mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 02:29:32 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dda51ba50 | ||
|
|
cd47304db9 | ||
|
|
6543590fea | ||
|
|
786181917d | ||
|
|
31c51c5ab6 |
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT token AS `token: UserToken`\nFROM users\nWHERE email_address = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0d2eadc64240b9ce5348b3b238b0ae6229df21697c952b08e930d03f97414cf4"
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,\n amount_usd_cents,\n event_type AS `event_type!: PaymentEventType`,\n maybe_credits_granted AS `maybe_credits_granted: u32`\nFROM user_spend_events\nWHERE maybe_user_token = ?\n AND payments_namespace = ?\n AND is_production = TRUE\nORDER BY payment_occurred_at ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payment_occurred_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "amount_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "event_type!: PaymentEventType",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_credits_granted: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1c997019b7ed18059bc08d857918c3ce3f5e6ed56d358894cbc496d345566519"
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n stripe_customer_id AS `stripe_customer_id!`,\n user_token AS `user_token!: UserToken`\nFROM user_stripe_customer_links\nWHERE payments_namespace = 'artcraft'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "stripe_customer_id!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2d49cfbafc29bce35f3e03135054c7521fb0182663aefa971245f91f33a6fc49"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_spend_events\nSET\n token = ?,\n payments_namespace = ?,\n maybe_user_token = ?,\n event_type = ?,\n amount_usd_cents = ?,\n maybe_credits_granted = ?,\n maybe_user_subscription_token = ?,\n maybe_wallet_ledger_entry_token = ?,\n payment_source = ?,\n source_object_id = ?,\n maybe_stripe_customer_id = ?,\n maybe_stripe_invoice_id = ?,\n maybe_stripe_payment_intent_id = ?,\n maybe_stripe_charge_id = ?,\n maybe_stripe_event_id = ?,\n is_production = ?,\n payment_occurred_at = ?\nON DUPLICATE KEY UPDATE token = token\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 17
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3e5a71cedc31cc656177c8331c2de1d1d2d2eb310aabeb7e6238943b073f6998"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_spend_summaries\nSET\n payments_namespace = ?,\n user_token = ?,\n lifetime_gross_spend_usd_cents = ?,\n lifetime_subscription_spend_usd_cents = ?,\n lifetime_credits_spend_usd_cents = ?,\n lifetime_refund_usd_cents = ?,\n lifetime_net_spend_usd_cents = ?,\n lifetime_payment_count = ?,\n lifetime_refund_count = ?,\n first_payment_at = ?,\n first_spend_usd_cents = ?,\n last_payment_at = ?,\n last_spend_usd_cents = ?,\n days_since_first_payment = ?,\n days_since_last_payment = ?,\n net_spend_7d_usd_cents = ?,\n net_spend_prev_7d_usd_cents = ?,\n net_spend_14d_usd_cents = ?,\n net_spend_prev_14d_usd_cents = ?,\n net_spend_30d_usd_cents = ?,\n net_spend_prev_30d_usd_cents = ?,\n net_spend_60d_usd_cents = ?,\n net_spend_90d_usd_cents = ?,\n net_spend_this_year_usd_cents = ?,\n avg_weekly_net_spend_4w_usd_cents = ?,\n avg_weekly_net_spend_12w_usd_cents = ?,\n active_weeks_in_last_4 = ?,\n active_weeks_in_last_8 = ?,\n active_weeks_in_last_12 = ?,\n active_weeks_in_last_24 = ?,\n active_weeks_in_last_52 = ?,\n consecutive_active_weeks = ?,\n consecutive_inactive_weeks = ?,\n weeks_since_last_spend = ?,\n is_active_subscriber = ?,\n maybe_subscription_interval = ?,\n maybe_reengagement_score = ?\nON DUPLICATE KEY UPDATE\n lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),\n lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),\n lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),\n lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),\n lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),\n lifetime_payment_count = VALUES(lifetime_payment_count),\n lifetime_refund_count = VALUES(lifetime_refund_count),\n first_payment_at = VALUES(first_payment_at),\n first_spend_usd_cents = VALUES(first_spend_usd_cents),\n last_payment_at = VALUES(last_payment_at),\n last_spend_usd_cents = VALUES(last_spend_usd_cents),\n days_since_first_payment = VALUES(days_since_first_payment),\n days_since_last_payment = VALUES(days_since_last_payment),\n net_spend_7d_usd_cents = VALUES(net_spend_7d_usd_cents),\n net_spend_prev_7d_usd_cents = VALUES(net_spend_prev_7d_usd_cents),\n net_spend_14d_usd_cents = VALUES(net_spend_14d_usd_cents),\n net_spend_prev_14d_usd_cents = VALUES(net_spend_prev_14d_usd_cents),\n net_spend_30d_usd_cents = VALUES(net_spend_30d_usd_cents),\n net_spend_prev_30d_usd_cents = VALUES(net_spend_prev_30d_usd_cents),\n net_spend_60d_usd_cents = VALUES(net_spend_60d_usd_cents),\n net_spend_90d_usd_cents = VALUES(net_spend_90d_usd_cents),\n net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),\n avg_weekly_net_spend_4w_usd_cents = VALUES(avg_weekly_net_spend_4w_usd_cents),\n avg_weekly_net_spend_12w_usd_cents = VALUES(avg_weekly_net_spend_12w_usd_cents),\n active_weeks_in_last_4 = VALUES(active_weeks_in_last_4),\n active_weeks_in_last_8 = VALUES(active_weeks_in_last_8),\n active_weeks_in_last_12 = VALUES(active_weeks_in_last_12),\n active_weeks_in_last_24 = VALUES(active_weeks_in_last_24),\n active_weeks_in_last_52 = VALUES(active_weeks_in_last_52),\n consecutive_active_weeks = VALUES(consecutive_active_weeks),\n consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),\n weeks_since_last_spend = VALUES(weeks_since_last_spend),\n is_active_subscriber = VALUES(is_active_subscriber),\n maybe_subscription_interval = VALUES(maybe_subscription_interval),\n maybe_reengagement_score = VALUES(maybe_reengagement_score)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 37
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "41cf2f8d64aa36fb930cc1bf8a46d37e0987b743438b44e1c55859fc6b6ee6a0"
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n s.user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n s.maybe_reengagement_score AS `reengagement_score!: u32`,\n s.lifetime_net_spend_usd_cents,\n s.last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,\n s.days_since_last_payment AS `maybe_days_since_last_payment: u32`,\n s.weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,\n s.is_active_subscriber AS `is_active_subscriber: bool`\nFROM user_spend_summaries s\nJOIN users u ON u.token = s.user_token\nWHERE s.payments_namespace = ?\n AND s.maybe_reengagement_score > 0\nORDER BY s.maybe_reengagement_score DESC, s.id ASC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_gravatar_hash!",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "reengagement_score!: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "lifetime_net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "maybe_last_payment_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_days_since_last_payment: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_weeks_since_last_spend: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_active_subscriber: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b1954301fc16703a04e2f63fd85c28bf2283bc1ecb5624107686dad0dfe364f"
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n user_token AS `user_token!: UserToken`,\n spend_date AS `spend_date!: NaiveDate`,\n subscription_spend_usd_cents,\n credits_spend_usd_cents,\n gross_spend_usd_cents,\n refund_usd_cents,\n net_spend_usd_cents,\n payment_count,\n credits_granted,\n created_at AS `created_at: DateTime<Utc>`,\n updated_at AS `updated_at: DateTime<Utc>`\nFROM user_daily_spends\nWHERE user_token = ?\n AND payments_namespace = ?\nORDER BY spend_date DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "spend_date!: NaiveDate",
|
||||
"type_info": {
|
||||
"type": "Date",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "subscription_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "credits_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "gross_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "refund_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "payment_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "credits_granted",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "77dca63cffdd18957cbc4be6b7d982a2599a2c06df07f8e34501a032cd688ead"
|
||||
}
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n maybe_user_token AS `user_token!: UserToken`,\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0\n AND event_type IN ('subscription_initial', 'subscription_renewal', 'subscription_proration_upgrade')\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `subscription_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 AND event_type = 'credit_pack_purchase'\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `credits_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents < 0 THEN -amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payment_occurred_at >= ?\n AND payment_occurred_at < ?\nGROUP BY maybe_user_token, payments_namespace\nHAVING SUM(CASE WHEN amount_usd_cents <> 0 THEN 1 ELSE 0 END) > 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "subscription_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "credits_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "gross_spend_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "refund_usd_cents!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "net_spend_usd_cents!: i64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "payment_count!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "credits_granted!: u64",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 21
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "83fc05a3594b379482745f851cd5a22786adde9968dd70490c91100cf21f8023"
|
||||
}
|
||||
-443
@@ -1,443 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n user_token AS `user_token!: UserToken`,\n lifetime_gross_spend_usd_cents,\n lifetime_subscription_spend_usd_cents,\n lifetime_credits_spend_usd_cents,\n lifetime_refund_usd_cents,\n lifetime_net_spend_usd_cents,\n lifetime_payment_count,\n lifetime_refund_count,\n first_payment_at AS `maybe_first_payment_at: DateTime<Utc>`,\n first_spend_usd_cents,\n last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,\n last_spend_usd_cents,\n days_since_first_payment AS `maybe_days_since_first_payment: u32`,\n days_since_last_payment AS `maybe_days_since_last_payment: u32`,\n net_spend_7d_usd_cents,\n net_spend_prev_7d_usd_cents,\n net_spend_14d_usd_cents,\n net_spend_prev_14d_usd_cents,\n net_spend_30d_usd_cents,\n net_spend_prev_30d_usd_cents,\n net_spend_60d_usd_cents,\n net_spend_90d_usd_cents,\n net_spend_this_year_usd_cents,\n avg_weekly_net_spend_4w_usd_cents,\n avg_weekly_net_spend_12w_usd_cents,\n active_weeks_in_last_4,\n active_weeks_in_last_8,\n active_weeks_in_last_12,\n active_weeks_in_last_24,\n active_weeks_in_last_52,\n consecutive_active_weeks,\n consecutive_inactive_weeks,\n weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,\n is_active_subscriber AS `is_active_subscriber: bool`,\n maybe_subscription_interval,\n maybe_reengagement_score AS `maybe_reengagement_score: u32`,\n created_at AS `created_at: DateTime<Utc>`,\n updated_at AS `updated_at: DateTime<Utc>`\nFROM user_spend_summaries\nWHERE payments_namespace = ?\n AND user_token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "lifetime_gross_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "lifetime_subscription_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "lifetime_credits_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "lifetime_refund_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "lifetime_net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "lifetime_payment_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "lifetime_refund_count",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_first_payment_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "first_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "maybe_last_payment_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "last_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_days_since_first_payment: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_days_since_last_payment: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "net_spend_7d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net_spend_prev_7d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "net_spend_14d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "net_spend_prev_14d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "net_spend_30d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "net_spend_prev_30d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "net_spend_60d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "net_spend_90d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "net_spend_this_year_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "avg_weekly_net_spend_4w_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "avg_weekly_net_spend_12w_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "active_weeks_in_last_4",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "active_weeks_in_last_8",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "active_weeks_in_last_12",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "active_weeks_in_last_24",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "active_weeks_in_last_52",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "consecutive_active_weeks",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "consecutive_inactive_weeks",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"name": "maybe_weeks_since_last_spend: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "is_active_subscriber: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "maybe_subscription_interval",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 36,
|
||||
"name": "maybe_reengagement_score: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 37,
|
||||
"name": "created_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 38,
|
||||
"name": "updated_at: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9715e4dc2ec6e596fdb91ecc50a2b80f63494bbc0c9df6c0a57ede2360716869"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO user_daily_spends\nSET\n user_token = ?,\n payments_namespace = ?,\n spend_date = ?,\n subscription_spend_usd_cents = ?,\n credits_spend_usd_cents = ?,\n gross_spend_usd_cents = ?,\n refund_usd_cents = ?,\n net_spend_usd_cents = ?,\n payment_count = ?,\n credits_granted = ?\nON DUPLICATE KEY UPDATE\n subscription_spend_usd_cents = VALUES(subscription_spend_usd_cents),\n credits_spend_usd_cents = VALUES(credits_spend_usd_cents),\n gross_spend_usd_cents = VALUES(gross_spend_usd_cents),\n refund_usd_cents = VALUES(refund_usd_cents),\n net_spend_usd_cents = VALUES(net_spend_usd_cents),\n payment_count = VALUES(payment_count),\n credits_granted = VALUES(credits_granted)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b7a5fea76da70c03b8744c985940a5bcdabdb3316aa407c5ca883bca863ee633"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT DISTINCT maybe_user_token AS `user_token!: UserToken`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payments_namespace = ?\n AND maybe_user_token > ?\nORDER BY maybe_user_token\nLIMIT ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bcd07050d5557bf59d6b2691478763b5b067447d295b002f6e766209ea077f42"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT DISTINCT payments_namespace AS `payments_namespace!: PaymentsNamespace`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "payments_namespace!: PaymentsNamespace",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cf8c702126bba73cd3abc2ed7202962b6da773460505222a75408dab1d26b70f"
|
||||
}
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n e.token AS `token!: UserSpendEventToken`,\n e.payments_namespace AS `payments_namespace!`,\n e.maybe_user_token AS `maybe_user_token: UserToken`,\n u.username AS `maybe_username?`,\n u.display_name AS `maybe_display_name?`,\n u.email_gravatar_hash AS `maybe_email_gravatar_hash?`,\n e.event_type AS `event_type!`,\n e.amount_usd_cents,\n e.maybe_credits_granted AS `maybe_credits_granted: u32`,\n e.payment_source AS `payment_source!`,\n e.source_object_id AS `maybe_source_object_id`,\n e.maybe_stripe_invoice_id,\n e.maybe_stripe_payment_intent_id,\n e.maybe_stripe_charge_id,\n e.maybe_stripe_customer_id,\n e.is_production AS `is_production: bool`,\n e.payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,\n e.created_at AS `created_at!: DateTime<Utc>`\nFROM user_spend_events e\nLEFT JOIN users u ON u.token = e.maybe_user_token\nWHERE (? IS NULL OR e.payments_namespace = ?)\nORDER BY e.payment_occurred_at DESC, e.id DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token!: UserSpendEventToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "payments_namespace!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "maybe_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_username?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_display_name?",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "maybe_email_gravatar_hash?",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "event_type!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "amount_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "maybe_credits_granted: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "payment_source!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_source_object_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "maybe_stripe_invoice_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "maybe_stripe_payment_intent_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "maybe_stripe_charge_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "maybe_stripe_customer_id",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "is_production: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "payment_occurred_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d5d3a59ea4885868b26168cd305f6168a1e62fe9f8fa663063591335c645b927"
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n wle.maybe_entity_ref AS `stripe_object_id!`,\n wle.entry_type AS `entry_type!`,\n wle.token AS `ledger_token!: WalletLedgerEntryToken`,\n w.owner_user_token AS `owner_user_token!: UserToken`,\n wle.credits_delta AS `credits_delta!`,\n wle.created_at AS `created_at!: DateTime<Utc>`\nFROM wallet_ledger_entries wle\nJOIN wallets w ON w.token = wle.wallet_token\nWHERE w.wallet_namespace = 'artcraft'\n AND wle.entry_type IN ('credit_banked', 'credit_monthly')\n AND wle.created_at >= ?\n AND (wle.maybe_entity_ref LIKE 'pi\\_%' OR wle.maybe_entity_ref LIKE 'in\\_%')\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "stripe_object_id!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "entry_type!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "ledger_token!: WalletLedgerEntryToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "owner_user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "credits_delta!",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "de69f3d564830f8fff90f754d13982de623fcda50118d261466eeaf034de5ecb"
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n s.user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n s.lifetime_net_spend_usd_cents,\n s.net_spend_7d_usd_cents,\n s.net_spend_14d_usd_cents,\n s.net_spend_30d_usd_cents,\n s.net_spend_60d_usd_cents,\n s.net_spend_90d_usd_cents,\n s.net_spend_this_year_usd_cents,\n s.is_active_subscriber AS `is_active_subscriber: bool`,\n s.maybe_reengagement_score AS `maybe_reengagement_score: u32`\nFROM user_spend_summaries s\nJOIN users u ON u.token = s.user_token\nWHERE s.payments_namespace = ?\nORDER BY\n (CASE ?\n WHEN '7d' THEN s.net_spend_7d_usd_cents\n WHEN '14d' THEN s.net_spend_14d_usd_cents\n WHEN '30d' THEN s.net_spend_30d_usd_cents\n WHEN '60d' THEN s.net_spend_60d_usd_cents\n WHEN '90d' THEN s.net_spend_90d_usd_cents\n WHEN '1y' THEN s.net_spend_this_year_usd_cents\n ELSE CAST(s.lifetime_net_spend_usd_cents AS SIGNED)\n END) DESC,\n s.id ASC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token!: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_gravatar_hash!",
|
||||
"type_info": {
|
||||
"type": "String",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "lifetime_net_spend_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL | UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "net_spend_7d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "net_spend_14d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "net_spend_30d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "net_spend_60d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "net_spend_90d_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "net_spend_this_year_usd_cents",
|
||||
"type_info": {
|
||||
"type": "LongLong",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "is_active_subscriber: bool",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "maybe_reengagement_score: u32",
|
||||
"type_info": {
|
||||
"type": "Long",
|
||||
"flags": "UNSIGNED",
|
||||
"char_set": 63,
|
||||
"max_size": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f6db2f3bf8695fa8431db209576328154b8ac20aa6e899739095b6206bb38944"
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n maybe_stripe_subscription_status,\n maybe_stripe_recurring_interval,\n subscription_expires_at AS `subscription_expires_at!: DateTime<Utc>`\nFROM user_subscriptions\nWHERE user_token = ?\n AND subscription_namespace = ?\nORDER BY subscription_expires_at DESC\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "maybe_stripe_subscription_status",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "maybe_stripe_recurring_interval",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "subscription_expires_at!: DateTime<Utc>",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f74615faa4321ec4d583829997e12cb8ae152afefd2574385c120c63278893d2"
|
||||
}
|
||||
Generated
-32
@@ -3049,8 +3049,6 @@ dependencies = [
|
||||
"futures 0.3.32",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
"reqwest 0.12.23",
|
||||
"rustls 0.23.36",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -13704,36 +13702,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "user-spend-analytics-job"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"actix_helpers",
|
||||
"anyhow",
|
||||
"bootstrap",
|
||||
"chrono",
|
||||
"concurrency",
|
||||
"easyenv",
|
||||
"enums",
|
||||
"errors",
|
||||
"jobs_common",
|
||||
"log",
|
||||
"mysql_queries",
|
||||
"pager",
|
||||
"rootly_client",
|
||||
"rootly_config",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"shared_env_var_config",
|
||||
"sqlx",
|
||||
"tokens",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "user_input_common"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -136,7 +136,6 @@ members = [
|
||||
"crates/service/job/grok_api_job",
|
||||
"crates/service/job/seedance2_pro_job",
|
||||
"crates/service/job/video_thumbnail_job",
|
||||
"crates/service/job/user_spend_analytics_job",
|
||||
"crates/service/job/worldlabs_job",
|
||||
"crates/service/web/dummy_service",
|
||||
"crates/service/web/storyteller_web",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_spend_events;
|
||||
@@ -1,103 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Append-only ledger of real money movements (USD cents), one row per settled
|
||||
-- event. Derived from settled Stripe webhooks (invoice.paid, payment_intent.
|
||||
-- succeeded / checkout.session.completed, charge.refunded, charge.dispute.*),
|
||||
-- NOT from every webhook. Idempotent via uq_source_object so retries never
|
||||
-- double-count. We always work in USD via Stripe, so there are no currency or
|
||||
-- fee columns.
|
||||
CREATE TABLE user_spend_events (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective public primary key. Token prefix "spend_".
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Multi-site segregation. Reuses the existing PaymentsNamespace enum.
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Who paid. Nullable so we can record money we can't yet attribute and
|
||||
-- backfill -- but expect this set ~always.
|
||||
maybe_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- PaymentEventType enum. Splits subscription vs credit-pack revenue:
|
||||
-- 'subscription_initial' -- first paid invoice of a new subscription
|
||||
-- 'subscription_renewal' -- recurring paid invoice (monthly, or annual once/yr)
|
||||
-- 'subscription_proration_upgrade' -- mid-cycle upgrade proration charge (positive)
|
||||
-- 'subscription_proration_downgrade' -- mid-cycle downgrade proration credit (rare as a settled event)
|
||||
-- 'credit_pack_purchase' -- one-time credit pack (the ~10x-volume case)
|
||||
-- 'refund' -- refund (amount negative)
|
||||
-- 'chargeback' -- dispute/chargeback (amount negative)
|
||||
-- 'manual_adjustment' -- staff/console correction (payment_source = 'manual')
|
||||
-- 'subscription_monthly_refill' -- OPTIONAL non-revenue event for annual-plan
|
||||
-- monthly credit refills (amount 0)
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== MONEY ==========
|
||||
|
||||
-- USD cents. SIGNED: refunds/chargebacks negative; refills are 0. BIGINT so
|
||||
-- downstream lifetime sums never overflow.
|
||||
amount_usd_cents BIGINT NOT NULL,
|
||||
|
||||
-- For credit-pack purchases / subscription grants/refills: how many of OUR
|
||||
-- credits this granted. Lets us correlate dollars-in vs credits-granted vs
|
||||
-- credits-used. NULL for refunds/chargebacks.
|
||||
maybe_credits_granted INTEGER UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ========== INTERNAL LINKAGE ==========
|
||||
|
||||
-- The subscription this payment belongs to, if any (-> user_subscriptions.token).
|
||||
maybe_user_subscription_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- The credit-grant ledger entry this produced, if any
|
||||
-- (-> wallet_ledger_entries.token). Ties a dollar event to its credit effect.
|
||||
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== SOURCE / PROVIDER ==========
|
||||
|
||||
-- PaymentSource enum: 'stripe' or 'manual'. (USD/Stripe only for the
|
||||
-- foreseeable future; 'manual' covers staff/console adjustments.)
|
||||
payment_source VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authoritative object this row represents -- the dedup anchor. NULLABLE.
|
||||
-- For Stripe: invoice id (subscriptions), payment_intent id (one-time), or
|
||||
-- refund/dispute id (refunds). For payment_source='manual' (no provider
|
||||
-- object) leave it NULL. MySQL allows multiple NULLs in a unique index, so
|
||||
-- manual rows freely duplicate, while any present id is still deduped.
|
||||
source_object_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Convenience copies for joins/forensics (255 + utf8mb4_bin for Stripe IDs).
|
||||
maybe_stripe_customer_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_invoice_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_charge_id VARCHAR(255) DEFAULT NULL,
|
||||
-- The webhook event that caused this row (provenance only).
|
||||
maybe_stripe_event_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Stripe livemode. Keep test-mode money out of dashboards.
|
||||
is_production BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
-- When the money actually moved (Stripe settlement time). Bin/aggregate on
|
||||
-- THIS, not created_at (which is when we ingested the webhook).
|
||||
payment_occurred_at TIMESTAMP NOT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
-- Idempotency: one row per settled object. NULL source_object_id rows (manual)
|
||||
-- are exempt -- MySQL treats each NULL in a unique index as distinct.
|
||||
UNIQUE KEY uq_source_object (payment_source, source_object_id),
|
||||
KEY idx_user_occurred (maybe_user_token, payment_occurred_at),
|
||||
KEY idx_occurred (payment_occurred_at),
|
||||
KEY idx_namespace_occurred (payments_namespace, payment_occurred_at),
|
||||
KEY idx_event_type (event_type),
|
||||
KEY idx_stripe_customer (maybe_stripe_customer_id),
|
||||
KEY idx_stripe_invoice (maybe_stripe_invoice_id)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_daily_spends;
|
||||
@@ -1,44 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Per-user, per-day spend rollup (L1). Net = gross - refunds. Subscription vs
|
||||
-- credit-pack splits expose the revenue mix per day without touching the event
|
||||
-- table. Sparsely populated -- no row for days with zero spend and zero refunds.
|
||||
-- Derived from (and fully rebuildable from) user_spend_events.
|
||||
CREATE TABLE user_daily_spends (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Reporting day. Bins user_spend_events.payment_occurred_at in a FIXED tz
|
||||
-- (UTC). DATE, not TIMESTAMP.
|
||||
spend_date DATE NOT NULL,
|
||||
|
||||
-- Revenue mix (subset of gross). Category totals can never go negative, so
|
||||
-- they're UNSIGNED.
|
||||
subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- sum of positive amounts (UNSIGNED)
|
||||
refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- refund/chargeback magnitude (UNSIGNED)
|
||||
-- NET is the ONE field here that must be SIGNED: a single day can net negative
|
||||
-- when refunds (often of an earlier day's purchase) exceed that day's purchases.
|
||||
net_spend_usd_cents BIGINT NOT NULL DEFAULT 0, -- gross - refund (SIGNED)
|
||||
|
||||
payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # positive money events
|
||||
credits_granted BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user_day (user_token, payments_namespace, spend_date),
|
||||
-- "Top spenders / spend in a date range": filter by date, then group by user.
|
||||
KEY idx_day_user_net (user_token, payments_namespace, spend_date, net_spend_usd_cents),
|
||||
KEY idx_user_day (user_token, spend_date)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_spend_summaries;
|
||||
@@ -1,95 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Per-user spend summary (L2). Lifetime/recency facts are event-maintained (they
|
||||
-- only grow); sliding windows and weekly-rhythm metrics are job-maintained (they
|
||||
-- shift even on days a user doesn't pay). Powers churn / dip / re-engagement
|
||||
-- dashboards in O(1). Derived from (and fully rebuildable from) user_spend_events
|
||||
-- and user_daily_spends.
|
||||
CREATE TABLE user_spend_summaries (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ===== Lifetime (event-maintained) =====
|
||||
-- Gross / category / refund totals are UNSIGNED. Lifetime net is UNSIGNED too:
|
||||
-- over a lifetime you can never refund more than was paid.
|
||||
lifetime_gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_net_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- gross - refund
|
||||
-- Event COUNTS (not money): number of payment events vs refund/chargeback
|
||||
-- events over the user's lifetime. Both event-maintained.
|
||||
lifetime_payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of positive money events
|
||||
lifetime_refund_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of refund/chargeback events
|
||||
first_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
first_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the first payment
|
||||
last_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
last_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the most recent payment
|
||||
days_since_first_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
days_since_last_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
|
||||
-- ===== Sliding NET spend windows -- SIGNED, a window can net negative
|
||||
-- (job-refreshed from user_daily_spends) =====
|
||||
-- "prev" = the equally-sized window immediately before, for WoW/MoM deltas.
|
||||
net_spend_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_14d_usd_cents BIGINT NOT NULL DEFAULT 0, -- bi-weekly
|
||||
net_spend_prev_14d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_60d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_90d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_this_year_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- ===== Weekly cadence / rhythm (job-refreshed) -- for the sales team =====
|
||||
-- A "week" is Mon-Sun UTC. These let sales spot users breaking their habit.
|
||||
-- Per-user weekly baselines at two horizons: 4w is responsive (recent norm),
|
||||
-- 12w is stable (established norm). 4w well below 12w is itself a cooling-off
|
||||
-- signal. Mean weekly NET spend over the trailing window (calendar weeks,
|
||||
-- zero-spend weeks included). SIGNED, like the window nets.
|
||||
avg_weekly_net_spend_4w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
avg_weekly_net_spend_12w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
-- How many of the last N calendar weeks had any net spend -- a regularity
|
||||
-- measure at several horizons.
|
||||
active_weeks_in_last_4 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..4
|
||||
active_weeks_in_last_8 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..8
|
||||
active_weeks_in_last_12 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..12
|
||||
active_weeks_in_last_24 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..24
|
||||
active_weeks_in_last_52 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..52
|
||||
consecutive_active_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "streak"
|
||||
consecutive_inactive_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "dip" length
|
||||
-- Recency in weeks. Subsumes the entire "is_active_spender_last_N_weeks"
|
||||
-- family: "active in the last N weeks" == (weeks_since_last_spend < N). So we
|
||||
-- intentionally store NO is_active_* booleans (redundant + poor index
|
||||
-- selectivity); we index this field instead and range-query any N. NULL = never spent.
|
||||
weeks_since_last_spend INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ===== Flags =====
|
||||
is_active_subscriber BOOLEAN NOT NULL DEFAULT FALSE, -- from user_subscriptions
|
||||
maybe_subscription_interval VARCHAR(16) DEFAULT NULL, -- 'month' / 'year' (annual plans behave differently)
|
||||
-- Optional precomputed re-engagement priority (job-set).
|
||||
maybe_reengagement_score INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user (payments_namespace, user_token),
|
||||
KEY idx_lifetime (payments_namespace, lifetime_net_spend_usd_cents),
|
||||
KEY idx_last_payment_at (payments_namespace, last_payment_at),
|
||||
KEY idx_first_payment_at (payments_namespace, first_payment_at),
|
||||
KEY idx_spend_7d (payments_namespace, net_spend_7d_usd_cents),
|
||||
KEY idx_spend_30d (payments_namespace, net_spend_30d_usd_cents),
|
||||
KEY idx_reengagement (payments_namespace, maybe_reengagement_score),
|
||||
-- Sort / range-filter by recency. idx_weeks_since_last_spend also powers every
|
||||
-- "active in the last N weeks" query (weeks_since_last_spend < N).
|
||||
KEY idx_weeks_since_last_spend (payments_namespace, weeks_since_last_spend),
|
||||
KEY idx_days_since_last_payment (payments_namespace, days_since_last_payment)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,47 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
|
||||
-- with comments attached to the fields for centralized documentation.
|
||||
|
||||
-- Per-user, per-day spend rollup (L1). Net = gross - refunds. Subscription vs
|
||||
-- credit-pack splits expose the revenue mix per day without touching the event
|
||||
-- table. Sparsely populated -- no row for days with zero spend and zero refunds.
|
||||
-- Derived from (and fully rebuildable from) user_spend_events.
|
||||
CREATE TABLE user_daily_spends (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Reporting day. Bins user_spend_events.payment_occurred_at in a FIXED tz
|
||||
-- (UTC). DATE, not TIMESTAMP.
|
||||
spend_date DATE NOT NULL,
|
||||
|
||||
-- Revenue mix (subset of gross). Category totals can never go negative, so
|
||||
-- they're UNSIGNED.
|
||||
subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- sum of positive amounts (UNSIGNED)
|
||||
refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- refund/chargeback magnitude (UNSIGNED)
|
||||
-- NET is the ONE field here that must be SIGNED: a single day can net negative
|
||||
-- when refunds (often of an earlier day's purchase) exceed that day's purchases.
|
||||
net_spend_usd_cents BIGINT NOT NULL DEFAULT 0, -- gross - refund (SIGNED)
|
||||
|
||||
payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # positive money events
|
||||
credits_granted BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user_day (user_token, payments_namespace, spend_date),
|
||||
-- "Top spenders / spend in a date range": filter by date, then group by user.
|
||||
KEY idx_day_user_net (user_token, payments_namespace, spend_date, net_spend_usd_cents),
|
||||
KEY idx_user_day (user_token, spend_date)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,106 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
|
||||
-- with comments attached to the fields for centralized documentation.
|
||||
|
||||
-- Append-only ledger of real money movements (USD cents), one row per settled
|
||||
-- event. Derived from settled Stripe webhooks (invoice.paid, payment_intent.
|
||||
-- succeeded / checkout.session.completed, charge.refunded, charge.dispute.*),
|
||||
-- NOT from every webhook. Idempotent via uq_source_object so retries never
|
||||
-- double-count. We always work in USD via Stripe, so there are no currency or
|
||||
-- fee columns.
|
||||
CREATE TABLE user_spend_events (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective public primary key. Token prefix "spend_".
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Multi-site segregation. Reuses the existing PaymentsNamespace enum.
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Who paid. Nullable so we can record money we can't yet attribute and
|
||||
-- backfill -- but expect this set ~always.
|
||||
maybe_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- PaymentEventType enum. Splits subscription vs credit-pack revenue:
|
||||
-- 'subscription_initial' -- first paid invoice of a new subscription
|
||||
-- 'subscription_renewal' -- recurring paid invoice (monthly, or annual once/yr)
|
||||
-- 'subscription_proration_upgrade' -- mid-cycle upgrade proration charge (positive)
|
||||
-- 'subscription_proration_downgrade' -- mid-cycle downgrade proration credit (rare as a settled event)
|
||||
-- 'credit_pack_purchase' -- one-time credit pack (the ~10x-volume case)
|
||||
-- 'refund' -- refund (amount negative)
|
||||
-- 'chargeback' -- dispute/chargeback (amount negative)
|
||||
-- 'manual_adjustment' -- staff/console correction (payment_source = 'manual')
|
||||
-- 'subscription_monthly_refill' -- OPTIONAL non-revenue event for annual-plan
|
||||
-- monthly credit refills (amount 0)
|
||||
event_type VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== MONEY ==========
|
||||
|
||||
-- USD cents. SIGNED: refunds/chargebacks negative; refills are 0. BIGINT so
|
||||
-- downstream lifetime sums never overflow.
|
||||
amount_usd_cents BIGINT NOT NULL,
|
||||
|
||||
-- For credit-pack purchases / subscription grants/refills: how many of OUR
|
||||
-- credits this granted. Lets us correlate dollars-in vs credits-granted vs
|
||||
-- credits-used. NULL for refunds/chargebacks.
|
||||
maybe_credits_granted INTEGER UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ========== INTERNAL LINKAGE ==========
|
||||
|
||||
-- The subscription this payment belongs to, if any (-> user_subscriptions.token).
|
||||
maybe_user_subscription_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- The credit-grant ledger entry this produced, if any
|
||||
-- (-> wallet_ledger_entries.token). Ties a dollar event to its credit effect.
|
||||
maybe_wallet_ledger_entry_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== SOURCE / PROVIDER ==========
|
||||
|
||||
-- PaymentSource enum: 'stripe' or 'manual'. (USD/Stripe only for the
|
||||
-- foreseeable future; 'manual' covers staff/console adjustments.)
|
||||
payment_source VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authoritative object this row represents -- the dedup anchor. NULLABLE.
|
||||
-- For Stripe: invoice id (subscriptions), payment_intent id (one-time), or
|
||||
-- refund/dispute id (refunds). For payment_source='manual' (no provider
|
||||
-- object) leave it NULL. MySQL allows multiple NULLs in a unique index, so
|
||||
-- manual rows freely duplicate, while any present id is still deduped.
|
||||
source_object_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Convenience copies for joins/forensics (255 + utf8mb4_bin for Stripe IDs).
|
||||
maybe_stripe_customer_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_invoice_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
maybe_stripe_charge_id VARCHAR(255) DEFAULT NULL,
|
||||
-- The webhook event that caused this row (provenance only).
|
||||
maybe_stripe_event_id VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Stripe livemode. Keep test-mode money out of dashboards.
|
||||
is_production BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
-- When the money actually moved (Stripe settlement time). Bin/aggregate on
|
||||
-- THIS, not created_at (which is when we ingested the webhook).
|
||||
payment_occurred_at TIMESTAMP NOT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
-- Idempotency: one row per settled object. NULL source_object_id rows (manual)
|
||||
-- are exempt -- MySQL treats each NULL in a unique index as distinct.
|
||||
UNIQUE KEY uq_source_object (payment_source, source_object_id),
|
||||
KEY idx_user_occurred (maybe_user_token, payment_occurred_at),
|
||||
KEY idx_occurred (payment_occurred_at),
|
||||
KEY idx_namespace_occurred (payments_namespace, payment_occurred_at),
|
||||
KEY idx_event_type (event_type),
|
||||
KEY idx_stripe_customer (maybe_stripe_customer_id),
|
||||
KEY idx_stripe_invoice (maybe_stripe_invoice_id)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,98 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- NB: This is a manually squashed view of all the CREATE and ALTER statements,
|
||||
-- with comments attached to the fields for centralized documentation.
|
||||
|
||||
-- Per-user spend summary (L2). Lifetime/recency facts are event-maintained (they
|
||||
-- only grow); sliding windows and weekly-rhythm metrics are job-maintained (they
|
||||
-- shift even on days a user doesn't pay). Powers churn / dip / re-engagement
|
||||
-- dashboards in O(1). Derived from (and fully rebuildable from) user_spend_events
|
||||
-- and user_daily_spends.
|
||||
CREATE TABLE user_spend_summaries (
|
||||
-- Replication only.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
payments_namespace VARCHAR(32) NOT NULL,
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ===== Lifetime (event-maintained) =====
|
||||
-- Gross / category / refund totals are UNSIGNED. Lifetime net is UNSIGNED too:
|
||||
-- over a lifetime you can never refund more than was paid.
|
||||
lifetime_gross_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_subscription_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_credits_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- revenue mix split
|
||||
lifetime_refund_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lifetime_net_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- gross - refund
|
||||
-- Event COUNTS (not money): number of payment events vs refund/chargeback
|
||||
-- events over the user's lifetime. Both event-maintained.
|
||||
lifetime_payment_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of positive money events
|
||||
lifetime_refund_count INT UNSIGNED NOT NULL DEFAULT 0, -- # of refund/chargeback events
|
||||
first_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
first_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the first payment
|
||||
last_payment_at TIMESTAMP NULL DEFAULT NULL,
|
||||
last_spend_usd_cents BIGINT UNSIGNED NOT NULL DEFAULT 0, -- amount of the most recent payment
|
||||
days_since_first_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
days_since_last_payment INT UNSIGNED DEFAULT NULL, -- job-refreshed
|
||||
|
||||
-- ===== Sliding NET spend windows -- SIGNED, a window can net negative
|
||||
-- (job-refreshed from user_daily_spends) =====
|
||||
-- "prev" = the equally-sized window immediately before, for WoW/MoM deltas.
|
||||
net_spend_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_7d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_14d_usd_cents BIGINT NOT NULL DEFAULT 0, -- bi-weekly
|
||||
net_spend_prev_14d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_prev_30d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_60d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_90d_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
net_spend_this_year_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- ===== Weekly cadence / rhythm (job-refreshed) -- for the sales team =====
|
||||
-- A "week" is Mon-Sun UTC. These let sales spot users breaking their habit.
|
||||
-- Per-user weekly baselines at two horizons: 4w is responsive (recent norm),
|
||||
-- 12w is stable (established norm). 4w well below 12w is itself a cooling-off
|
||||
-- signal. Mean weekly NET spend over the trailing window (calendar weeks,
|
||||
-- zero-spend weeks included). SIGNED, like the window nets.
|
||||
avg_weekly_net_spend_4w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
avg_weekly_net_spend_12w_usd_cents BIGINT NOT NULL DEFAULT 0,
|
||||
-- How many of the last N calendar weeks had any net spend -- a regularity
|
||||
-- measure at several horizons.
|
||||
active_weeks_in_last_4 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..4
|
||||
active_weeks_in_last_8 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..8
|
||||
active_weeks_in_last_12 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..12
|
||||
active_weeks_in_last_24 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..24
|
||||
active_weeks_in_last_52 TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0..52
|
||||
consecutive_active_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "streak"
|
||||
consecutive_inactive_weeks INT UNSIGNED NOT NULL DEFAULT 0, -- current "dip" length
|
||||
-- Recency in weeks. Subsumes the entire "is_active_spender_last_N_weeks"
|
||||
-- family: "active in the last N weeks" == (weeks_since_last_spend < N). So we
|
||||
-- intentionally store NO is_active_* booleans (redundant + poor index
|
||||
-- selectivity); we index this field instead and range-query any N. NULL = never spent.
|
||||
weeks_since_last_spend INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- ===== Flags =====
|
||||
is_active_subscriber BOOLEAN NOT NULL DEFAULT FALSE, -- from user_subscriptions
|
||||
maybe_subscription_interval VARCHAR(16) DEFAULT NULL, -- 'month' / 'year' (annual plans behave differently)
|
||||
-- Optional precomputed re-engagement priority (job-set).
|
||||
maybe_reengagement_score INT UNSIGNED DEFAULT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_user (payments_namespace, user_token),
|
||||
KEY idx_lifetime (payments_namespace, lifetime_net_spend_usd_cents),
|
||||
KEY idx_last_payment_at (payments_namespace, last_payment_at),
|
||||
KEY idx_first_payment_at (payments_namespace, first_payment_at),
|
||||
KEY idx_spend_7d (payments_namespace, net_spend_7d_usd_cents),
|
||||
KEY idx_spend_30d (payments_namespace, net_spend_30d_usd_cents),
|
||||
KEY idx_reengagement (payments_namespace, maybe_reengagement_score),
|
||||
-- Sort / range-filter by recency. idx_weeks_since_last_spend also powers every
|
||||
-- "active in the last N weeks" query (weeks_since_last_spend < N).
|
||||
KEY idx_weeks_since_last_spend (payments_namespace, weeks_since_last_spend),
|
||||
KEY idx_days_since_last_payment (payments_namespace, days_since_last_payment)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -138,7 +138,6 @@ RUN SQLX_OFFLINE=true \
|
||||
$HOME/.cargo/bin/cargo build \
|
||||
--release \
|
||||
--bin storyteller-web \
|
||||
--bin user-spend-analytics-job \
|
||||
--bin seedance2-pro-job \
|
||||
--bin worldlabs-job \
|
||||
--bin video-thumbnail-job \
|
||||
@@ -191,7 +190,6 @@ COPY --from=builder /tmp/target/release/analytics-job /
|
||||
COPY --from=builder /tmp/target/release/email-sender-job /
|
||||
COPY --from=builder /tmp/target/release/es-update-job /
|
||||
COPY --from=builder /tmp/target/release/grok-api-job /
|
||||
COPY --from=builder /tmp/target/release/user-spend-analytics-job /
|
||||
|
||||
# Legacy apps:
|
||||
# COPY --from=builder /tmp/target/release/tts-download-job /
|
||||
|
||||
@@ -5,6 +5,3 @@ pub mod user_referrals;
|
||||
pub mod user_stripe_data;
|
||||
pub mod wallet_ledger_entries;
|
||||
pub mod wallets;
|
||||
pub mod user_daily_spends;
|
||||
pub mod user_spend_summaries;
|
||||
pub mod user_spend_events;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod user_daily_spends_list;
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_USER_DAILY_SPENDS_PATH: &str =
|
||||
"/v1/moderation/user_daily_spends/user/{user_token}";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorUserDailySpendsQueryParams {
|
||||
/// Page size (default 200, max 5000).
|
||||
pub limit: Option<u32>,
|
||||
/// Row offset for pagination. Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorUserDailySpendsResponse {
|
||||
pub success: bool,
|
||||
pub user_token: UserToken,
|
||||
pub records: Vec<UserDailySpendEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserDailySpendEntry {
|
||||
pub payments_namespace: String,
|
||||
pub spend_date: NaiveDate,
|
||||
pub subscription_spend_usd_cents: u64,
|
||||
pub credits_spend_usd_cents: u64,
|
||||
pub gross_spend_usd_cents: u64,
|
||||
pub refund_usd_cents: u64,
|
||||
pub net_spend_usd_cents: i64,
|
||||
pub payment_count: u32,
|
||||
pub credits_granted: u64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::user_spend_events::UserSpendEventToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_LIST_USER_SPEND_EVENTS_PATH: &str =
|
||||
"/v1/moderation/user_spend_events/list";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorListUserSpendEventsQueryParams {
|
||||
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorListUserSpendEventsResponse {
|
||||
pub success: bool,
|
||||
pub events: Vec<UserSpendEventListEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserSpendEventListEntry {
|
||||
pub token: UserSpendEventToken,
|
||||
pub payments_namespace: String,
|
||||
pub maybe_user_token: Option<UserToken>,
|
||||
pub maybe_username: Option<String>,
|
||||
pub maybe_display_name: Option<String>,
|
||||
pub maybe_email_gravatar_hash: Option<String>,
|
||||
pub event_type: String,
|
||||
pub amount_usd_cents: i64,
|
||||
pub maybe_credits_granted: Option<u32>,
|
||||
pub payment_source: String,
|
||||
pub maybe_source_object_id: Option<String>,
|
||||
pub maybe_stripe_invoice_id: Option<String>,
|
||||
pub maybe_stripe_payment_intent_id: Option<String>,
|
||||
pub maybe_stripe_charge_id: Option<String>,
|
||||
pub maybe_stripe_customer_id: Option<String>,
|
||||
pub is_production: bool,
|
||||
pub payment_occurred_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod list;
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_GET_USER_SPEND_SUMMARY_PATH: &str =
|
||||
"/v1/moderation/user_spend_summaries/summary/{user_token}";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorGetUserSpendSummaryQueryParams {
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorGetUserSpendSummaryResponse {
|
||||
pub success: bool,
|
||||
/// `None` if the user has no spend summary row.
|
||||
pub maybe_summary: Option<UserSpendSummaryView>,
|
||||
}
|
||||
|
||||
/// The full `user_spend_summaries` record for a user.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserSpendSummaryView {
|
||||
pub payments_namespace: String,
|
||||
pub user_token: UserToken,
|
||||
|
||||
pub lifetime_gross_spend_usd_cents: u64,
|
||||
pub lifetime_subscription_spend_usd_cents: u64,
|
||||
pub lifetime_credits_spend_usd_cents: u64,
|
||||
pub lifetime_refund_usd_cents: u64,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub lifetime_payment_count: u32,
|
||||
pub lifetime_refund_count: u32,
|
||||
pub maybe_first_payment_at: Option<DateTime<Utc>>,
|
||||
pub first_spend_usd_cents: u64,
|
||||
pub maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
pub last_spend_usd_cents: u64,
|
||||
pub maybe_days_since_first_payment: Option<u32>,
|
||||
pub maybe_days_since_last_payment: Option<u32>,
|
||||
|
||||
pub net_spend_7d_usd_cents: i64,
|
||||
pub net_spend_prev_7d_usd_cents: i64,
|
||||
pub net_spend_14d_usd_cents: i64,
|
||||
pub net_spend_prev_14d_usd_cents: i64,
|
||||
pub net_spend_30d_usd_cents: i64,
|
||||
pub net_spend_prev_30d_usd_cents: i64,
|
||||
pub net_spend_60d_usd_cents: i64,
|
||||
pub net_spend_90d_usd_cents: i64,
|
||||
pub net_spend_this_year_usd_cents: i64,
|
||||
|
||||
pub avg_weekly_net_spend_4w_usd_cents: i64,
|
||||
pub avg_weekly_net_spend_12w_usd_cents: i64,
|
||||
pub active_weeks_in_last_4: u8,
|
||||
pub active_weeks_in_last_8: u8,
|
||||
pub active_weeks_in_last_12: u8,
|
||||
pub active_weeks_in_last_24: u8,
|
||||
pub active_weeks_in_last_52: u8,
|
||||
pub consecutive_active_weeks: u32,
|
||||
pub consecutive_inactive_weeks: u32,
|
||||
pub maybe_weeks_since_last_spend: Option<u32>,
|
||||
|
||||
pub is_active_subscriber: bool,
|
||||
pub maybe_subscription_interval: Option<String>,
|
||||
pub maybe_reengagement_score: Option<u32>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
pub mod get_summary;
|
||||
pub mod reengagement_list;
|
||||
pub mod top_users_list;
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_REENGAGEMENT_LIST_PATH: &str =
|
||||
"/v1/moderation/user_spend_summaries/reengagement_list";
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorReengagementListQueryParams {
|
||||
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorReengagementListResponse {
|
||||
pub success: bool,
|
||||
pub candidates: Vec<ReengagementCandidateEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReengagementCandidateEntry {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_gravatar_hash: String,
|
||||
pub reengagement_score: u32,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub maybe_last_payment_at: Option<DateTime<Utc>>,
|
||||
pub maybe_days_since_last_payment: Option<u32>,
|
||||
pub maybe_weeks_since_last_spend: Option<u32>,
|
||||
pub is_active_subscriber: bool,
|
||||
}
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::users::UserToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub const MODERATOR_TOP_USERS_LIST_PATH: &str =
|
||||
"/v1/moderation/user_spend_summaries/top_users_list";
|
||||
|
||||
/// Net-spend window that selects which column `top_users_list` sorts by.
|
||||
#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)]
|
||||
pub enum TopUsersWindow {
|
||||
#[serde(rename = "7d")]
|
||||
SevenDays,
|
||||
#[serde(rename = "14d")]
|
||||
FourteenDays,
|
||||
#[serde(rename = "30d")]
|
||||
ThirtyDays,
|
||||
#[serde(rename = "60d")]
|
||||
SixtyDays,
|
||||
#[serde(rename = "90d")]
|
||||
NinetyDays,
|
||||
#[serde(rename = "1y")]
|
||||
OneYear,
|
||||
#[serde(rename = "lifetime")]
|
||||
Lifetime,
|
||||
}
|
||||
|
||||
impl TopUsersWindow {
|
||||
/// The `window` key the `list_top_users_by_net_spend` query matches on.
|
||||
pub fn as_query_key(self) -> &'static str {
|
||||
match self {
|
||||
TopUsersWindow::SevenDays => "7d",
|
||||
TopUsersWindow::FourteenDays => "14d",
|
||||
TopUsersWindow::ThirtyDays => "30d",
|
||||
TopUsersWindow::SixtyDays => "60d",
|
||||
TopUsersWindow::NinetyDays => "90d",
|
||||
TopUsersWindow::OneYear => "1y",
|
||||
TopUsersWindow::Lifetime => "lifetime",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct ModeratorTopUsersListQueryParams {
|
||||
/// Sort window: `7d`, `14d`, `30d`, `60d`, `90d`, `1y`, or `lifetime` (default).
|
||||
pub window: Option<TopUsersWindow>,
|
||||
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
|
||||
pub offset: Option<u64>,
|
||||
/// Payments namespace. Defaults to `artcraft`.
|
||||
pub payments_namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ModeratorTopUsersListResponse {
|
||||
pub success: bool,
|
||||
/// The window that was sorted by (echo of the request).
|
||||
pub window: String,
|
||||
pub users: Vec<TopUserEntry>,
|
||||
/// Offset for the next page, or `None` if this was the last page.
|
||||
pub maybe_next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TopUserEntry {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_gravatar_hash: String,
|
||||
pub lifetime_net_spend_usd_cents: u64,
|
||||
pub net_spend_7d_usd_cents: i64,
|
||||
pub net_spend_14d_usd_cents: i64,
|
||||
pub net_spend_30d_usd_cents: i64,
|
||||
pub net_spend_60d_usd_cents: i64,
|
||||
pub net_spend_90d_usd_cents: i64,
|
||||
pub net_spend_this_year_usd_cents: i64,
|
||||
pub is_active_subscriber: bool,
|
||||
pub maybe_reengagement_score: Option<u32>,
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonAspectRatio` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientAspectRatio {
|
||||
Auto,
|
||||
Square,
|
||||
WideThreeByTwo,
|
||||
WideFourByThree,
|
||||
WideFiveByFour,
|
||||
WideSixteenByNine,
|
||||
WideTwentyOneByNine,
|
||||
TallTwoByThree,
|
||||
TallThreeByFour,
|
||||
TallFourByFive,
|
||||
TallNineBySixteen,
|
||||
TallNineByTwentyOne,
|
||||
Wide,
|
||||
Tall,
|
||||
Auto2k,
|
||||
Auto3k,
|
||||
Auto4k,
|
||||
SquareHd,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientAspectRatio {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"auto" => Self::Auto,
|
||||
"square" => Self::Square,
|
||||
"wide_three_by_two" => Self::WideThreeByTwo,
|
||||
"wide_four_by_three" => Self::WideFourByThree,
|
||||
"wide_five_by_four" => Self::WideFiveByFour,
|
||||
"wide_sixteen_by_nine" => Self::WideSixteenByNine,
|
||||
"wide_twenty_one_by_nine" => Self::WideTwentyOneByNine,
|
||||
"tall_two_by_three" => Self::TallTwoByThree,
|
||||
"tall_three_by_four" => Self::TallThreeByFour,
|
||||
"tall_four_by_five" => Self::TallFourByFive,
|
||||
"tall_nine_by_sixteen" => Self::TallNineBySixteen,
|
||||
"tall_nine_by_twenty_one" => Self::TallNineByTwentyOne,
|
||||
"wide" => Self::Wide,
|
||||
"tall" => Self::Tall,
|
||||
"auto_2k" => Self::Auto2k,
|
||||
"auto_3k" => Self::Auto3k,
|
||||
"auto_4k" => Self::Auto4k,
|
||||
"square_hd" => Self::SquareHd,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientAspectRatio> for String {
|
||||
fn from(value: ApiClientAspectRatio) -> Self {
|
||||
match value {
|
||||
ApiClientAspectRatio::Auto => "auto".to_string(),
|
||||
ApiClientAspectRatio::Square => "square".to_string(),
|
||||
ApiClientAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(),
|
||||
ApiClientAspectRatio::WideFourByThree => "wide_four_by_three".to_string(),
|
||||
ApiClientAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(),
|
||||
ApiClientAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(),
|
||||
ApiClientAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(),
|
||||
ApiClientAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(),
|
||||
ApiClientAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(),
|
||||
ApiClientAspectRatio::TallFourByFive => "tall_four_by_five".to_string(),
|
||||
ApiClientAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(),
|
||||
ApiClientAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(),
|
||||
ApiClientAspectRatio::Wide => "wide".to_string(),
|
||||
ApiClientAspectRatio::Tall => "tall".to_string(),
|
||||
ApiClientAspectRatio::Auto2k => "auto_2k".to_string(),
|
||||
ApiClientAspectRatio::Auto3k => "auto_3k".to_string(),
|
||||
ApiClientAspectRatio::Auto4k => "auto_4k".to_string(),
|
||||
ApiClientAspectRatio::SquareHd => "square_hd".to_string(),
|
||||
ApiClientAspectRatio::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientAspectRatio = serde_json::from_str("\"auto\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientAspectRatio::Auto);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"auto\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientAspectRatio = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientAspectRatio::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientAspectRatio::Auto, "auto"),
|
||||
(ApiClientAspectRatio::Square, "square"),
|
||||
(ApiClientAspectRatio::WideThreeByTwo, "wide_three_by_two"),
|
||||
(ApiClientAspectRatio::WideFourByThree, "wide_four_by_three"),
|
||||
(ApiClientAspectRatio::WideFiveByFour, "wide_five_by_four"),
|
||||
(ApiClientAspectRatio::WideSixteenByNine, "wide_sixteen_by_nine"),
|
||||
(ApiClientAspectRatio::WideTwentyOneByNine, "wide_twenty_one_by_nine"),
|
||||
(ApiClientAspectRatio::TallTwoByThree, "tall_two_by_three"),
|
||||
(ApiClientAspectRatio::TallThreeByFour, "tall_three_by_four"),
|
||||
(ApiClientAspectRatio::TallFourByFive, "tall_four_by_five"),
|
||||
(ApiClientAspectRatio::TallNineBySixteen, "tall_nine_by_sixteen"),
|
||||
(ApiClientAspectRatio::TallNineByTwentyOne, "tall_nine_by_twenty_one"),
|
||||
(ApiClientAspectRatio::Wide, "wide"),
|
||||
(ApiClientAspectRatio::Tall, "tall"),
|
||||
(ApiClientAspectRatio::Auto2k, "auto_2k"),
|
||||
(ApiClientAspectRatio::Auto3k, "auto_3k"),
|
||||
(ApiClientAspectRatio::Auto4k, "auto_4k"),
|
||||
(ApiClientAspectRatio::SquareHd, "square_hd"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientAspectRatio = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonBitrate` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientBitrate {
|
||||
Normal,
|
||||
High,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientBitrate {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"normal" => Self::Normal,
|
||||
"high" => Self::High,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientBitrate> for String {
|
||||
fn from(value: ApiClientBitrate) -> Self {
|
||||
match value {
|
||||
ApiClientBitrate::Normal => "normal".to_string(),
|
||||
ApiClientBitrate::High => "high".to_string(),
|
||||
ApiClientBitrate::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientBitrate = serde_json::from_str("\"normal\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientBitrate::Normal);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"normal\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientBitrate = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientBitrate::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientBitrate::Normal, "normal"),
|
||||
(ApiClientBitrate::High, "high"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientBitrate = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `GenerationProvider` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientGenerationProvider {
|
||||
Artcraft,
|
||||
Fal,
|
||||
Grok,
|
||||
Midjourney,
|
||||
Sora,
|
||||
WorldLabs,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientGenerationProvider {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"artcraft" => Self::Artcraft,
|
||||
"fal" => Self::Fal,
|
||||
"grok" => Self::Grok,
|
||||
"midjourney" => Self::Midjourney,
|
||||
"sora" => Self::Sora,
|
||||
"world_labs" => Self::WorldLabs,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientGenerationProvider> for String {
|
||||
fn from(value: ApiClientGenerationProvider) -> Self {
|
||||
match value {
|
||||
ApiClientGenerationProvider::Artcraft => "artcraft".to_string(),
|
||||
ApiClientGenerationProvider::Fal => "fal".to_string(),
|
||||
ApiClientGenerationProvider::Grok => "grok".to_string(),
|
||||
ApiClientGenerationProvider::Midjourney => "midjourney".to_string(),
|
||||
ApiClientGenerationProvider::Sora => "sora".to_string(),
|
||||
ApiClientGenerationProvider::WorldLabs => "world_labs".to_string(),
|
||||
ApiClientGenerationProvider::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientGenerationProvider = serde_json::from_str("\"artcraft\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientGenerationProvider::Artcraft);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"artcraft\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientGenerationProvider = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientGenerationProvider::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientGenerationProvider::Artcraft, "artcraft"),
|
||||
(ApiClientGenerationProvider::Fal, "fal"),
|
||||
(ApiClientGenerationProvider::Grok, "grok"),
|
||||
(ApiClientGenerationProvider::Midjourney, "midjourney"),
|
||||
(ApiClientGenerationProvider::Sora, "sora"),
|
||||
(ApiClientGenerationProvider::WorldLabs, "world_labs"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientGenerationProvider = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonImageModel` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientImageModel {
|
||||
Flux1Dev,
|
||||
Flux1Schnell,
|
||||
FluxPro11,
|
||||
FluxPro11Ultra,
|
||||
GptImage1,
|
||||
GptImage1p5,
|
||||
GptImage2,
|
||||
GrokImagineImage,
|
||||
GrokImagineImageQuality,
|
||||
Midjourney7,
|
||||
Midjourney7Niji,
|
||||
Midjourney8,
|
||||
NanoBanana,
|
||||
NanoBanana2,
|
||||
NanoBananaPro,
|
||||
Seedream4,
|
||||
Seedream4p5,
|
||||
Seedream5Lite,
|
||||
QwenEdit2511Angles,
|
||||
Flux2LoraAngles,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientImageModel {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"flux_1_dev" => Self::Flux1Dev,
|
||||
"flux_1_schnell" => Self::Flux1Schnell,
|
||||
"flux_pro_1p1" => Self::FluxPro11,
|
||||
"flux_pro_1p1_ultra" => Self::FluxPro11Ultra,
|
||||
"gpt_image_1" => Self::GptImage1,
|
||||
"gpt_image_1p5" => Self::GptImage1p5,
|
||||
"gpt_image_2" => Self::GptImage2,
|
||||
"grok_imagine_image" => Self::GrokImagineImage,
|
||||
"grok_imagine_image_q" => Self::GrokImagineImageQuality,
|
||||
"midjourney_7" => Self::Midjourney7,
|
||||
"midjourney_7_niji" => Self::Midjourney7Niji,
|
||||
"midjourney_8" => Self::Midjourney8,
|
||||
"nano_banana" => Self::NanoBanana,
|
||||
"nano_banana_2" => Self::NanoBanana2,
|
||||
"nano_banana_pro" => Self::NanoBananaPro,
|
||||
"seedream_4" => Self::Seedream4,
|
||||
"seedream_4p5" => Self::Seedream4p5,
|
||||
"seedream_5_lite" => Self::Seedream5Lite,
|
||||
"qwen_edit_2511_angles" => Self::QwenEdit2511Angles,
|
||||
"flux_2_lora_angles" => Self::Flux2LoraAngles,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientImageModel> for String {
|
||||
fn from(value: ApiClientImageModel) -> Self {
|
||||
match value {
|
||||
ApiClientImageModel::Flux1Dev => "flux_1_dev".to_string(),
|
||||
ApiClientImageModel::Flux1Schnell => "flux_1_schnell".to_string(),
|
||||
ApiClientImageModel::FluxPro11 => "flux_pro_1p1".to_string(),
|
||||
ApiClientImageModel::FluxPro11Ultra => "flux_pro_1p1_ultra".to_string(),
|
||||
ApiClientImageModel::GptImage1 => "gpt_image_1".to_string(),
|
||||
ApiClientImageModel::GptImage1p5 => "gpt_image_1p5".to_string(),
|
||||
ApiClientImageModel::GptImage2 => "gpt_image_2".to_string(),
|
||||
ApiClientImageModel::GrokImagineImage => "grok_imagine_image".to_string(),
|
||||
ApiClientImageModel::GrokImagineImageQuality => "grok_imagine_image_q".to_string(),
|
||||
ApiClientImageModel::Midjourney7 => "midjourney_7".to_string(),
|
||||
ApiClientImageModel::Midjourney7Niji => "midjourney_7_niji".to_string(),
|
||||
ApiClientImageModel::Midjourney8 => "midjourney_8".to_string(),
|
||||
ApiClientImageModel::NanoBanana => "nano_banana".to_string(),
|
||||
ApiClientImageModel::NanoBanana2 => "nano_banana_2".to_string(),
|
||||
ApiClientImageModel::NanoBananaPro => "nano_banana_pro".to_string(),
|
||||
ApiClientImageModel::Seedream4 => "seedream_4".to_string(),
|
||||
ApiClientImageModel::Seedream4p5 => "seedream_4p5".to_string(),
|
||||
ApiClientImageModel::Seedream5Lite => "seedream_5_lite".to_string(),
|
||||
ApiClientImageModel::QwenEdit2511Angles => "qwen_edit_2511_angles".to_string(),
|
||||
ApiClientImageModel::Flux2LoraAngles => "flux_2_lora_angles".to_string(),
|
||||
ApiClientImageModel::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientImageModel = serde_json::from_str("\"flux_1_dev\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientImageModel::Flux1Dev);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"flux_1_dev\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientImageModel = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientImageModel::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientImageModel::Flux1Dev, "flux_1_dev"),
|
||||
(ApiClientImageModel::Flux1Schnell, "flux_1_schnell"),
|
||||
(ApiClientImageModel::FluxPro11, "flux_pro_1p1"),
|
||||
(ApiClientImageModel::FluxPro11Ultra, "flux_pro_1p1_ultra"),
|
||||
(ApiClientImageModel::GptImage1, "gpt_image_1"),
|
||||
(ApiClientImageModel::GptImage1p5, "gpt_image_1p5"),
|
||||
(ApiClientImageModel::GptImage2, "gpt_image_2"),
|
||||
(ApiClientImageModel::GrokImagineImage, "grok_imagine_image"),
|
||||
(ApiClientImageModel::GrokImagineImageQuality, "grok_imagine_image_q"),
|
||||
(ApiClientImageModel::Midjourney7, "midjourney_7"),
|
||||
(ApiClientImageModel::Midjourney7Niji, "midjourney_7_niji"),
|
||||
(ApiClientImageModel::Midjourney8, "midjourney_8"),
|
||||
(ApiClientImageModel::NanoBanana, "nano_banana"),
|
||||
(ApiClientImageModel::NanoBanana2, "nano_banana_2"),
|
||||
(ApiClientImageModel::NanoBananaPro, "nano_banana_pro"),
|
||||
(ApiClientImageModel::Seedream4, "seedream_4"),
|
||||
(ApiClientImageModel::Seedream4p5, "seedream_4p5"),
|
||||
(ApiClientImageModel::Seedream5Lite, "seedream_5_lite"),
|
||||
(ApiClientImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles"),
|
||||
(ApiClientImageModel::Flux2LoraAngles, "flux_2_lora_angles"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientImageModel = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `ModelCreator` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientModelCreator {
|
||||
Alibaba,
|
||||
ArtCraft,
|
||||
BlackForestLabs,
|
||||
Bytedance,
|
||||
Fal,
|
||||
Google,
|
||||
Grok,
|
||||
Hailuo,
|
||||
Higgsfield,
|
||||
Kling,
|
||||
Krea,
|
||||
Midjourney,
|
||||
OpenAi,
|
||||
OpenArt,
|
||||
Recraft,
|
||||
Replicate,
|
||||
Runway,
|
||||
Stability,
|
||||
Tencent,
|
||||
TensorArt,
|
||||
Vidu,
|
||||
WorldLabs,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientModelCreator {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"alibaba" => Self::Alibaba,
|
||||
"artcraft" => Self::ArtCraft,
|
||||
"black_forest_labs" => Self::BlackForestLabs,
|
||||
"bytedance" => Self::Bytedance,
|
||||
"fal" => Self::Fal,
|
||||
"google" => Self::Google,
|
||||
"grok" => Self::Grok,
|
||||
"hailuo" => Self::Hailuo,
|
||||
"higgsfield" => Self::Higgsfield,
|
||||
"kling" => Self::Kling,
|
||||
"krea" => Self::Krea,
|
||||
"midjourney" => Self::Midjourney,
|
||||
"open_ai" => Self::OpenAi,
|
||||
"open_art" => Self::OpenArt,
|
||||
"recraft" => Self::Recraft,
|
||||
"replicate" => Self::Replicate,
|
||||
"runway" => Self::Runway,
|
||||
"stability" => Self::Stability,
|
||||
"tencent" => Self::Tencent,
|
||||
"tensor_art" => Self::TensorArt,
|
||||
"vidu" => Self::Vidu,
|
||||
"world_labs" => Self::WorldLabs,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientModelCreator> for String {
|
||||
fn from(value: ApiClientModelCreator) -> Self {
|
||||
match value {
|
||||
ApiClientModelCreator::Alibaba => "alibaba".to_string(),
|
||||
ApiClientModelCreator::ArtCraft => "artcraft".to_string(),
|
||||
ApiClientModelCreator::BlackForestLabs => "black_forest_labs".to_string(),
|
||||
ApiClientModelCreator::Bytedance => "bytedance".to_string(),
|
||||
ApiClientModelCreator::Fal => "fal".to_string(),
|
||||
ApiClientModelCreator::Google => "google".to_string(),
|
||||
ApiClientModelCreator::Grok => "grok".to_string(),
|
||||
ApiClientModelCreator::Hailuo => "hailuo".to_string(),
|
||||
ApiClientModelCreator::Higgsfield => "higgsfield".to_string(),
|
||||
ApiClientModelCreator::Kling => "kling".to_string(),
|
||||
ApiClientModelCreator::Krea => "krea".to_string(),
|
||||
ApiClientModelCreator::Midjourney => "midjourney".to_string(),
|
||||
ApiClientModelCreator::OpenAi => "open_ai".to_string(),
|
||||
ApiClientModelCreator::OpenArt => "open_art".to_string(),
|
||||
ApiClientModelCreator::Recraft => "recraft".to_string(),
|
||||
ApiClientModelCreator::Replicate => "replicate".to_string(),
|
||||
ApiClientModelCreator::Runway => "runway".to_string(),
|
||||
ApiClientModelCreator::Stability => "stability".to_string(),
|
||||
ApiClientModelCreator::Tencent => "tencent".to_string(),
|
||||
ApiClientModelCreator::TensorArt => "tensor_art".to_string(),
|
||||
ApiClientModelCreator::Vidu => "vidu".to_string(),
|
||||
ApiClientModelCreator::WorldLabs => "world_labs".to_string(),
|
||||
ApiClientModelCreator::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientModelCreator = serde_json::from_str("\"alibaba\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientModelCreator::Alibaba);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"alibaba\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientModelCreator = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientModelCreator::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientModelCreator::Alibaba, "alibaba"),
|
||||
(ApiClientModelCreator::ArtCraft, "artcraft"),
|
||||
(ApiClientModelCreator::BlackForestLabs, "black_forest_labs"),
|
||||
(ApiClientModelCreator::Bytedance, "bytedance"),
|
||||
(ApiClientModelCreator::Fal, "fal"),
|
||||
(ApiClientModelCreator::Google, "google"),
|
||||
(ApiClientModelCreator::Grok, "grok"),
|
||||
(ApiClientModelCreator::Hailuo, "hailuo"),
|
||||
(ApiClientModelCreator::Higgsfield, "higgsfield"),
|
||||
(ApiClientModelCreator::Kling, "kling"),
|
||||
(ApiClientModelCreator::Krea, "krea"),
|
||||
(ApiClientModelCreator::Midjourney, "midjourney"),
|
||||
(ApiClientModelCreator::OpenAi, "open_ai"),
|
||||
(ApiClientModelCreator::OpenArt, "open_art"),
|
||||
(ApiClientModelCreator::Recraft, "recraft"),
|
||||
(ApiClientModelCreator::Replicate, "replicate"),
|
||||
(ApiClientModelCreator::Runway, "runway"),
|
||||
(ApiClientModelCreator::Stability, "stability"),
|
||||
(ApiClientModelCreator::Tencent, "tencent"),
|
||||
(ApiClientModelCreator::TensorArt, "tensor_art"),
|
||||
(ApiClientModelCreator::Vidu, "vidu"),
|
||||
(ApiClientModelCreator::WorldLabs, "world_labs"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientModelCreator = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonQuality` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientQuality {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientQuality {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"high" => Self::High,
|
||||
"medium" => Self::Medium,
|
||||
"low" => Self::Low,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientQuality> for String {
|
||||
fn from(value: ApiClientQuality) -> Self {
|
||||
match value {
|
||||
ApiClientQuality::High => "high".to_string(),
|
||||
ApiClientQuality::Medium => "medium".to_string(),
|
||||
ApiClientQuality::Low => "low".to_string(),
|
||||
ApiClientQuality::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientQuality = serde_json::from_str("\"high\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientQuality::High);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"high\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientQuality = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientQuality::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientQuality::High, "high"),
|
||||
(ApiClientQuality::Medium, "medium"),
|
||||
(ApiClientQuality::Low, "low"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientQuality = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonResolution` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientResolution {
|
||||
OneK,
|
||||
TwoK,
|
||||
ThreeK,
|
||||
FourK,
|
||||
HalfK,
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientResolution {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"one_k" => Self::OneK,
|
||||
"two_k" => Self::TwoK,
|
||||
"three_k" => Self::ThreeK,
|
||||
"four_k" => Self::FourK,
|
||||
"half_k" => Self::HalfK,
|
||||
"four_eighty_p" => Self::FourEightyP,
|
||||
"seven_twenty_p" => Self::SevenTwentyP,
|
||||
"ten_eighty_p" => Self::TenEightyP,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientResolution> for String {
|
||||
fn from(value: ApiClientResolution) -> Self {
|
||||
match value {
|
||||
ApiClientResolution::OneK => "one_k".to_string(),
|
||||
ApiClientResolution::TwoK => "two_k".to_string(),
|
||||
ApiClientResolution::ThreeK => "three_k".to_string(),
|
||||
ApiClientResolution::FourK => "four_k".to_string(),
|
||||
ApiClientResolution::HalfK => "half_k".to_string(),
|
||||
ApiClientResolution::FourEightyP => "four_eighty_p".to_string(),
|
||||
ApiClientResolution::SevenTwentyP => "seven_twenty_p".to_string(),
|
||||
ApiClientResolution::TenEightyP => "ten_eighty_p".to_string(),
|
||||
ApiClientResolution::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientResolution = serde_json::from_str("\"one_k\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientResolution::OneK);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"one_k\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientResolution = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientResolution::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientResolution::OneK, "one_k"),
|
||||
(ApiClientResolution::TwoK, "two_k"),
|
||||
(ApiClientResolution::ThreeK, "three_k"),
|
||||
(ApiClientResolution::FourK, "four_k"),
|
||||
(ApiClientResolution::HalfK, "half_k"),
|
||||
(ApiClientResolution::FourEightyP, "four_eighty_p"),
|
||||
(ApiClientResolution::SevenTwentyP, "seven_twenty_p"),
|
||||
(ApiClientResolution::TenEightyP, "ten_eighty_p"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientResolution = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-191
@@ -1,191 +0,0 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
/// Forward-compatible, self-contained API-client copy of the server's `CommonVideoModel` enum.
|
||||
///
|
||||
/// Serialized as a string. Any value this client build does not recognize is preserved
|
||||
/// verbatim in [`Unknown`], so newer server variants never break deserialization and
|
||||
/// still round-trip back to the original string.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ApiClientVideoModel {
|
||||
GrokVideo,
|
||||
GrokImagineVideo,
|
||||
GrokImagineVideo1p5,
|
||||
Kling16Pro,
|
||||
Kling21Pro,
|
||||
Kling21Master,
|
||||
Kling2p5TurboPro,
|
||||
Kling2p6Pro,
|
||||
Kling3p0Standard,
|
||||
Kling3p0Pro,
|
||||
HappyHorse1p0,
|
||||
Seedance10Lite,
|
||||
Seedance1p5Pro,
|
||||
Seedance2p0,
|
||||
Seedance2p0Fast,
|
||||
Seedance2p0BytePlus,
|
||||
Seedance2p0BytePlusFast,
|
||||
Seedance2p0Ultra,
|
||||
Seedance2p0UltraFast,
|
||||
Seedance2p0BytePlusUltra,
|
||||
Seedance2p0BytePlusUltraFast,
|
||||
Seedance2p0Mini,
|
||||
Seedance2p0BytePlusMini,
|
||||
Seedance2p0BytePlusUltraMini,
|
||||
Sora2,
|
||||
Sora2Pro,
|
||||
Veo2,
|
||||
Veo3,
|
||||
Veo3Fast,
|
||||
Veo3p1,
|
||||
Veo3p1Fast,
|
||||
PreviewModel,
|
||||
PreviewModelFast,
|
||||
/// A value not known to this client build (preserved as-is).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<String> for ApiClientVideoModel {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"grok_video" => Self::GrokVideo,
|
||||
"grok_imagine_video" => Self::GrokImagineVideo,
|
||||
"grok_imagine_video_1p5" => Self::GrokImagineVideo1p5,
|
||||
"kling_1p6_pro" => Self::Kling16Pro,
|
||||
"kling_2p1_pro" => Self::Kling21Pro,
|
||||
"kling_2p1_master" => Self::Kling21Master,
|
||||
"kling_2p5_turbo_pro" => Self::Kling2p5TurboPro,
|
||||
"kling_2p6_pro" => Self::Kling2p6Pro,
|
||||
"kling_3p0_standard" => Self::Kling3p0Standard,
|
||||
"kling_3p0_pro" => Self::Kling3p0Pro,
|
||||
"happy_horse_1p0" => Self::HappyHorse1p0,
|
||||
"seedance_1p0_lite" => Self::Seedance10Lite,
|
||||
"seedance_1p5_pro" => Self::Seedance1p5Pro,
|
||||
"seedance_2p0" => Self::Seedance2p0,
|
||||
"seedance_2p0_fast" => Self::Seedance2p0Fast,
|
||||
"seedance_2p0_bp" => Self::Seedance2p0BytePlus,
|
||||
"seedance_2p0_bp_fast" => Self::Seedance2p0BytePlusFast,
|
||||
"seedance_2p0_u" => Self::Seedance2p0Ultra,
|
||||
"seedance_2p0_u_fast" => Self::Seedance2p0UltraFast,
|
||||
"seedance_2p0_bpu" => Self::Seedance2p0BytePlusUltra,
|
||||
"seedance_2p0_bpu_fast" => Self::Seedance2p0BytePlusUltraFast,
|
||||
"seedance_2p0_mini" => Self::Seedance2p0Mini,
|
||||
"seedance_2p0_bp_mini" => Self::Seedance2p0BytePlusMini,
|
||||
"seedance_2p0_bpu_mini" => Self::Seedance2p0BytePlusUltraMini,
|
||||
"sora_2" => Self::Sora2,
|
||||
"sora_2_pro" => Self::Sora2Pro,
|
||||
"veo_2" => Self::Veo2,
|
||||
"veo_3" => Self::Veo3,
|
||||
"veo_3_fast" => Self::Veo3Fast,
|
||||
"veo_3p1" => Self::Veo3p1,
|
||||
"veo_3p1_fast" => Self::Veo3p1Fast,
|
||||
"preview_model" => Self::PreviewModel,
|
||||
"preview_model_fast" => Self::PreviewModelFast,
|
||||
_ => Self::Unknown(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiClientVideoModel> for String {
|
||||
fn from(value: ApiClientVideoModel) -> Self {
|
||||
match value {
|
||||
ApiClientVideoModel::GrokVideo => "grok_video".to_string(),
|
||||
ApiClientVideoModel::GrokImagineVideo => "grok_imagine_video".to_string(),
|
||||
ApiClientVideoModel::GrokImagineVideo1p5 => "grok_imagine_video_1p5".to_string(),
|
||||
ApiClientVideoModel::Kling16Pro => "kling_1p6_pro".to_string(),
|
||||
ApiClientVideoModel::Kling21Pro => "kling_2p1_pro".to_string(),
|
||||
ApiClientVideoModel::Kling21Master => "kling_2p1_master".to_string(),
|
||||
ApiClientVideoModel::Kling2p5TurboPro => "kling_2p5_turbo_pro".to_string(),
|
||||
ApiClientVideoModel::Kling2p6Pro => "kling_2p6_pro".to_string(),
|
||||
ApiClientVideoModel::Kling3p0Standard => "kling_3p0_standard".to_string(),
|
||||
ApiClientVideoModel::Kling3p0Pro => "kling_3p0_pro".to_string(),
|
||||
ApiClientVideoModel::HappyHorse1p0 => "happy_horse_1p0".to_string(),
|
||||
ApiClientVideoModel::Seedance10Lite => "seedance_1p0_lite".to_string(),
|
||||
ApiClientVideoModel::Seedance1p5Pro => "seedance_1p5_pro".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0 => "seedance_2p0".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Fast => "seedance_2p0_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlus => "seedance_2p0_bp".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusFast => "seedance_2p0_bp_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Ultra => "seedance_2p0_u".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0UltraFast => "seedance_2p0_u_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltra => "seedance_2p0_bpu".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraFast => "seedance_2p0_bpu_fast".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0Mini => "seedance_2p0_mini".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusMini => "seedance_2p0_bp_mini".to_string(),
|
||||
ApiClientVideoModel::Seedance2p0BytePlusUltraMini => "seedance_2p0_bpu_mini".to_string(),
|
||||
ApiClientVideoModel::Sora2 => "sora_2".to_string(),
|
||||
ApiClientVideoModel::Sora2Pro => "sora_2_pro".to_string(),
|
||||
ApiClientVideoModel::Veo2 => "veo_2".to_string(),
|
||||
ApiClientVideoModel::Veo3 => "veo_3".to_string(),
|
||||
ApiClientVideoModel::Veo3Fast => "veo_3_fast".to_string(),
|
||||
ApiClientVideoModel::Veo3p1 => "veo_3p1".to_string(),
|
||||
ApiClientVideoModel::Veo3p1Fast => "veo_3p1_fast".to_string(),
|
||||
ApiClientVideoModel::PreviewModel => "preview_model".to_string(),
|
||||
ApiClientVideoModel::PreviewModelFast => "preview_model_fast".to_string(),
|
||||
ApiClientVideoModel::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_variant_round_trips() {
|
||||
let parsed: ApiClientVideoModel = serde_json::from_str("\"grok_video\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientVideoModel::GrokVideo);
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"grok_video\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_is_preserved_and_round_trips() {
|
||||
let parsed: ApiClientVideoModel = serde_json::from_str("\"zzz_not_a_real_value\"").unwrap();
|
||||
assert_eq!(parsed, ApiClientVideoModel::Unknown("zzz_not_a_real_value".to_string()));
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), "\"zzz_not_a_real_value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_variant_round_trips() {
|
||||
let all = [
|
||||
(ApiClientVideoModel::GrokVideo, "grok_video"),
|
||||
(ApiClientVideoModel::GrokImagineVideo, "grok_imagine_video"),
|
||||
(ApiClientVideoModel::GrokImagineVideo1p5, "grok_imagine_video_1p5"),
|
||||
(ApiClientVideoModel::Kling16Pro, "kling_1p6_pro"),
|
||||
(ApiClientVideoModel::Kling21Pro, "kling_2p1_pro"),
|
||||
(ApiClientVideoModel::Kling21Master, "kling_2p1_master"),
|
||||
(ApiClientVideoModel::Kling2p5TurboPro, "kling_2p5_turbo_pro"),
|
||||
(ApiClientVideoModel::Kling2p6Pro, "kling_2p6_pro"),
|
||||
(ApiClientVideoModel::Kling3p0Standard, "kling_3p0_standard"),
|
||||
(ApiClientVideoModel::Kling3p0Pro, "kling_3p0_pro"),
|
||||
(ApiClientVideoModel::HappyHorse1p0, "happy_horse_1p0"),
|
||||
(ApiClientVideoModel::Seedance10Lite, "seedance_1p0_lite"),
|
||||
(ApiClientVideoModel::Seedance1p5Pro, "seedance_1p5_pro"),
|
||||
(ApiClientVideoModel::Seedance2p0, "seedance_2p0"),
|
||||
(ApiClientVideoModel::Seedance2p0Fast, "seedance_2p0_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlus, "seedance_2p0_bp"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusFast, "seedance_2p0_bp_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Ultra, "seedance_2p0_u"),
|
||||
(ApiClientVideoModel::Seedance2p0UltraFast, "seedance_2p0_u_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltra, "seedance_2p0_bpu"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltraFast, "seedance_2p0_bpu_fast"),
|
||||
(ApiClientVideoModel::Seedance2p0Mini, "seedance_2p0_mini"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusMini, "seedance_2p0_bp_mini"),
|
||||
(ApiClientVideoModel::Seedance2p0BytePlusUltraMini, "seedance_2p0_bpu_mini"),
|
||||
(ApiClientVideoModel::Sora2, "sora_2"),
|
||||
(ApiClientVideoModel::Sora2Pro, "sora_2_pro"),
|
||||
(ApiClientVideoModel::Veo2, "veo_2"),
|
||||
(ApiClientVideoModel::Veo3, "veo_3"),
|
||||
(ApiClientVideoModel::Veo3Fast, "veo_3_fast"),
|
||||
(ApiClientVideoModel::Veo3p1, "veo_3p1"),
|
||||
(ApiClientVideoModel::Veo3p1Fast, "veo_3p1_fast"),
|
||||
(ApiClientVideoModel::PreviewModel, "preview_model"),
|
||||
(ApiClientVideoModel::PreviewModelFast, "preview_model_fast"),
|
||||
];
|
||||
for (variant, s) in all {
|
||||
assert_eq!(String::from(variant.clone()), s, "serialize mismatch for {:?}", variant);
|
||||
let parsed: ApiClientVideoModel = serde_json::from_str(&format!("\"{}\"", s)).unwrap();
|
||||
assert_eq!(parsed, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
pub mod api_client_aspect_ratio;
|
||||
pub mod api_client_bitrate;
|
||||
pub mod api_client_generation_provider;
|
||||
pub mod api_client_image_model;
|
||||
pub mod api_client_model_creator;
|
||||
pub mod api_client_quality;
|
||||
pub mod api_client_resolution;
|
||||
pub mod api_client_video_model;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod generation;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod common_enums;
|
||||
@@ -1,2 +1 @@
|
||||
pub mod generate;
|
||||
pub mod models;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod omni_gen_list_image_models;
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
//! Self-contained, forward-compatible binding for `GET /v1/omni_gen/models/image`.
|
||||
//!
|
||||
//! IMPORTANT: This module deliberately does NOT reuse any types from
|
||||
//! `artcraft_api_defs`. Client builds may be deployed in the wild long after the
|
||||
//! server's models, enums, and response shapes have changed, so every request /
|
||||
//! response type is copied here and made permissive:
|
||||
//! - Unknown JSON fields are ignored (serde default behavior).
|
||||
//! - Missing collection/flag fields default rather than fail (`serde(default)`).
|
||||
//! - Every string-valued enum has an `Unknown(String)` catch-all so new server
|
||||
//! variants deserialize instead of erroring.
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio;
|
||||
use crate::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider;
|
||||
use crate::datatypes::common_enums::generation::api_client_image_model::ApiClientImageModel;
|
||||
use crate::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator;
|
||||
use crate::datatypes::common_enums::generation::api_client_quality::ApiClientQuality;
|
||||
use crate::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::basic_json_get_request::basic_json_get_request;
|
||||
|
||||
pub const OMNI_GEN_IMAGE_MODELS_PATH: &str = "/v1/omni_gen/models/image";
|
||||
|
||||
/// Arguments for [`omni_gen_list_image_models`].
|
||||
pub struct OmniGenListImageModelsArgs<'a> {
|
||||
pub api_host: &'a ApiHost,
|
||||
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
|
||||
/// Which provider's models to list. `None` lets the server default (artcraft).
|
||||
pub provider: Option<OmniGenImageModelsProvider>,
|
||||
}
|
||||
|
||||
/// List available image models.
|
||||
pub async fn omni_gen_list_image_models(
|
||||
args: OmniGenListImageModelsArgs<'_>,
|
||||
) -> Result<OmniGenImageModelsResponse, StorytellerError> {
|
||||
let path = match args.provider {
|
||||
Some(provider) => format!("{}?provider={}", OMNI_GEN_IMAGE_MODELS_PATH, provider.as_query_value()),
|
||||
None => OMNI_GEN_IMAGE_MODELS_PATH.to_string(),
|
||||
};
|
||||
|
||||
Ok(basic_json_get_request(args.api_host, &path, args.maybe_creds).await?)
|
||||
}
|
||||
|
||||
/// The provider filter for the models endpoint (a client-supplied request value).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OmniGenImageModelsProvider {
|
||||
/// Only models available through ArtCraft.
|
||||
Artcraft,
|
||||
/// All known models across all providers.
|
||||
All,
|
||||
}
|
||||
|
||||
impl OmniGenImageModelsProvider {
|
||||
pub fn as_query_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Artcraft => "artcraft",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OmniGenImageModelsProvider {
|
||||
fn default() -> Self {
|
||||
Self::Artcraft
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Response types ============================
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageModelsResponse {
|
||||
#[serde(default)]
|
||||
pub success: bool,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenImageModelDetails>,
|
||||
#[serde(default)]
|
||||
pub providers: Vec<OmniGenImageModelProviderDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageModelProviderDetails {
|
||||
pub provider: ApiClientGenerationProvider,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenImageProviderModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageProviderModelDetails {
|
||||
pub model: ApiClientImageModel,
|
||||
#[serde(default)]
|
||||
pub overrides: Option<OmniGenImageModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenImageModelDetails {
|
||||
pub model: ApiClientImageModel,
|
||||
#[serde(default)]
|
||||
pub model_creator: Option<ApiClientModelCreator>,
|
||||
#[serde(default)]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub image_refs_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub image_refs_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub has_fixed_editing_aspect_ratio: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_options: Option<Vec<ApiClientAspectRatio>>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_default: Option<ApiClientAspectRatio>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_default_when_editing: Option<ApiClientAspectRatio>,
|
||||
#[serde(default)]
|
||||
pub resolution_options: Option<Vec<ApiClientResolution>>,
|
||||
#[serde(default)]
|
||||
pub resolution_default: Option<ApiClientResolution>,
|
||||
#[serde(default)]
|
||||
pub quality_options: Option<Vec<ApiClientQuality>>,
|
||||
#[serde(default)]
|
||||
pub default_quality: Option<ApiClientQuality>,
|
||||
#[serde(default)]
|
||||
pub batch_size_min: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_options: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub batch_size_default: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_tolerates_unknown_fields_and_variants() {
|
||||
let json = r#"{
|
||||
"success": true,
|
||||
"models": [
|
||||
{
|
||||
"model": "brand_new_image_model",
|
||||
"model_creator": "some_new_studio",
|
||||
"aspect_ratio_options": ["square", "some_new_ratio"],
|
||||
"surprise_new_field": 123
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{ "provider": "new_provider", "models": [ { "model": "nano_banana_pro" } ] }
|
||||
],
|
||||
"another_unexpected_top_level_field": "ignored"
|
||||
}"#;
|
||||
|
||||
let resp: OmniGenImageModelsResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.success);
|
||||
assert_eq!(resp.models.len(), 1);
|
||||
assert_eq!(resp.models[0].model, ApiClientImageModel::Unknown("brand_new_image_model".to_string()));
|
||||
assert_eq!(resp.models[0].model_creator, Some(ApiClientModelCreator::Unknown("some_new_studio".to_string())));
|
||||
let ratios = resp.models[0].aspect_ratio_options.as_ref().unwrap();
|
||||
assert_eq!(ratios[0], ApiClientAspectRatio::Square);
|
||||
assert_eq!(ratios[1], ApiClientAspectRatio::Unknown("some_new_ratio".to_string()));
|
||||
assert_eq!(resp.models[0].is_disabled, None);
|
||||
assert_eq!(resp.providers[0].provider, ApiClientGenerationProvider::Unknown("new_provider".to_string()));
|
||||
assert_eq!(resp.providers[0].models[0].model, ApiClientImageModel::NanoBananaPro);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_uses_defaults() {
|
||||
let resp: OmniGenImageModelsResponse = serde_json::from_str("{}").unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.models.is_empty());
|
||||
assert!(resp.providers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Live tests that hit real servers. All `#[ignore]` so they never run by default.
|
||||
/// Run explicitly, e.g.:
|
||||
/// cargo test -p artcraft_client omni_gen_list_image_models::live_tests -- --ignored --nocapture
|
||||
#[cfg(test)]
|
||||
mod live_tests {
|
||||
mod localhost {
|
||||
use super::super::{omni_gen_list_image_models, OmniGenListImageModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits http://localhost:12345
|
||||
async fn list_image_models() {
|
||||
let api_host = ApiHost::Localhost { port: 12345 };
|
||||
let response = omni_gen_list_image_models(OmniGenListImageModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[localhost] image models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one image model");
|
||||
}
|
||||
}
|
||||
|
||||
mod production {
|
||||
use super::super::{omni_gen_list_image_models, OmniGenListImageModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits https://api.storyteller.ai
|
||||
async fn list_image_models() {
|
||||
let api_host = ApiHost::Storyteller;
|
||||
let response = omni_gen_list_image_models(OmniGenListImageModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[production] image models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one image model");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
@@ -1 +0,0 @@
|
||||
pub mod omni_gen_list_video_models;
|
||||
-291
@@ -1,291 +0,0 @@
|
||||
//! Self-contained, forward-compatible binding for `GET /v1/omni_gen/models/video`.
|
||||
//!
|
||||
//! IMPORTANT: This module deliberately does NOT reuse any types from
|
||||
//! `artcraft_api_defs`. Client builds may be deployed in the wild long after the
|
||||
//! server's models, enums, and response shapes have changed, so every request /
|
||||
//! response type is copied here and made permissive:
|
||||
//! - Unknown JSON fields are ignored (serde default behavior).
|
||||
//! - Missing collection/flag fields default rather than fail (`serde(default)`).
|
||||
//! - Every string-valued enum has an `Unknown(String)` catch-all so new server
|
||||
//! variants deserialize instead of erroring.
|
||||
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::basic_json_get_request::basic_json_get_request;
|
||||
use crate::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio;
|
||||
use crate::datatypes::common_enums::generation::api_client_bitrate::ApiClientBitrate;
|
||||
use crate::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider;
|
||||
use crate::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator;
|
||||
use crate::datatypes::common_enums::generation::api_client_quality::ApiClientQuality;
|
||||
use crate::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution;
|
||||
use crate::datatypes::common_enums::generation::api_client_video_model::ApiClientVideoModel;
|
||||
|
||||
pub const OMNI_GEN_VIDEO_MODELS_PATH: &str = "/v1/omni_gen/models/video";
|
||||
|
||||
/// Arguments for [`omni_gen_list_video_models`].
|
||||
pub struct OmniGenListVideoModelsArgs<'a> {
|
||||
pub api_host: &'a ApiHost,
|
||||
pub maybe_creds: Option<&'a StorytellerCredentialSet>,
|
||||
/// Which provider's models to list. `None` lets the server default (artcraft).
|
||||
pub provider: Option<OmniGenVideoModelsProvider>,
|
||||
}
|
||||
|
||||
/// List available video models.
|
||||
pub async fn omni_gen_list_video_models(
|
||||
args: OmniGenListVideoModelsArgs<'_>,
|
||||
) -> Result<OmniGenVideoModelsResponse, StorytellerError> {
|
||||
let path = match args.provider {
|
||||
Some(provider) => format!("{}?provider={}", OMNI_GEN_VIDEO_MODELS_PATH, provider.as_query_value()),
|
||||
None => OMNI_GEN_VIDEO_MODELS_PATH.to_string(),
|
||||
};
|
||||
|
||||
Ok(basic_json_get_request(args.api_host, &path, args.maybe_creds).await?)
|
||||
}
|
||||
|
||||
/// The provider filter for the models endpoint (a client-supplied request value).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OmniGenVideoModelsProvider {
|
||||
/// Only models available through ArtCraft.
|
||||
Artcraft,
|
||||
/// All known models across all providers.
|
||||
All,
|
||||
}
|
||||
|
||||
impl OmniGenVideoModelsProvider {
|
||||
pub fn as_query_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Artcraft => "artcraft",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OmniGenVideoModelsProvider {
|
||||
fn default() -> Self {
|
||||
Self::Artcraft
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Response types ============================
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoModelsResponse {
|
||||
#[serde(default)]
|
||||
pub success: bool,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenVideoModelDetails>,
|
||||
#[serde(default)]
|
||||
pub providers: Vec<OmniGenVideoModelProviderDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoModelProviderDetails {
|
||||
pub provider: ApiClientGenerationProvider,
|
||||
#[serde(default)]
|
||||
pub models: Vec<OmniGenVideoProviderModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoProviderModelDetails {
|
||||
pub model: ApiClientVideoModel,
|
||||
#[serde(default)]
|
||||
pub overrides: Option<OmniGenVideoModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OmniGenVideoModelDetails {
|
||||
pub model: ApiClientVideoModel,
|
||||
#[serde(default)]
|
||||
pub model_creator: Option<ApiClientModelCreator>,
|
||||
#[serde(default)]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_info: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_info_short: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_to_video_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub negative_text_prompt_max_length: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub starting_keyframe_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub starting_keyframe_required: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub ending_keyframe_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub image_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub image_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub video_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub video_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub video_references_max_total_duration_seconds: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub audio_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub audio_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub audio_references_max_total_duration_seconds: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub character_references_supported: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub character_references_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub show_generate_with_sound_toggle: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_options: Option<Vec<ApiClientAspectRatio>>,
|
||||
#[serde(default)]
|
||||
pub aspect_ratio_default: Option<ApiClientAspectRatio>,
|
||||
#[serde(default)]
|
||||
pub resolution_options: Option<Vec<ApiClientResolution>>,
|
||||
#[serde(default)]
|
||||
pub resolution_default: Option<ApiClientResolution>,
|
||||
#[serde(default)]
|
||||
pub bitrate_options: Option<Vec<ApiClientBitrate>>,
|
||||
#[serde(default)]
|
||||
pub bitrate_default: Option<ApiClientBitrate>,
|
||||
#[serde(default)]
|
||||
pub quality_options: Option<Vec<ApiClientQuality>>,
|
||||
#[serde(default)]
|
||||
pub default_quality: Option<ApiClientQuality>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_min: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_max_with_image_references: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_options: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub duration_seconds_default: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_min: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_max: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub batch_size_options: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub batch_size_default: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unknown_enum_value_is_preserved_and_round_trips() {
|
||||
let known: ApiClientVideoModel = serde_json::from_str("\"seedance_2p0\"").unwrap();
|
||||
assert_eq!(known, ApiClientVideoModel::Seedance2p0);
|
||||
assert_eq!(serde_json::to_string(&known).unwrap(), "\"seedance_2p0\"");
|
||||
|
||||
// A value this build has never heard of does NOT fail — it is captured verbatim.
|
||||
let unknown: ApiClientVideoModel = serde_json::from_str("\"future_model_9000\"").unwrap();
|
||||
assert_eq!(unknown, ApiClientVideoModel::Unknown("future_model_9000".to_string()));
|
||||
// ...and serializes back to exactly what came in.
|
||||
assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"future_model_9000\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_tolerates_unknown_fields_and_variants() {
|
||||
let json = r#"{
|
||||
"success": true,
|
||||
"models": [
|
||||
{
|
||||
"model": "brand_new_model",
|
||||
"model_creator": "some_new_studio",
|
||||
"aspect_ratio_options": ["wide_sixteen_by_nine", "some_new_ratio"],
|
||||
"surprise_new_field": 123
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{ "provider": "new_provider", "models": [ { "model": "seedance_2p0" } ] }
|
||||
],
|
||||
"another_unexpected_top_level_field": "ignored"
|
||||
}"#;
|
||||
|
||||
let resp: OmniGenVideoModelsResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.success);
|
||||
assert_eq!(resp.models.len(), 1);
|
||||
assert_eq!(resp.models[0].model, ApiClientVideoModel::Unknown("brand_new_model".to_string()));
|
||||
assert_eq!(resp.models[0].model_creator, Some(ApiClientModelCreator::Unknown("some_new_studio".to_string())));
|
||||
let ratios = resp.models[0].aspect_ratio_options.as_ref().unwrap();
|
||||
assert_eq!(ratios[0], ApiClientAspectRatio::WideSixteenByNine);
|
||||
assert_eq!(ratios[1], ApiClientAspectRatio::Unknown("some_new_ratio".to_string()));
|
||||
assert_eq!(resp.models[0].is_disabled, None); // missing optional -> None
|
||||
assert_eq!(resp.providers[0].provider, ApiClientGenerationProvider::Unknown("new_provider".to_string()));
|
||||
assert_eq!(resp.providers[0].models[0].model, ApiClientVideoModel::Seedance2p0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_uses_defaults() {
|
||||
let resp: OmniGenVideoModelsResponse = serde_json::from_str("{}").unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.models.is_empty());
|
||||
assert!(resp.providers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Live tests that hit real servers. All `#[ignore]` so they never run by default.
|
||||
/// Run explicitly, e.g.:
|
||||
/// cargo test -p artcraft_client omni_gen_list_video_models::live_tests -- --ignored --nocapture
|
||||
#[cfg(test)]
|
||||
mod live_tests {
|
||||
mod localhost {
|
||||
use super::super::{omni_gen_list_video_models, OmniGenListVideoModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits http://localhost:12345
|
||||
async fn list_video_models() {
|
||||
let api_host = ApiHost::Localhost { port: 12345 };
|
||||
let response = omni_gen_list_video_models(OmniGenListVideoModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[localhost] video models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one video model");
|
||||
}
|
||||
}
|
||||
|
||||
mod production {
|
||||
use super::super::{omni_gen_list_video_models, OmniGenListVideoModelsArgs};
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // live: hits https://api.storyteller.ai
|
||||
async fn list_video_models() {
|
||||
let api_host = ApiHost::Storyteller;
|
||||
let response = omni_gen_list_video_models(OmniGenListVideoModelsArgs {
|
||||
api_host: &api_host,
|
||||
maybe_creds: None,
|
||||
provider: None,
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
println!("[production] video models response: {:#?}", response);
|
||||
assert!(response.success);
|
||||
assert!(!response.models.is_empty(), "expected at least one video model");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
//! An API client for Artcraft / Storyteller / FakeYou
|
||||
|
||||
pub mod credentials;
|
||||
pub mod datatypes;
|
||||
pub mod endpoints;
|
||||
pub mod error;
|
||||
pub mod recipes;
|
||||
|
||||
+6
-6
@@ -14,7 +14,7 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
||||
///
|
||||
/// Same constraints as the v1 variant — xAI v1.5 still supports only 480p
|
||||
/// and 720p and no 21:9 ultra-wide. The on-wire model identifier is
|
||||
/// `grok-imagine-video-1.5`; the cost calculator in
|
||||
/// `grok-imagine-video-1.5-preview`; the cost calculator in
|
||||
/// [`super::cost`] keys off that to apply the v1.5 pricing tier (with a 5%
|
||||
/// ArtCraft markup on top of the upstream rates).
|
||||
pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
@@ -23,7 +23,7 @@ pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
let request = build_artcraft_omni_video_request(
|
||||
builder,
|
||||
CommonVideoModelEnum::GrokImagineVideo1p5,
|
||||
SupportedResolutions::Full,
|
||||
SupportedResolutions::Fast,
|
||||
UltraWideSupport::Unsupported,
|
||||
)?;
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn build_artcraft_grok_imagine_video_1p5(
|
||||
{
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -113,10 +113,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_is_supported() {
|
||||
// Grok Imagine 1.5 produces genuine 1080p output.
|
||||
fn res_1080p_downgrades_to_720p() {
|
||||
// SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output).
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::TenEightyP));
|
||||
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-8
@@ -71,7 +71,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
if self.input_image_count == 0 {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -99,9 +99,8 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
fn build_equivalent_grok_request(&self) -> GrokVideoGenerationRequest {
|
||||
let resolution = Some(match self.resolution {
|
||||
CommonResolution::FourEightyP => GrokResolution::FourEightyP,
|
||||
CommonResolution::TenEightyP => GrokResolution::TenEightyP,
|
||||
// Grok Imagine 1.5 tops out at 1080p (the build step downgrades higher
|
||||
// tiers via SupportedResolutions::Full); price anything else as 720p.
|
||||
// Grok Imagine caps at 720p (and our build step downgrades higher
|
||||
// tiers via SupportedResolutions::Fast); price anything else as 720p.
|
||||
_ => GrokResolution::SevenTwentyP,
|
||||
});
|
||||
|
||||
@@ -120,7 +119,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
|
||||
|
||||
GrokVideoGenerationRequest {
|
||||
prompt: String::new(),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
image,
|
||||
reference_images,
|
||||
aspect_ratio: None,
|
||||
@@ -318,13 +317,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_prices_higher_than_720p() {
|
||||
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
|
||||
fn ten_eighty_p_request_clamps_to_720p_pricing() {
|
||||
let p1080 = build_cost_via_builder(Some(RouterResolution::TenEightyP), 10, 1)
|
||||
.cost_in_usd_cents.unwrap();
|
||||
let p720 = build_cost_via_builder(Some(RouterResolution::SevenTwentyP), 10, 1)
|
||||
.cost_in_usd_cents.unwrap();
|
||||
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
|
||||
assert_eq!(p1080, p720);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ impl ArtcraftGrokImagineVideo1p5RequestState {
|
||||
{
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+15
-16
@@ -28,11 +28,11 @@ use crate::generate::generate_video::video_generation_request::VideoGenerationRe
|
||||
/// the only material differences are the `model` identifier on the wire and
|
||||
/// the steeper pricing in the cost calculator (see `cost.rs`).
|
||||
///
|
||||
/// xAI's grok-imagine-video-1.5 accepts:
|
||||
/// xAI's grok-imagine-video-1.5-preview accepts:
|
||||
/// - `image` (single source image, image-to-video mode) OR `reference_images`
|
||||
/// (multi-image reference-to-video). These two are mutually exclusive per
|
||||
/// xAI's API.
|
||||
/// - Aspect ratio, resolution (480p/720p/1080p), duration (1–15s), prompt.
|
||||
/// - Aspect ratio, resolution (480p/720p), duration (1–15s), prompt.
|
||||
///
|
||||
/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`,
|
||||
/// `reference_audio`, and `MediaFileToken`-style image refs) are silently
|
||||
@@ -78,7 +78,7 @@ pub fn build_grok_api_grok_imagine_video_1p5(
|
||||
if image.is_none() && reference_images.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn build_grok_api_grok_imagine_video_1p5(
|
||||
prompt,
|
||||
// Pin to the 1.5 preview identifier so the grok_api_client cost
|
||||
// calculator picks the v1.5 pricing tier (see VideoModel::pricing_tier).
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
image,
|
||||
reference_images,
|
||||
aspect_ratio,
|
||||
@@ -146,11 +146,10 @@ fn plan_resolution(
|
||||
None => None,
|
||||
Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP),
|
||||
Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP),
|
||||
Some(RouterResolution::TenEightyP) => Some(GrokResolution::TenEightyP),
|
||||
// 2K/3K/4K aren't produced — cap at the model's max of 1080p.
|
||||
Some(RouterResolution::TwoK)
|
||||
Some(RouterResolution::TenEightyP)
|
||||
| Some(RouterResolution::TwoK)
|
||||
| Some(RouterResolution::ThreeK)
|
||||
| Some(RouterResolution::FourK) => Some(GrokResolution::TenEightyP),
|
||||
| Some(RouterResolution::FourK) => Some(GrokResolution::SevenTwentyP),
|
||||
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => {
|
||||
Some(GrokResolution::FourEightyP)
|
||||
}
|
||||
@@ -280,7 +279,7 @@ mod tests {
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
assert_eq!(
|
||||
req.request.model,
|
||||
Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,7 +289,7 @@ mod tests {
|
||||
// calculator in grok_api_client keys off this string.
|
||||
let req = unwrap_request(make_builder(|_| {}));
|
||||
let model = req.request.model.as_ref().expect("model should be set");
|
||||
assert_eq!(model.as_str(), "grok-imagine-video-1.5");
|
||||
assert_eq!(model.as_str(), "grok-imagine-video-1.5-preview");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,15 +417,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_1080p_maps_to_1080p() {
|
||||
fn res_1080p_clamps_to_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_4k_caps_to_1080p() {
|
||||
fn res_4k_clamps_to_720p() {
|
||||
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::TenEightyP));
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,7 +542,7 @@ mod tests {
|
||||
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
|
||||
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
|
||||
assert_eq!(req.request.duration, Some(5));
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -661,7 +660,7 @@ mod tests {
|
||||
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
|
||||
assert_eq!(req.request.duration, Some(8));
|
||||
// Still pinned to the 1.5 model regardless of kitchen-sink inputs.
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5));
|
||||
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -21,13 +21,13 @@ impl GrokApiGrokImagineVideo1p5CostState {
|
||||
if self.request.request.image.is_none() && self.request.request.reference_images.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// grok_api_client's calculator picks the v1.5 pricing tier from the
|
||||
// request's `model` field (see VideoModel::pricing_tier). The build step
|
||||
// sets `model = Some(GrokImagineVideo1p5)`, so this returns the
|
||||
// sets `model = Some(GrokImagineVideo1p5Preview)`, so this returns the
|
||||
// v1.5 rates:
|
||||
// Output: 80 mills/s @ 480p, 140 mills/s @ 720p
|
||||
// Input: 10 mills per source image
|
||||
@@ -205,11 +205,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_eighty_p_prices_higher_than_720p() {
|
||||
// Grok Imagine 1.5 now renders genuine 1080p ($0.25/s vs $0.14/s @ 720p).
|
||||
fn ten_eighty_p_request_clamps_to_720p_pricing() {
|
||||
let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None);
|
||||
let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None);
|
||||
assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})");
|
||||
assert_eq!(p1080, p720);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +305,7 @@ mod tests {
|
||||
fn estimate_cost_returns_err_when_no_image_sources() {
|
||||
let request = GrokVideoGenerationRequest {
|
||||
prompt: "text only".to_string(),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5),
|
||||
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: None,
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ use crate::generate::generate_video::generate_video_response::{
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GrokApiGrokImagineVideo1p5RequestState {
|
||||
/// The fully-resolved Grok request body, with `model` set to
|
||||
/// `VideoModel::GrokImagineVideo1p5`. Doesn't carry the API key —
|
||||
/// `VideoModel::GrokImagineVideo1p5Preview`. Doesn't carry the API key —
|
||||
/// that gets borrowed at send time from the router client.
|
||||
pub request: GrokVideoGenerationRequest,
|
||||
}
|
||||
@@ -27,7 +27,7 @@ impl GrokApiGrokImagineVideo1p5RequestState {
|
||||
if self.request.image.is_none() && self.request.reference_images.is_none() {
|
||||
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
|
||||
field: "image_inputs",
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5; supply a start_frame or at least one reference image".to_string(),
|
||||
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
..grok_builder()
|
||||
};
|
||||
let request = unwrap_request(builder);
|
||||
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5));
|
||||
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5Preview));
|
||||
}
|
||||
|
||||
// ── Live API tests (manual) ──
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })),
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+4
-4
@@ -103,12 +103,12 @@ pub async fn upload_file(args: UploadFileArgs<'_>) -> Result<UploadFileSuccess,
|
||||
let req = args.request;
|
||||
|
||||
if req.file_bytes.is_empty() {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
"upload_file: file_bytes is empty".to_string(),
|
||||
).into());
|
||||
}
|
||||
if req.file_bytes.len() as u64 > MAX_FILE_BYTES {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", req.file_bytes.len()),
|
||||
).into());
|
||||
}
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -210,7 +210,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
// ── Public Request shape — must not leak api_key OR file_bytes ──
|
||||
|
||||
@@ -105,7 +105,7 @@ pub async fn image_edit(args: ImageEditArgs<'_>) -> Result<ImageEditSuccess, Gro
|
||||
let req = args.request;
|
||||
|
||||
if req.source_images.is_empty() {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"image_edit requires at least one source image".to_string(),
|
||||
).into());
|
||||
}
|
||||
@@ -311,7 +311,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+10
-35
@@ -10,15 +10,15 @@ use crate::api::types::video_types::video_resolution::VideoResolution;
|
||||
// Output: 480p $0.05/sec, 720p $0.07/sec
|
||||
// Input: $0.01/sec (source video) + $0.002/img (source images)
|
||||
//
|
||||
// grok-imagine-video-1.5 (v1.5)
|
||||
// Output: 480p $0.08/sec, 720p $0.14/sec, 1080p $0.25/sec
|
||||
// grok-imagine-video-1.5-preview (v1.5)
|
||||
// Output: 480p $0.08/sec, 720p $0.14/sec
|
||||
// Input: $0.01/img (source images)
|
||||
//
|
||||
// In mills (1¢ = 10 mills):
|
||||
//
|
||||
// v1 output: 480p 50 mills/sec, 720p 70 mills/sec
|
||||
// v1 input: 10 mills/sec (video), 2 mills/img
|
||||
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec, 1080p 250 mills/sec
|
||||
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec
|
||||
// v1.5 input: 10 mills/img
|
||||
//
|
||||
// video_generation has NO input video — only optional input images. xAI
|
||||
@@ -78,20 +78,17 @@ fn output_mills_per_second_for_tier(
|
||||
tier: VideoModelPricingTier,
|
||||
) -> UsdMills {
|
||||
match (tier, resolution) {
|
||||
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
|
||||
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
|
||||
// v1 doesn't produce 1080p output (it downsizes to 720p); price at 720p.
|
||||
(VideoModelPricingTier::V1, VideoResolution::TenEightyP) => 70,
|
||||
(VideoModelPricingTier::V1p5, VideoResolution::FourEightyP) => 80,
|
||||
(VideoModelPricingTier::V1p5, VideoResolution::SevenTwentyP) => 140,
|
||||
(VideoModelPricingTier::V1p5, VideoResolution::TenEightyP) => 250,
|
||||
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
|
||||
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
|
||||
(VideoModelPricingTier::V1p5Preview, VideoResolution::FourEightyP) => 80,
|
||||
(VideoModelPricingTier::V1p5Preview, VideoResolution::SevenTwentyP) => 140,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_mills_per_image(tier: VideoModelPricingTier) -> UsdMills {
|
||||
match tier {
|
||||
VideoModelPricingTier::V1 => INPUT_MILLS_PER_IMAGE,
|
||||
VideoModelPricingTier::V1p5 => INPUT_MILLS_PER_IMAGE_V1P5,
|
||||
VideoModelPricingTier::V1p5Preview => INPUT_MILLS_PER_IMAGE_V1P5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +303,7 @@ mod tests {
|
||||
reference_images: Option<Vec<VideoImageSource>>,
|
||||
) -> VideoGenerationRequest {
|
||||
let mut req = make_request(duration, resolution, image, reference_images);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
|
||||
req
|
||||
}
|
||||
|
||||
@@ -326,22 +323,6 @@ mod tests {
|
||||
assert_eq!(req.calculate_cost_in_cents(), 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_1080p_text_only() {
|
||||
// 250 × 5 = 1250 mills = $1.25
|
||||
let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), None, None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 1250);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_1080p_one_source_image() {
|
||||
// 250 × 5 + 10 = 1260 mills = $1.26
|
||||
let req = make_v1p5_request(Some(5), Some(VideoResolution::TenEightyP), Some(url_source()), None);
|
||||
assert_eq!(req.calculate_cost_in_mills(), 1260);
|
||||
assert_eq!(req.calculate_cost_in_cents(), 126);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_seconds_720p_one_source_image() {
|
||||
// 140 × 5 + 10 × 1 = 710 mills = 71¢
|
||||
@@ -367,7 +348,7 @@ mod tests {
|
||||
// canonical 1.5 variant exactly.
|
||||
let canonical = {
|
||||
let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5);
|
||||
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
|
||||
req.calculate_cost_in_mills()
|
||||
};
|
||||
let via_alias = {
|
||||
@@ -415,12 +396,6 @@ mod tests {
|
||||
// 720p with reference images
|
||||
( 5, VideoResolution::SevenTwentyP, false, 2, 720),
|
||||
( 5, VideoResolution::SevenTwentyP, false, 3, 730),
|
||||
// 1080p (250 mills/sec)
|
||||
( 1, VideoResolution::TenEightyP, false, 0, 250),
|
||||
( 5, VideoResolution::TenEightyP, false, 0, 1250),
|
||||
(15, VideoResolution::TenEightyP, false, 0, 3750),
|
||||
( 5, VideoResolution::TenEightyP, true, 0, 1260),
|
||||
( 5, VideoResolution::TenEightyP, false, 2, 1270),
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-29
@@ -1,4 +1,4 @@
|
||||
use log::{info, warn};
|
||||
use log::info;
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use crate::api::requests::videos::video_generation::request_types::*;
|
||||
@@ -106,7 +106,7 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
|
||||
let req = args.request;
|
||||
|
||||
if req.image.is_some() && req.reference_images.as_ref().is_some_and(|v| !v.is_empty()) {
|
||||
return Err(GrokClientError::InvalidRequest(
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(),
|
||||
).into());
|
||||
}
|
||||
@@ -141,14 +141,6 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
// DEBUG: log exactly what we send (request body has no secrets — the API key
|
||||
// travels in the Authorization header, not the body). Helps confirm the wire
|
||||
// shape when diagnosing 4xx responses.
|
||||
match serde_json::to_string(&request_body) {
|
||||
Ok(body_json) => info!("Grok video_generation request: POST {url} body={body_json}"),
|
||||
Err(err) => warn!("Grok video_generation: failed to serialize request body for logging: {err}"),
|
||||
}
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -158,27 +150,12 @@ pub async fn video_generation(args: VideoGenerationArgs<'_>) -> Result<VideoGene
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
// Capture headers BEFORE `.text()` consumes the response. Response headers
|
||||
// reveal WAF/CDN blocks (e.g. `server: cloudflare`, `cf-ray`, `cf-mitigated`)
|
||||
// vs. genuine API errors, and any rate-limit / retry hints.
|
||||
let response_headers = format!("{:#?}", response.headers());
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_generation response: status={}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
// The response body now travels in the returned error (`raw_http_body`),
|
||||
// so log the HEADERS here — those aren't captured in the error type and
|
||||
// reveal CDN/WAF context (e.g. `server: cloudflare`, `cf-ray`) vs. a
|
||||
// genuine API rejection.
|
||||
warn!(
|
||||
"Grok video_generation FAILED: status={status} ({} body bytes)\n--- response headers ---\n{response_headers}",
|
||||
response_body.len(),
|
||||
);
|
||||
}
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoGenerationResponseBody = serde_json::from_str(&response_body)
|
||||
@@ -306,7 +283,7 @@ mod tests {
|
||||
},
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::Client(GrokClientError::InvalidRequest(_))));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -437,7 +414,7 @@ mod tests {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "Timelapse of a flower blooming in a sunlit garden.".to_string(),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5Preview),
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
|
||||
@@ -464,7 +441,7 @@ mod tests {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "The camera slowly pushes in toward the building as the sun sinks below the horizon. Soft golden light, gentle breeze rustling the trees.".to_string(),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5Preview),
|
||||
image: Some(VideoImageSource::Url(WHITE_HOUSE_SUNSET_IMAGE_URL.to_string())),
|
||||
reference_images: None,
|
||||
aspect_ratio: Some(VideoAspectRatio::Landscape16x9),
|
||||
@@ -495,7 +472,7 @@ mod tests {
|
||||
api_key: &api_key,
|
||||
request: VideoGenerationRequest {
|
||||
prompt: "The dogs from <IMAGE_1> in the scene from <IMAGE_2>. Make them play together.".to_string(),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5),
|
||||
model: Some(VideoModel::GrokImagineVideo1p5Preview),
|
||||
image: None,
|
||||
reference_images: Some(vec![
|
||||
VideoImageSource::Url(TALL_MOCHI_WITH_GLASSES_IMAGE_URL.to_string()),
|
||||
|
||||
+3
-3
@@ -286,7 +286,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pending_response_with_v1p5_model_does_not_break_dispatch() {
|
||||
// `model` doesn't surface on Pending; it flows through on Complete only.
|
||||
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5" }"#;
|
||||
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-preview" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let result = classify_status_field(&parsed, json).unwrap();
|
||||
assert!(matches!(result.status, VideoStatus::Pending { .. }));
|
||||
@@ -400,7 +400,7 @@ mod tests {
|
||||
#[test]
|
||||
fn moderation_4xx_body_becomes_failed_content_moderated() {
|
||||
// The real-world body that previously surfaced as
|
||||
// `Err(GrokSpecificApiError::PromptModerated { .. })`. It should now be
|
||||
// `Err(GrokSpecificApiError::PromptModerated(..))`. It should now be
|
||||
// an Ok(Failed { ContentModerated }) with all fields populated from
|
||||
// the JSON.
|
||||
let body = r#"{"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation.","usage":{"cost_in_usd_ticks":11300000000}}"#;
|
||||
@@ -482,7 +482,7 @@ mod tests {
|
||||
|
||||
println!("Result: {:?}", result.as_ref().map(|r| &r.status));
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })),
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ pub enum VideoModel {
|
||||
/// generation, editing, and extension endpoints.
|
||||
GrokImagineVideo,
|
||||
|
||||
/// `grok-imagine-video-1.5` — the GA 1.5 model (formerly
|
||||
/// `grok-imagine-video-1.5-preview`). xAI still accepts the old `-preview`
|
||||
/// name and the dated alias `grok-imagine-video-1.5-2026-05-30` as synonyms
|
||||
/// server-side; this variant serializes to the new canonical name.
|
||||
GrokImagineVideo1p5,
|
||||
/// `grok-imagine-video-1.5-preview` — the 1.5 preview model. xAI also
|
||||
/// accepts the dated alias `grok-imagine-video-1.5-2026-05-30`, which
|
||||
/// resolves to the same model server-side; serialize via this variant for
|
||||
/// the canonical name.
|
||||
GrokImagineVideo1p5Preview,
|
||||
|
||||
/// Escape hatch for model identifiers not yet enumerated here.
|
||||
Custom(String),
|
||||
@@ -32,7 +32,7 @@ impl VideoModel {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GrokImagineVideo => "grok-imagine-video",
|
||||
Self::GrokImagineVideo1p5 => "grok-imagine-video-1.5",
|
||||
Self::GrokImagineVideo1p5Preview => "grok-imagine-video-1.5-preview",
|
||||
Self::Custom(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,10 @@ impl VideoModel {
|
||||
pub fn pricing_tier(&self) -> VideoModelPricingTier {
|
||||
match self {
|
||||
Self::GrokImagineVideo => VideoModelPricingTier::V1,
|
||||
Self::GrokImagineVideo1p5 => VideoModelPricingTier::V1p5,
|
||||
Self::GrokImagineVideo1p5Preview => VideoModelPricingTier::V1p5Preview,
|
||||
Self::Custom(s) => match s.as_str() {
|
||||
"grok-imagine-video-1.5"
|
||||
| "grok-imagine-video-1.5-preview"
|
||||
| "grok-imagine-video-1.5-2026-05-30" => {
|
||||
VideoModelPricingTier::V1p5
|
||||
"grok-imagine-video-1.5-preview" | "grok-imagine-video-1.5-2026-05-30" => {
|
||||
VideoModelPricingTier::V1p5Preview
|
||||
}
|
||||
_ => VideoModelPricingTier::V1,
|
||||
},
|
||||
@@ -65,8 +63,8 @@ pub enum VideoModelPricingTier {
|
||||
/// `grok-imagine-video` and any unrecognized identifier that defaults
|
||||
/// to v1 rates.
|
||||
V1,
|
||||
/// `grok-imagine-video-1.5` (and its old `-preview` / dated aliases).
|
||||
V1p5,
|
||||
/// `grok-imagine-video-1.5-preview` (and its dated alias).
|
||||
V1p5Preview,
|
||||
}
|
||||
|
||||
// Serialize as the wire string ("grok-imagine-video" or the Custom inner
|
||||
@@ -91,12 +89,12 @@ mod tests {
|
||||
#[test]
|
||||
fn one_point_five_preview_serializes_canonical_name() {
|
||||
assert_eq!(
|
||||
VideoModel::GrokImagineVideo1p5.as_str(),
|
||||
"grok-imagine-video-1.5",
|
||||
VideoModel::GrokImagineVideo1p5Preview.as_str(),
|
||||
"grok-imagine-video-1.5-preview",
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&VideoModel::GrokImagineVideo1p5).unwrap(),
|
||||
"\"grok-imagine-video-1.5\"",
|
||||
serde_json::to_string(&VideoModel::GrokImagineVideo1p5Preview).unwrap(),
|
||||
"\"grok-imagine-video-1.5-preview\"",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,28 +124,22 @@ mod tests {
|
||||
#[test]
|
||||
fn one_point_five_preview_enum_is_v1p5() {
|
||||
assert_eq!(
|
||||
VideoModel::GrokImagineVideo1p5.pricing_tier(),
|
||||
VideoModelPricingTier::V1p5,
|
||||
VideoModel::GrokImagineVideo1p5Preview.pricing_tier(),
|
||||
VideoModelPricingTier::V1p5Preview,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_with_canonical_1p5_name_is_v1p5() {
|
||||
let m = VideoModel::Custom("grok-imagine-video-1.5".to_string());
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_with_old_preview_name_is_still_v1p5() {
|
||||
let m = VideoModel::Custom("grok-imagine-video-1.5-preview".to_string());
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_with_dated_1p5_alias_is_v1p5() {
|
||||
// xAI treats this alias as a synonym for the 1.5 preview model.
|
||||
let m = VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string());
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5);
|
||||
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/// Output resolution for `/v1/videos/generations`.
|
||||
///
|
||||
/// `480p` (server default), `720p`, and `1080p`. The 1.5 model produces
|
||||
/// genuine 1080p output (the original `grok-imagine-video` capped at 720p).
|
||||
/// Currently `480p` (server default) and `720p` are the only supported output
|
||||
/// resolutions. The xAI REST schema also lists `"1080p"`, but the capability
|
||||
/// page contradicts this — it documents that "a 1080p input will be downsized
|
||||
/// to 720p", indicating 720p is the actual output cap. We follow the
|
||||
/// capability page since it describes observed behavior rather than just the
|
||||
/// wire schema.
|
||||
///
|
||||
/// If/when xAI removes the cap and 1080p is genuinely produced as output,
|
||||
/// add a `TenEightyP` variant here.
|
||||
///
|
||||
/// Video edits (`/v1/videos/edits`) and extensions (`/v1/videos/extensions`)
|
||||
/// don't accept this field — they inherit the source video's resolution
|
||||
@@ -15,11 +22,8 @@ pub enum VideoResolution {
|
||||
/// `"480p"` — standard definition, faster processing. Server default.
|
||||
FourEightyP,
|
||||
|
||||
/// `"720p"` — HD quality.
|
||||
/// `"720p"` — HD quality. Currently the maximum supported output resolution.
|
||||
SevenTwentyP,
|
||||
|
||||
/// `"1080p"` — full HD. Supported by `grok-imagine-video-1.5`.
|
||||
TenEightyP,
|
||||
}
|
||||
|
||||
impl VideoResolution {
|
||||
@@ -29,7 +33,6 @@ impl VideoResolution {
|
||||
match self {
|
||||
Self::FourEightyP => "480p",
|
||||
Self::SevenTwentyP => "720p",
|
||||
Self::TenEightyP => "1080p",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,13 +52,11 @@ mod tests {
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(VideoResolution::FourEightyP.as_str(), "480p");
|
||||
assert_eq!(VideoResolution::SevenTwentyP.as_str(), "720p");
|
||||
assert_eq!(VideoResolution::TenEightyP.as_str(), "1080p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_plain_wire_string() {
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::FourEightyP).unwrap(), "\"480p\"");
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::SevenTwentyP).unwrap(), "\"720p\"");
|
||||
assert_eq!(serde_json::to_string(&VideoResolution::TenEightyP).unwrap(), "\"1080p\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,11 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Owned copy of the raw response body, attached to every error variant so
|
||||
// downstream logging/alerting/pages have the full upstream context.
|
||||
let raw_body = || maybe_body.map(|b| b.to_string());
|
||||
|
||||
match status_code {
|
||||
StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited { raw_http_body: raw_body() }.into()),
|
||||
StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized.into()),
|
||||
StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits.into()),
|
||||
StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound.into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited.into()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -31,10 +27,10 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
}
|
||||
return Err(GrokSpecificApiError::Forbidden { raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::Forbidden.into());
|
||||
}
|
||||
|
||||
// 400 Bad Request is usually a malformed body or moderation rejection.
|
||||
@@ -42,12 +38,12 @@ pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated { reason: message, raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::BadRequest { reason: message, raw_http_body: raw_body() }.into());
|
||||
return Err(GrokSpecificApiError::BadRequest(message).into());
|
||||
}
|
||||
return Err(GrokSpecificApiError::BadRequest { reason: String::new(), raw_http_body: None }.into());
|
||||
return Err(GrokSpecificApiError::BadRequest(String::new()).into());
|
||||
}
|
||||
|
||||
// Everything else falls through to the generic catch-all.
|
||||
@@ -103,25 +99,25 @@ mod tests {
|
||||
#[test]
|
||||
fn unauthorized_401() {
|
||||
let err = classify_grok_http_error(StatusCode::UNAUTHORIZED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_required_402() {
|
||||
let err = classify_grok_http_error(StatusCode::PAYMENT_REQUIRED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_404() {
|
||||
let err = classify_grok_http_error(StatusCode::NOT_FOUND, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_429() {
|
||||
let err = classify_grok_http_error(StatusCode::TOO_MANY_REQUESTS, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited)));
|
||||
}
|
||||
|
||||
// ── 403 Forbidden ──
|
||||
@@ -129,14 +125,14 @@ mod tests {
|
||||
#[test]
|
||||
fn plain_403_is_forbidden() {
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(r#"{"error":{"message":"Forbidden"}}"#)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_403_is_prompt_moderated() {
|
||||
let body = r#"{"error":{"message":"Content moderation blocked this prompt","code":"content_policy"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── 400 Bad Request ──
|
||||
@@ -146,7 +142,7 @@ mod tests {
|
||||
let body = r#"{"error":{"code":"invalid_argument","message":"unknown field 'foo'"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "unknown field 'foo'");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
@@ -158,7 +154,7 @@ mod tests {
|
||||
let body = "definitely not json";
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "definitely not json");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
@@ -169,7 +165,7 @@ mod tests {
|
||||
fn bad_request_400_no_body_is_empty_string() {
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, None).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest { reason: msg, .. }) => {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert!(msg.is_empty());
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
@@ -181,7 +177,7 @@ mod tests {
|
||||
// Some APIs return moderation as 400 rather than 403.
|
||||
let body = r#"{"error":{"message":"Safety filter blocked this request"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated { .. })));
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── Generic fallback ──
|
||||
|
||||
@@ -13,11 +13,6 @@ pub enum GrokClientError {
|
||||
|
||||
/// The request body could not be serialized to JSON.
|
||||
RequestSerializationError(serde_json::Error),
|
||||
|
||||
/// The request failed our own validation before being sent to xAI — e.g.
|
||||
/// mutually-exclusive fields supplied together, a required input missing, or
|
||||
/// an oversize payload. Detected client-side; no HTTP call was made.
|
||||
InvalidRequest(String),
|
||||
}
|
||||
|
||||
impl Error for GrokClientError {}
|
||||
@@ -28,7 +23,6 @@ impl Display for GrokClientError {
|
||||
Self::NoApiKeyPresent => write!(f, "No API key present."),
|
||||
Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err),
|
||||
Self::RequestSerializationError(err) => write!(f, "Failed to serialize request body to JSON: {}", err),
|
||||
Self::InvalidRequest(msg) => write!(f, "Invalid request (rejected client-side before sending): {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,59 +5,30 @@ use std::fmt::{Display, Formatter};
|
||||
///
|
||||
/// Distinguishes things like "your key is bad" from generic 5xx so callers can
|
||||
/// react appropriately (retry vs. surface to user vs. alert).
|
||||
///
|
||||
/// Every variant carries the raw HTTP response body (`raw_http_body`) when one
|
||||
/// was available, so downstream logging, alerting, and pages have the full
|
||||
/// upstream context — not just our classification. It's `None` for errors we
|
||||
/// raise client-side before making the HTTP call (e.g. pre-flight validation).
|
||||
#[derive(Debug)]
|
||||
pub enum GrokSpecificApiError {
|
||||
/// 401 Unauthorized — API key is missing, invalid, or revoked.
|
||||
Unauthorized {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
Unauthorized,
|
||||
|
||||
/// 402 Payment Required / insufficient credits to fulfill the request.
|
||||
InsufficientCredits {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
InsufficientCredits,
|
||||
|
||||
/// 403 Forbidden — the API key is valid but lacks permission for this
|
||||
/// resource/model (also covers team-level "out of credits / over spending
|
||||
/// limit" responses, which xAI returns as 403 `permission-denied`).
|
||||
Forbidden {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
/// 403 Forbidden — the API key is valid but lacks permission for this resource/model.
|
||||
Forbidden,
|
||||
|
||||
/// 404 Not Found — typically returned for an unknown `request_id` on the
|
||||
/// video status endpoint.
|
||||
NotFound {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
NotFound,
|
||||
|
||||
/// 429 Too Many Requests — rate limit exceeded.
|
||||
RateLimited {
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
RateLimited,
|
||||
|
||||
/// The prompt was rejected by xAI's content moderation.
|
||||
PromptModerated {
|
||||
/// The reason we parsed out of the response (e.g. the `error.message`).
|
||||
reason: String,
|
||||
/// The raw HTTP response body.
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
PromptModerated(String),
|
||||
|
||||
/// 400 Bad Request — the request body shape was invalid (e.g. missing
|
||||
/// required field, unsupported model, invalid aspect ratio).
|
||||
BadRequest {
|
||||
/// The reason we parsed out of the response (or the raw body when it
|
||||
/// wasn't parseable). For client-side pre-flight failures, the message
|
||||
/// we generated.
|
||||
reason: String,
|
||||
/// The raw HTTP response body. `None` for client-side pre-flight failures.
|
||||
raw_http_body: Option<String>,
|
||||
},
|
||||
BadRequest(String),
|
||||
}
|
||||
|
||||
impl Error for GrokSpecificApiError {}
|
||||
@@ -65,36 +36,13 @@ impl Error for GrokSpecificApiError {}
|
||||
impl Display for GrokSpecificApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unauthorized { raw_http_body } => {
|
||||
write!(f, "Grok API: Unauthorized (invalid or missing API key){}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::InsufficientCredits { raw_http_body } => {
|
||||
write!(f, "Grok API: Insufficient credits{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::Forbidden { raw_http_body } => {
|
||||
write!(f, "Grok API: Forbidden{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::NotFound { raw_http_body } => {
|
||||
write!(f, "Grok API: Not found{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::RateLimited { raw_http_body } => {
|
||||
write!(f, "Grok API: Rate limited{}", fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::PromptModerated { reason, raw_http_body } => {
|
||||
write!(f, "Grok API: Prompt moderated: {}{}", reason, fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::BadRequest { reason, raw_http_body } => {
|
||||
write!(f, "Grok API: Bad request: {}{}", reason, fmt_raw_body(raw_http_body))
|
||||
}
|
||||
Self::Unauthorized => write!(f, "Grok API: Unauthorized (invalid or missing API key)"),
|
||||
Self::InsufficientCredits => write!(f, "Grok API: Insufficient credits"),
|
||||
Self::Forbidden => write!(f, "Grok API: Forbidden"),
|
||||
Self::NotFound => write!(f, "Grok API: Not found"),
|
||||
Self::RateLimited => write!(f, "Grok API: Rate limited"),
|
||||
Self::PromptModerated(msg) => write!(f, "Grok API: Prompt moderated: {}", msg),
|
||||
Self::BadRequest(msg) => write!(f, "Grok API: Bad request: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the raw HTTP body to a `Display` message when one is present, so
|
||||
/// `{}`-formatted logs/pages carry the upstream context. Empty when absent.
|
||||
fn fmt_raw_body(raw_http_body: &Option<String>) -> String {
|
||||
match raw_http_body {
|
||||
Some(body) if !body.is_empty() => format!(" [raw http body: {}]", body),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,7 @@ log = "0.4.14"
|
||||
# Bigger pieces
|
||||
futures = { version = "0.3.17", features = ["thread-pool"] }
|
||||
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
|
||||
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
# Stripe is queried via plain REST (reqwest) with lenient serde structs — the
|
||||
# typed SDK's miniserde parsing is too strict for production data variety.
|
||||
# rustls 0.23 needs an explicit crypto provider (graph has both ring + aws-lc-rs).
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
tokio = { version = "1.38.0", features = ["macros"] }
|
||||
|
||||
# Serialization
|
||||
serde = "1.0.125"
|
||||
|
||||
@@ -20,7 +20,6 @@ pub struct Args {
|
||||
// #[derive(Debug, Deserialize, Copy, Clone)]
|
||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||
pub enum Command {
|
||||
BackfillUserSpendEvents,
|
||||
CalculateLegacyTtsResultsUsages,
|
||||
CalculateModelWeightsUsages,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::io::Write;
|
||||
|
||||
use chrono::Local;
|
||||
use log::{info, warn, LevelFilter};
|
||||
use log::{info, LevelFilter};
|
||||
use sqlx::{MySql, Pool};
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
|
||||
@@ -10,9 +10,6 @@ use easyenv::init_all_with_default_logging;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::args::{Command, parse_cli_args};
|
||||
use crate::operations::backfill_user_spend_events::backfill_user_spend_events::backfill_user_spend_events;
|
||||
use crate::operations::backfill_user_spend_events::stripe_api::StripeApi;
|
||||
use crate::operations::backfill_user_spend_events::sub_args::parse_backfill_user_spend_events_args;
|
||||
use crate::operations::calculate_legacy_tts_results_usages::calculate_legacy_tts_results_usages::calculate_legacy_tts_result_usages;
|
||||
use crate::operations::calculate_model_weights_usages::run_migration::run_migration;
|
||||
|
||||
@@ -24,12 +21,6 @@ pub mod operations;
|
||||
async fn main() -> AnyhowResult<()> {
|
||||
println!("db-backfill: run backfill or migration operations");
|
||||
|
||||
// rustls 0.23 requires a process-wide crypto provider before the first TLS
|
||||
// handshake (the replica's `ssl-mode=required` connection and Stripe HTTPS).
|
||||
// The dependency graph enables BOTH ring and aws-lc-rs, so rustls refuses to
|
||||
// auto-select and panics — install one explicitly. (`Err` = already installed.)
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// init_all_with_default_logging(None);
|
||||
Builder::new()
|
||||
.format(|buf, record| {
|
||||
@@ -48,149 +39,27 @@ async fn main() -> AnyhowResult<()> {
|
||||
|
||||
// NB: This secrets file differs from the rest because we might want to backfill production from local dev.
|
||||
// (Hopefully this isn't getting out of hand at this point.)
|
||||
// The path is resolved relative to the current working directory — log the
|
||||
// absolute path so a "not found" is actionable.
|
||||
let secrets_filename = ".env-db-backfill-secrets";
|
||||
let secrets_path = std::env::current_dir()
|
||||
.map(|dir| dir.join(secrets_filename))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(secrets_filename));
|
||||
info!("Loading secrets from: {}", secrets_path.display());
|
||||
// NB: We deliberately do NOT use `dotenv` here. Its parser treats `$`, spaces,
|
||||
// and `\` as special INSIDE the value (quoted or not), which mangles DB
|
||||
// connection URLs and generated passwords. This loader takes each value
|
||||
// verbatim, so `MYSQL_READ_URL=mysql://user:p$ss w@host:25060/db?ssl-mode=required`
|
||||
// just works.
|
||||
load_secrets_file(&secrets_path)?;
|
||||
easyenv::from_filename(".env-db-backfill-secrets")?;
|
||||
|
||||
let mysql = get_mysql("MYSQL_PRODUCTION_URL").await?;
|
||||
|
||||
info!("dispatching command: {:?}", command);
|
||||
|
||||
match command.sub_command {
|
||||
Command::CalculateModelWeightsUsages => {
|
||||
let mysql = get_mysql("MYSQL_PRODUCTION_URL").await?;
|
||||
run_migration(mysql).await?
|
||||
}
|
||||
Command::CalculateLegacyTtsResultsUsages => {
|
||||
let mysql = get_mysql("MYSQL_PRODUCTION_URL").await?;
|
||||
calculate_legacy_tts_result_usages(mysql).await?
|
||||
}
|
||||
Command::BackfillUserSpendEvents => {
|
||||
// Read pool = the replica (source); write pool = your target DB (local in
|
||||
// a dry run, prod when you go live). Stripe key = the ArtCraft account.
|
||||
let sub_args = parse_backfill_user_spend_events_args();
|
||||
let read_pool = get_mysql("MYSQL_READ_URL").await?;
|
||||
let write_pool = get_mysql("MYSQL_WRITE_URL").await?;
|
||||
let stripe = StripeApi::new(easyenv::get_env_string_required("STRIPE_ARTCRAFT_SECRET_KEY")?)?;
|
||||
backfill_user_spend_events(&read_pool, &write_pool, &stripe, sub_args).await?;
|
||||
}
|
||||
Command::CalculateModelWeightsUsages => run_migration(mysql).await?,
|
||||
Command::CalculateLegacyTtsResultsUsages => calculate_legacy_tts_result_usages(mysql).await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal, forgiving `.env`-style loader that sets process env vars from a
|
||||
/// `KEY=VALUE` file. The value is the remainder of the line taken VERBATIM
|
||||
/// (after one optional layer of surrounding quotes is stripped). Blank lines and
|
||||
/// `#` comment lines are ignored. Unlike `dotenv`, it does not interpret `$`,
|
||||
/// spaces, `\`, or `#` inside the value — so DB URLs and generated passwords
|
||||
/// pass through unchanged.
|
||||
fn load_secrets_file(path: &std::path::Path) -> AnyhowResult<()> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|err| anyhow::anyhow!("Could not read secrets file at {} : {}", path.display(), err))?;
|
||||
|
||||
let mut loaded_keys: Vec<String> = Vec::new();
|
||||
for (line_index, raw_line) in contents.lines().enumerate() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (key, value) = match line.split_once('=') {
|
||||
Some((key, value)) => (key.trim(), strip_matching_quotes(value.trim())),
|
||||
None => {
|
||||
warn!("secrets {}:{}: ignoring line without '=': {:?}", path.display(), line_index + 1, line);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if key.is_empty() {
|
||||
warn!("secrets {}:{}: ignoring empty key", path.display(), line_index + 1);
|
||||
continue;
|
||||
}
|
||||
std::env::set_var(key, value);
|
||||
loaded_keys.push(key.to_string());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Loaded {} key(s) from {} ({} bytes): [{}]",
|
||||
loaded_keys.len(),
|
||||
path.display(),
|
||||
contents.len(),
|
||||
loaded_keys.join(", "),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod secrets_loader_tests {
|
||||
use super::strip_matching_quotes;
|
||||
|
||||
// The whole point: a DB URL with a password containing `$`, a space, `#`, and a
|
||||
// query string with `=` must survive verbatim (this is exactly what `dotenv` mangled).
|
||||
#[test]
|
||||
fn url_value_is_taken_verbatim() {
|
||||
let line = r#"MYSQL_READ_URL=mysql://user:p$ss w#x@host:25060/db?ssl-mode=required"#;
|
||||
let (key, value) = line.split_once('=').unwrap();
|
||||
assert_eq!(key.trim(), "MYSQL_READ_URL");
|
||||
assert_eq!(
|
||||
strip_matching_quotes(value.trim()),
|
||||
"mysql://user:p$ss w#x@host:25060/db?ssl-mode=required",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_one_layer_of_matching_quotes_only() {
|
||||
assert_eq!(strip_matching_quotes("\"abc\""), "abc");
|
||||
assert_eq!(strip_matching_quotes("'abc'"), "abc");
|
||||
assert_eq!(strip_matching_quotes("abc"), "abc");
|
||||
assert_eq!(strip_matching_quotes("\"abc'"), "\"abc'"); // mismatched: untouched
|
||||
assert_eq!(strip_matching_quotes("\""), "\""); // single char: untouched
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip a single layer of matching surrounding single or double quotes, if any.
|
||||
fn strip_matching_quotes(value: &str) -> &str {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
let first = bytes[0];
|
||||
let last = bytes[bytes.len() - 1];
|
||||
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||
return &value[1..value.len() - 1];
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
async fn get_mysql(env_var_name: &str) -> AnyhowResult<Pool<MySql>> {
|
||||
let url = easyenv::get_env_string_required(env_var_name)?;
|
||||
// Redacted so you can SEE the host + database + params actually being used
|
||||
// (e.g. whether a database path is present) without leaking the password.
|
||||
info!("Connecting to MySQL {env_var_name}: {}", redact_db_url(&url));
|
||||
info!("Connecting to MySQL {env_var_name}...");
|
||||
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 20)?)
|
||||
.connect(&url)
|
||||
.connect(&easyenv::get_env_string_required(env_var_name)?)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Mask the user:password portion of a DB URL for logging, leaving scheme, host,
|
||||
/// port, database path, and query params visible.
|
||||
fn redact_db_url(url: &str) -> String {
|
||||
if let Some(scheme_end) = url.find("://") {
|
||||
let after = &url[scheme_end + 3..];
|
||||
if let Some(at) = after.rfind('@') {
|
||||
return format!("{}://***@{}", &url[..scheme_end], &after[at + 1..]);
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# backfill-user-spend-events
|
||||
|
||||
Backfills the `user_spend_events` table from historical Stripe payments
|
||||
(subscription creations, subscription renewals, and credit-pack purchases),
|
||||
idempotently and safely.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Stripe is the authoritative payment list** (so we don't miss any payment,
|
||||
even ones the live webhook path failed to fulfill):
|
||||
- Paid **invoices** with `billing_reason` ∈ {`subscription_create`,
|
||||
`subscription_cycle`} → `subscription_initial` / `subscription_renewal`.
|
||||
- Succeeded one-off **PaymentIntents** (carry our `user_token` metadata, or
|
||||
match a ledger `credit_banked` entry) → `credit_pack_purchase`.
|
||||
Using the ArtCraft Stripe key means this is ArtCraft-only (FakeYou is a
|
||||
separate Stripe account), so there is no FakeYou contamination.
|
||||
2. **The ArtCraft ledger (read pool) enriches** each payment with the granted
|
||||
credits (`maybe_credits_granted`) and the ledger-entry FK
|
||||
(`maybe_wallet_ledger_entry_token`), looked up by the Stripe object id.
|
||||
3. **User attribution**, in order: (a) Stripe object metadata `user_token`,
|
||||
(b) ledger entry by Stripe id, (c) `user_stripe_customer_links` by customer.
|
||||
Payments that resolve to no user are **written to a CSV and NOT inserted**.
|
||||
4. **Upsert** via `insert_user_spend_event`, keyed on
|
||||
`(payment_source, source_object_id)` — the same dedup key the webhook write
|
||||
path uses — so rows already written live are no-ops (no duplicates).
|
||||
|
||||
Read and write use **separate connections/credentials**, so you can dry-run
|
||||
against the read replica → your local DB before running it live.
|
||||
|
||||
## Required env (loaded from `.env-db-backfill-secrets`)
|
||||
|
||||
```
|
||||
MYSQL_READ_URL=mysql://USER:PASS@READ_REPLICA_HOST:PORT/storyteller
|
||||
MYSQL_WRITE_URL=mysql://USER:PASS@TARGET_HOST:PORT/storyteller # local for dry runs; prod when live
|
||||
STRIPE_ARTCRAFT_SECRET_KEY=sk_live_or_test_for_the_ArtCraft_account
|
||||
# optional: MYSQL_MAX_CONNECTIONS (default 20)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Safe dry run: enumerate + resolve + write the unattributed CSV, but DON'T write events.
|
||||
cargo run -p db-backfill -- backfill-user-spend-events --since 2026-02-01 --dry-run --limit 50
|
||||
|
||||
# Real run into MYSQL_WRITE_URL (point it at LOCAL first to validate):
|
||||
cargo run -p db-backfill -- backfill-user-spend-events --since 2026-02-01
|
||||
|
||||
# Go live by pointing MYSQL_WRITE_URL at production and re-running (idempotent).
|
||||
```
|
||||
|
||||
Flags: `--since YYYY-MM-DD` (default `2026-02-01`), `--dry-run`,
|
||||
`--unattributed-report PATH` (default `unattributed_payments.csv`),
|
||||
`--limit N` (cap per type, for testing).
|
||||
|
||||
## Notes
|
||||
|
||||
- Prorations (`subscription_update` invoices) are intentionally skipped, matching
|
||||
the webhook write-path scope. The `PaymentEventType` enum has
|
||||
`subscription_proration_upgrade/downgrade` if you later want them.
|
||||
- `maybe_stripe_event_id` is `NULL` for backfilled rows (no originating webhook).
|
||||
- Because it's idempotent, it's safe to re-run after fixing user links to pick up
|
||||
previously-unattributed payments.
|
||||
-515
@@ -1,515 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
||||
use log::{info, warn};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use enums::by_table::user_spend_events::payment_event_type::PaymentEventType;
|
||||
use enums::by_table::user_spend_events::payment_source::PaymentSource;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use errors::AnyhowResult;
|
||||
use mysql_queries::queries::user_spend_events::backfill_get_user_token_by_email::{
|
||||
backfill_get_user_token_by_email, BackfillGetUserTokenByEmailArgs,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_events::backfill_list_artcraft_customer_links::{
|
||||
backfill_list_artcraft_customer_links, BackfillListCustomerLinksArgs,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_events::backfill_list_artcraft_ledger_payments::{
|
||||
backfill_list_artcraft_ledger_payments, BackfillListLedgerPaymentsArgs, LedgerPaymentRef,
|
||||
};
|
||||
use mysql_queries::queries::user_spend_events::insert_user_spend_event::{
|
||||
insert_user_spend_event, InsertUserSpendEventArgs,
|
||||
};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::operations::backfill_user_spend_events::stripe_api::{Invoice, StripeApi};
|
||||
use crate::operations::backfill_user_spend_events::sub_args::BackfillUserSpendEventsArgs;
|
||||
|
||||
/// Backfill `user_spend_events` from historical Stripe payments.
|
||||
///
|
||||
/// Stripe (REST) is the authoritative payment list (so we don't miss charged-
|
||||
/// but-unfulfilled payments); the ArtCraft ledger (read pool) only ENRICHES each
|
||||
/// payment with the granted credits + ledger-entry FK. Every event is upserted
|
||||
/// (write pool) on the same `(payment_source, source_object_id)` dedup key the
|
||||
/// webhook path uses, so rows already written live are no-ops. Payments that
|
||||
/// can't be linked to a user are written to a CSV instead of inserted.
|
||||
pub async fn backfill_user_spend_events(
|
||||
read_pool: &Pool<MySql>,
|
||||
write_pool: &Pool<MySql>,
|
||||
stripe: &StripeApi,
|
||||
args: BackfillUserSpendEventsArgs,
|
||||
) -> AnyhowResult<()> {
|
||||
let started = Instant::now();
|
||||
let since = parse_since(&args.since)?;
|
||||
info!("Backfill user_spend_events since {since} (UTC). dry_run={}", args.dry_run);
|
||||
|
||||
// ---- 1. Enrichment maps from the READ pool (replica) ----
|
||||
let ledger_rows = backfill_list_artcraft_ledger_payments(BackfillListLedgerPaymentsArgs {
|
||||
since,
|
||||
mysql_executor: read_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
info!("Loaded {} ArtCraft ledger payment refs (enrichment)", ledger_rows.len());
|
||||
let ledger_by_id: HashMap<String, LedgerPaymentRef> =
|
||||
ledger_rows.into_iter().map(|r| (r.stripe_object_id.clone(), r)).collect();
|
||||
|
||||
let links = backfill_list_artcraft_customer_links(BackfillListCustomerLinksArgs {
|
||||
mysql_executor: read_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
info!("Loaded {} ArtCraft customer links (fallback attribution)", links.len());
|
||||
let user_by_customer: HashMap<String, UserToken> =
|
||||
links.into_iter().map(|l| (l.stripe_customer_id, l.user_token)).collect();
|
||||
|
||||
let mut stats = Stats::default();
|
||||
let mut unattributed: Vec<UnattributedRow> = Vec::new();
|
||||
// Stripe customer -> resolved user, cached so deep attribution retrieves each
|
||||
// customer at most once.
|
||||
let mut customer_cache: HashMap<String, Option<UserToken>> = HashMap::new();
|
||||
|
||||
// ---- 2. Credit-pack purchases: succeeded one-off PaymentIntents ----
|
||||
backfill_payment_intents(
|
||||
stripe, read_pool, write_pool, since, &args, &ledger_by_id, &user_by_customer,
|
||||
&mut customer_cache, &mut stats, &mut unattributed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- 3. Subscription payments: paid invoices (create/cycle) ----
|
||||
backfill_invoices(
|
||||
stripe, read_pool, write_pool, since, &args, &ledger_by_id, &user_by_customer,
|
||||
&mut customer_cache, &mut stats, &mut unattributed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- 4. Report unattributable payments ----
|
||||
write_unattributed_report(&args.unattributed_report, &unattributed)?;
|
||||
|
||||
info!("==== BACKFILL SUMMARY ====");
|
||||
info!(" credit-pack purchases upserted: {}", stats.credit_packs);
|
||||
info!(" subscription payments upserted: {}", stats.subscriptions);
|
||||
info!(" skipped (not a tracked type): {}", stats.skipped);
|
||||
info!(" UNATTRIBUTED (no user account): {} -> {}", unattributed.len(), args.unattributed_report);
|
||||
let elapsed = started.elapsed();
|
||||
info!(" elapsed: {:.1}s ({:.0} payments/sec)",
|
||||
elapsed.as_secs_f64(),
|
||||
(stats.credit_packs + stats.subscriptions) as f64 / elapsed.as_secs_f64().max(0.001),
|
||||
);
|
||||
if args.dry_run {
|
||||
info!(" (dry run — NOTHING was written to the database)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Enumerators ----
|
||||
|
||||
async fn backfill_payment_intents(
|
||||
stripe: &StripeApi,
|
||||
read_pool: &Pool<MySql>,
|
||||
write_pool: &Pool<MySql>,
|
||||
since: DateTime<Utc>,
|
||||
args: &BackfillUserSpendEventsArgs,
|
||||
ledger_by_id: &HashMap<String, LedgerPaymentRef>,
|
||||
user_by_customer: &HashMap<String, UserToken>,
|
||||
customer_cache: &mut HashMap<String, Option<UserToken>>,
|
||||
stats: &mut Stats,
|
||||
unattributed: &mut Vec<UnattributedRow>,
|
||||
) -> AnyhowResult<()> {
|
||||
info!("Enumerating Stripe PaymentIntents (credit packs) since {} …", since.format("%Y-%m-%d"));
|
||||
let mut starting_after: Option<String> = None;
|
||||
let mut processed = 0usize;
|
||||
let mut page_num = 0usize;
|
||||
|
||||
loop {
|
||||
let page = stripe.list_payment_intents(since.timestamp(), starting_after.as_deref()).await?;
|
||||
page_num += 1;
|
||||
if page.data.is_empty() {
|
||||
break;
|
||||
}
|
||||
let last_id = page.data.last().map(|pi| pi.id.clone());
|
||||
|
||||
for pi in &page.data {
|
||||
if let Some(limit) = args.limit {
|
||||
if processed >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pi.status != "succeeded" {
|
||||
continue;
|
||||
}
|
||||
let pi_id = pi.id.clone();
|
||||
// A one-off credit-pack PI carries our `user_token` metadata; subscription
|
||||
// PIs have empty metadata. The ledger `credit_banked` match is a backstop
|
||||
// for any historical pack PI that lacks metadata.
|
||||
let is_credit_pack = pi.metadata.contains_key("user_token")
|
||||
|| ledger_by_id.get(&pi_id).map(|l| l.entry_type == "credit_banked").unwrap_or(false);
|
||||
if !is_credit_pack {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
processed += 1;
|
||||
|
||||
let customer_id = pi.customer.clone();
|
||||
let charge_id = pi.latest_charge.clone();
|
||||
let occurred_at = DateTime::from_timestamp(pi.created, 0).unwrap_or_else(Utc::now);
|
||||
let mut maybe_user = resolve_user(
|
||||
pi.metadata.get("user_token").map(|s| s.as_str()),
|
||||
&pi_id, customer_id.as_deref(), ledger_by_id, user_by_customer,
|
||||
);
|
||||
if maybe_user.is_none() && args.deep_attribution {
|
||||
maybe_user = deep_resolve_user(stripe, read_pool, customer_cache, customer_id.as_deref(), None).await;
|
||||
}
|
||||
|
||||
match maybe_user {
|
||||
Some(user) => {
|
||||
upsert_event(write_pool, args.dry_run, UpsertInput {
|
||||
event_type: PaymentEventType::CreditPackPurchase,
|
||||
amount_usd_cents: pi.amount_received,
|
||||
source_object_id: &pi_id,
|
||||
invoice_id: None,
|
||||
payment_intent_id: Some(&pi_id),
|
||||
charge_id: charge_id.as_deref(),
|
||||
customer_id: customer_id.as_deref(),
|
||||
user: &user,
|
||||
ledger: ledger_by_id.get(&pi_id),
|
||||
is_production: pi.livemode,
|
||||
occurred_at,
|
||||
})
|
||||
.await?;
|
||||
stats.credit_packs += 1;
|
||||
}
|
||||
None => unattributed.push(UnattributedRow {
|
||||
kind: "credit_pack",
|
||||
stripe_id: pi_id.clone(),
|
||||
amount_usd_cents: pi.amount_received,
|
||||
customer_id: customer_id.clone(),
|
||||
occurred_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
" payment_intents page {page_num}: {} fetched | packs={} skipped={} unattributed={}",
|
||||
page.data.len(), stats.credit_packs, stats.skipped, unattributed.len(),
|
||||
);
|
||||
if !page.has_more {
|
||||
break;
|
||||
}
|
||||
starting_after = last_id;
|
||||
}
|
||||
info!("PaymentIntents done: {} credit-pack event(s) across {page_num} page(s).", stats.credit_packs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_invoices(
|
||||
stripe: &StripeApi,
|
||||
read_pool: &Pool<MySql>,
|
||||
write_pool: &Pool<MySql>,
|
||||
since: DateTime<Utc>,
|
||||
args: &BackfillUserSpendEventsArgs,
|
||||
ledger_by_id: &HashMap<String, LedgerPaymentRef>,
|
||||
user_by_customer: &HashMap<String, UserToken>,
|
||||
customer_cache: &mut HashMap<String, Option<UserToken>>,
|
||||
stats: &mut Stats,
|
||||
unattributed: &mut Vec<UnattributedRow>,
|
||||
) -> AnyhowResult<()> {
|
||||
info!("Enumerating Stripe paid invoices (subscriptions) since {} …", since.format("%Y-%m-%d"));
|
||||
let mut starting_after: Option<String> = None;
|
||||
let mut processed = 0usize;
|
||||
let mut page_num = 0usize;
|
||||
|
||||
loop {
|
||||
let page = stripe.list_paid_invoices(since.timestamp(), starting_after.as_deref()).await?;
|
||||
page_num += 1;
|
||||
if page.data.is_empty() {
|
||||
break;
|
||||
}
|
||||
let last_id = page.data.last().and_then(|inv| inv.id.clone());
|
||||
|
||||
for inv in &page.data {
|
||||
if let Some(limit) = args.limit {
|
||||
if processed >= limit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Only subscription creation + renewal are in scope (prorations / manual
|
||||
// invoices are skipped, matching the webhook write-path).
|
||||
let event_type = match inv.billing_reason.as_deref() {
|
||||
Some("subscription_create") => PaymentEventType::SubscriptionInitial,
|
||||
Some("subscription_cycle") => PaymentEventType::SubscriptionRenewal,
|
||||
_ => {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let inv_id = match inv.id.clone() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
processed += 1;
|
||||
|
||||
let customer_id = inv.customer.clone();
|
||||
let occurred_at = DateTime::from_timestamp(inv.created, 0).unwrap_or_else(Utc::now);
|
||||
let mut maybe_user = resolve_user(
|
||||
invoice_metadata_user_token(inv),
|
||||
&inv_id, customer_id.as_deref(), ledger_by_id, user_by_customer,
|
||||
);
|
||||
if maybe_user.is_none() && args.deep_attribution {
|
||||
maybe_user = deep_resolve_user(
|
||||
stripe, read_pool, customer_cache, customer_id.as_deref(), inv.customer_email.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match maybe_user {
|
||||
Some(user) => {
|
||||
upsert_event(write_pool, args.dry_run, UpsertInput {
|
||||
event_type,
|
||||
amount_usd_cents: inv.amount_paid,
|
||||
source_object_id: &inv_id,
|
||||
invoice_id: Some(&inv_id),
|
||||
payment_intent_id: None,
|
||||
charge_id: None,
|
||||
customer_id: customer_id.as_deref(),
|
||||
user: &user,
|
||||
ledger: ledger_by_id.get(&inv_id),
|
||||
is_production: inv.livemode,
|
||||
occurred_at,
|
||||
})
|
||||
.await?;
|
||||
stats.subscriptions += 1;
|
||||
}
|
||||
None => unattributed.push(UnattributedRow {
|
||||
kind: "subscription",
|
||||
stripe_id: inv_id.clone(),
|
||||
amount_usd_cents: inv.amount_paid,
|
||||
customer_id: customer_id.clone(),
|
||||
occurred_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
" invoices page {page_num}: {} fetched | subscriptions={} skipped={} unattributed={}",
|
||||
page.data.len(), stats.subscriptions, stats.skipped, unattributed.len(),
|
||||
);
|
||||
if !page.has_more {
|
||||
break;
|
||||
}
|
||||
starting_after = last_id;
|
||||
}
|
||||
info!("Invoices done: {} subscription event(s) across {page_num} page(s).", stats.subscriptions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// Attribute a payment to a user, in order of reliability:
|
||||
/// 1. Stripe object metadata `user_token` (set at checkout).
|
||||
/// 2. The wallet ledger by Stripe id (`wallets.owner_user_token`) — this is
|
||||
/// the "other half" that covers fulfilled subscriptions, which the customer
|
||||
/// links table does NOT reliably have.
|
||||
/// 3. `user_stripe_customer_links` by customer id — LAST resort (sparse for
|
||||
/// subscriptions).
|
||||
fn resolve_user(
|
||||
metadata_user_token: Option<&str>,
|
||||
stripe_object_id: &str,
|
||||
customer_id: Option<&str>,
|
||||
ledger_by_id: &HashMap<String, LedgerPaymentRef>,
|
||||
user_by_customer: &HashMap<String, UserToken>,
|
||||
) -> Option<UserToken> {
|
||||
if let Some(user_token) = metadata_user_token {
|
||||
if !user_token.is_empty() {
|
||||
return Some(UserToken(user_token.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(ledger) = ledger_by_id.get(stripe_object_id) {
|
||||
return Some(ledger.owner_user_token.clone());
|
||||
}
|
||||
if let Some(customer_id) = customer_id {
|
||||
if let Some(user_token) = user_by_customer.get(customer_id) {
|
||||
return Some(user_token.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract our `user_token` from an invoice. Subscription invoices usually carry
|
||||
/// it on the subscription/line-item metadata, NOT the top-level invoice metadata
|
||||
/// (which is typically empty), so we check all three locations.
|
||||
fn invoice_metadata_user_token(inv: &Invoice) -> Option<&str> {
|
||||
if let Some(v) = inv.metadata.get("user_token") {
|
||||
if !v.is_empty() {
|
||||
return Some(v.as_str());
|
||||
}
|
||||
}
|
||||
if let Some(sd) = inv.parent.as_ref().and_then(|p| p.subscription_details.as_ref()) {
|
||||
if let Some(v) = sd.metadata.get("user_token") {
|
||||
if !v.is_empty() {
|
||||
return Some(v.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(line) = inv.lines.data.get(0) {
|
||||
if let Some(v) = line.metadata.get("user_token") {
|
||||
if !v.is_empty() {
|
||||
return Some(v.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Deep attribution (opt-in): when the cheap path fails, interrogate Stripe.
|
||||
/// (1) invoice email -> users; (2) retrieve the Stripe Customer and use its
|
||||
/// `user_token` metadata, then its email -> users. Customer retrieval is cached.
|
||||
async fn deep_resolve_user(
|
||||
stripe: &StripeApi,
|
||||
read_pool: &Pool<MySql>,
|
||||
customer_cache: &mut HashMap<String, Option<UserToken>>,
|
||||
customer_id: Option<&str>,
|
||||
invoice_email: Option<&str>,
|
||||
) -> Option<UserToken> {
|
||||
if let Some(email) = invoice_email {
|
||||
if let Some(user) = lookup_user_token_by_email(read_pool, email).await {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
|
||||
let customer_id = customer_id?;
|
||||
if let Some(cached) = customer_cache.get(customer_id) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let mut resolved: Option<UserToken> = None;
|
||||
match stripe.retrieve_customer(customer_id).await {
|
||||
Ok(customer) if !customer.deleted => {
|
||||
if let Some(user_token) = customer.metadata.get("user_token") {
|
||||
if !user_token.is_empty() {
|
||||
resolved = Some(UserToken(user_token.clone()));
|
||||
}
|
||||
}
|
||||
if resolved.is_none() {
|
||||
if let Some(email) = &customer.email {
|
||||
resolved = lookup_user_token_by_email(read_pool, email).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_deleted) => {}
|
||||
Err(err) => warn!("deep attribution: retrieve customer {customer_id} failed: {err:?}"),
|
||||
}
|
||||
|
||||
customer_cache.insert(customer_id.to_string(), resolved.clone());
|
||||
resolved
|
||||
}
|
||||
|
||||
async fn lookup_user_token_by_email(read_pool: &Pool<MySql>, email: &str) -> Option<UserToken> {
|
||||
match backfill_get_user_token_by_email(BackfillGetUserTokenByEmailArgs {
|
||||
email_address: email,
|
||||
mysql_executor: read_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
warn!("deep attribution: email lookup for {email:?} failed: {err:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UpsertInput<'a> {
|
||||
event_type: PaymentEventType,
|
||||
amount_usd_cents: i64,
|
||||
source_object_id: &'a str,
|
||||
invoice_id: Option<&'a str>,
|
||||
payment_intent_id: Option<&'a str>,
|
||||
charge_id: Option<&'a str>,
|
||||
customer_id: Option<&'a str>,
|
||||
user: &'a UserToken,
|
||||
ledger: Option<&'a LedgerPaymentRef>,
|
||||
is_production: bool,
|
||||
occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
async fn upsert_event(write_pool: &Pool<MySql>, dry_run: bool, input: UpsertInput<'_>) -> AnyhowResult<()> {
|
||||
if dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
insert_user_spend_event(InsertUserSpendEventArgs {
|
||||
payments_namespace: PaymentsNamespace::Artcraft,
|
||||
maybe_user_token: Some(input.user),
|
||||
event_type: input.event_type,
|
||||
amount_usd_cents: input.amount_usd_cents,
|
||||
maybe_credits_granted: input.ledger.and_then(|l| u32::try_from(l.credits_delta).ok()),
|
||||
maybe_user_subscription_token: None,
|
||||
maybe_wallet_ledger_entry_token: input.ledger.map(|l| &l.ledger_token),
|
||||
payment_source: PaymentSource::Stripe,
|
||||
maybe_source_object_id: Some(input.source_object_id),
|
||||
maybe_stripe_customer_id: input.customer_id,
|
||||
maybe_stripe_invoice_id: input.invoice_id,
|
||||
maybe_stripe_payment_intent_id: input.payment_intent_id,
|
||||
maybe_stripe_charge_id: input.charge_id,
|
||||
maybe_stripe_event_id: None, // backfill has no originating webhook event
|
||||
is_production: input.is_production,
|
||||
payment_occurred_at: input.occurred_at,
|
||||
mysql_executor: write_pool,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_unattributed_report(path: &str, rows: &[UnattributedRow]) -> AnyhowResult<()> {
|
||||
let mut file = File::create(path)?;
|
||||
writeln!(file, "kind,stripe_object_id,amount_usd_cents,stripe_customer_id,occurred_at,reason")?;
|
||||
for row in rows {
|
||||
writeln!(
|
||||
file,
|
||||
"{},{},{},{},{},no user_token in metadata/ledger/customer_link",
|
||||
row.kind,
|
||||
row.stripe_id,
|
||||
row.amount_usd_cents,
|
||||
row.customer_id.as_deref().unwrap_or(""),
|
||||
row.occurred_at.to_rfc3339(),
|
||||
)?;
|
||||
}
|
||||
if !rows.is_empty() {
|
||||
warn!("{} payment(s) could not be linked to a user — see {}", rows.len(), path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_since(s: &str) -> AnyhowResult<DateTime<Utc>> {
|
||||
let date = NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map_err(|err| anyhow::anyhow!("invalid --since {s:?} (want YYYY-MM-DD): {err}"))?;
|
||||
let naive = date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid time for --since {s:?}"))?;
|
||||
Ok(Utc.from_utc_datetime(&naive))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Stats {
|
||||
credit_packs: usize,
|
||||
subscriptions: usize,
|
||||
skipped: usize,
|
||||
}
|
||||
|
||||
struct UnattributedRow {
|
||||
kind: &'static str,
|
||||
stripe_id: String,
|
||||
amount_usd_cents: i64,
|
||||
customer_id: Option<String>,
|
||||
occurred_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod backfill_user_spend_events;
|
||||
pub mod stripe_api;
|
||||
pub mod sub_args;
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use errors::AnyhowResult;
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
const STRIPE_API_BASE: &str = "https://api.stripe.com/v1";
|
||||
const PAGE_LIMIT: &str = "100";
|
||||
const MAX_ERROR_BODY_CHARS: usize = 400;
|
||||
|
||||
/// Minimal Stripe REST client for the backfill.
|
||||
///
|
||||
/// It deliberately does NOT use the typed `async-stripe` SDK: that SDK
|
||||
/// deserializes the entire response with `miniserde` into fully-typed structs,
|
||||
/// so a single object anywhere in a page that has an unexpected shape fails the
|
||||
/// whole request ("error deserializing request data"). A production account has
|
||||
/// far too much variety for that to be reliable. Instead we parse with `serde`
|
||||
/// into small structs holding ONLY the fields we need; unknown fields are
|
||||
/// ignored, so it tolerates whatever else Stripe returns.
|
||||
pub struct StripeApi {
|
||||
http: Client,
|
||||
secret_key: String,
|
||||
}
|
||||
|
||||
/// A Stripe `list` envelope.
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeList<T> {
|
||||
#[serde(default = "Vec::new")]
|
||||
pub data: Vec<T>,
|
||||
#[serde(default)]
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PaymentIntent {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub amount_received: i64,
|
||||
#[serde(default)]
|
||||
pub created: i64,
|
||||
#[serde(default)]
|
||||
pub livemode: bool,
|
||||
/// Unexpanded: the customer id string (or null).
|
||||
#[serde(default)]
|
||||
pub customer: Option<String>,
|
||||
/// Unexpanded: the charge id string (or null).
|
||||
#[serde(default)]
|
||||
pub latest_charge: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Invoice {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub amount_paid: i64,
|
||||
#[serde(default)]
|
||||
pub created: i64,
|
||||
#[serde(default)]
|
||||
pub livemode: bool,
|
||||
/// e.g. "subscription_create", "subscription_cycle".
|
||||
#[serde(default)]
|
||||
pub billing_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub customer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub customer_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub lines: InvoiceLines,
|
||||
#[serde(default)]
|
||||
pub parent: Option<InvoiceParent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct InvoiceLines {
|
||||
#[serde(default)]
|
||||
pub data: Vec<InvoiceLine>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InvoiceLine {
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InvoiceParent {
|
||||
#[serde(default)]
|
||||
pub subscription_details: Option<SubscriptionDetails>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SubscriptionDetails {
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Customer {
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
/// Present and `true` for deleted customers.
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
impl StripeApi {
|
||||
pub fn new(secret_key: String) -> AnyhowResult<Self> {
|
||||
let http = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|err| anyhow!("building reqwest client: {err}"))?;
|
||||
Ok(Self { http, secret_key })
|
||||
}
|
||||
|
||||
/// One page of PaymentIntents created at/after `created_gte` (unix seconds).
|
||||
pub async fn list_payment_intents(
|
||||
&self,
|
||||
created_gte: i64,
|
||||
starting_after: Option<&str>,
|
||||
) -> AnyhowResult<StripeList<PaymentIntent>> {
|
||||
let created_gte = created_gte.to_string();
|
||||
let mut query: Vec<(&str, &str)> =
|
||||
vec![("created[gte]", &created_gte), ("limit", PAGE_LIMIT)];
|
||||
if let Some(after) = starting_after {
|
||||
query.push(("starting_after", after));
|
||||
}
|
||||
self.get_list("/payment_intents", &query).await
|
||||
}
|
||||
|
||||
/// One page of PAID invoices created at/after `created_gte` (unix seconds).
|
||||
pub async fn list_paid_invoices(
|
||||
&self,
|
||||
created_gte: i64,
|
||||
starting_after: Option<&str>,
|
||||
) -> AnyhowResult<StripeList<Invoice>> {
|
||||
let created_gte = created_gte.to_string();
|
||||
let mut query: Vec<(&str, &str)> =
|
||||
vec![("created[gte]", &created_gte), ("status", "paid"), ("limit", PAGE_LIMIT)];
|
||||
if let Some(after) = starting_after {
|
||||
query.push(("starting_after", after));
|
||||
}
|
||||
self.get_list("/invoices", &query).await
|
||||
}
|
||||
|
||||
pub async fn retrieve_customer(&self, customer_id: &str) -> AnyhowResult<Customer> {
|
||||
let url = format!("{STRIPE_API_BASE}/customers/{customer_id}");
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.secret_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| anyhow!("Stripe GET {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|err| anyhow!("Stripe read body {url}: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("Stripe {status} retrieving customer {customer_id}: {}", truncate(&body)));
|
||||
}
|
||||
serde_json::from_str::<Customer>(&body).map_err(|err| anyhow!("parsing customer {customer_id}: {err}"))
|
||||
}
|
||||
|
||||
async fn get_list<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> AnyhowResult<StripeList<T>> {
|
||||
let url = format!("{STRIPE_API_BASE}{path}");
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(query)
|
||||
.bearer_auth(&self.secret_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| anyhow!("Stripe GET {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|err| anyhow!("Stripe read body {url}: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("Stripe {status} on {path}: {}", truncate(&body)));
|
||||
}
|
||||
serde_json::from_str::<StripeList<T>>(&body).map_err(|err| anyhow!("parsing {path} response: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate (on a char boundary) for safe error logging. Stripe error bodies
|
||||
/// contain messages, not secrets.
|
||||
fn truncate(s: &str) -> String {
|
||||
if s.chars().count() > MAX_ERROR_BODY_CHARS {
|
||||
format!("{}…", s.chars().take(MAX_ERROR_BODY_CHARS).collect::<String>())
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
use clap::Parser;
|
||||
|
||||
/// Flags for the `backfill-user-spend-events` sub-command.
|
||||
///
|
||||
/// Parsed from the args AFTER the sub-command token, e.g.:
|
||||
/// db-backfill backfill-user-spend-events --since 2026-02-01 --dry-run
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "backfill-user-spend-events")]
|
||||
pub struct BackfillUserSpendEventsArgs {
|
||||
/// Only backfill payments whose Stripe object was created on/after this UTC
|
||||
/// date (YYYY-MM-DD). Payments effectively start in February.
|
||||
#[arg(long, default_value = "2026-02-01")]
|
||||
pub since: String,
|
||||
|
||||
/// Plan only: enumerate + resolve + report, but do NOT upsert anything.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// CSV path where payments that could not be linked to a user account are
|
||||
/// written (one row each) for manual follow-up.
|
||||
#[arg(long, default_value = "unattributed_payments.csv")]
|
||||
pub unattributed_report: String,
|
||||
|
||||
/// Optional cap on the number of Stripe objects processed per type. For
|
||||
/// testing against the read replica without walking the whole account.
|
||||
#[arg(long)]
|
||||
pub limit: Option<usize>,
|
||||
|
||||
/// For payments that don't resolve to a user via metadata / ledger / customer
|
||||
/// links, additionally interrogate Stripe: retrieve the Customer and use its
|
||||
/// `user_token` metadata, then fall back to matching the customer/invoice
|
||||
/// email against our users table. Costs extra Stripe API calls (cached per
|
||||
/// customer), so it's opt-in.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub deep_attribution: bool,
|
||||
}
|
||||
|
||||
/// Parse the sub-command flags. The first element of `args()` is the binary and
|
||||
/// the second is the sub-command token; skipping one leaves the sub-command
|
||||
/// token as clap's "binary name" (ignored) followed by the flags.
|
||||
pub fn parse_backfill_user_spend_events_args() -> BackfillUserSpendEventsArgs {
|
||||
BackfillUserSpendEventsArgs::parse_from(std::env::args().skip(1))
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod backfill_user_spend_events;
|
||||
pub mod calculate_legacy_tts_results_usages;
|
||||
pub mod calculate_model_weights_usages;
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod tauri_image_model_to_common_model_type;
|
||||
pub mod tauri_image_model_to_enums_model;
|
||||
pub mod tauri_image_model_to_generation_model;
|
||||
pub mod tauri_image_model_to_router_model;
|
||||
pub mod tauri_image_model_to_router_model;
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
|
||||
use crate::core::commands::generate::generate_image::tauri_image_model::TauriImageModel;
|
||||
|
||||
/// Map TauriImageModel to the legacy CommonModelType (enums crate).
|
||||
pub fn tauri_image_model_to_common_model_type(model: TauriImageModel) -> CommonModelType {
|
||||
match model {
|
||||
TauriImageModel::Flux1Dev => CommonModelType::Flux1Dev,
|
||||
TauriImageModel::Flux1Schnell => CommonModelType::Flux1Schnell,
|
||||
TauriImageModel::FluxPro11 => CommonModelType::FluxPro11,
|
||||
TauriImageModel::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
|
||||
TauriImageModel::GrokImage => CommonModelType::GrokImage,
|
||||
TauriImageModel::Recraft3 => CommonModelType::Recraft3,
|
||||
TauriImageModel::GptImage1 => CommonModelType::GptImage1,
|
||||
TauriImageModel::GptImage1p5 => CommonModelType::GptImage1p5,
|
||||
TauriImageModel::GptImage2 => CommonModelType::GptImage2,
|
||||
TauriImageModel::Gemini25Flash => CommonModelType::NanoBanana,
|
||||
TauriImageModel::NanoBanana => CommonModelType::NanoBanana,
|
||||
TauriImageModel::NanoBanana2 => CommonModelType::NanoBanana2,
|
||||
TauriImageModel::NanoBananaPro => CommonModelType::NanoBananaPro,
|
||||
TauriImageModel::Seedream4 => CommonModelType::Seedream4,
|
||||
TauriImageModel::Seedream4p5 => CommonModelType::Seedream4p5,
|
||||
TauriImageModel::Seedream5Lite => CommonModelType::Seedream5Lite,
|
||||
TauriImageModel::Midjourney => CommonModelType::Midjourney,
|
||||
TauriImageModel::Midjourney7 => CommonModelType::Midjourney7,
|
||||
TauriImageModel::Midjourney7Niji => CommonModelType::Midjourney7Niji,
|
||||
TauriImageModel::Midjourney8 => CommonModelType::Midjourney8,
|
||||
TauriImageModel::FluxProKontextMax => CommonModelType::FluxProKontextMax,
|
||||
TauriImageModel::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
|
||||
TauriImageModel::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
|
||||
TauriImageModel::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut,
|
||||
TauriImageModel::FluxPro1 => CommonModelType::FluxPro1,
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -23,13 +23,10 @@ pub fn tauri_image_model_to_enums_model(model: TauriImageModel) -> Option<Common
|
||||
TauriImageModel::Seedream5Lite => Some(CommonImageModel::Seedream5Lite),
|
||||
TauriImageModel::QwenEdit2511Angles => Some(CommonImageModel::QwenEdit2511Angles),
|
||||
TauriImageModel::Flux2LoraAngles => Some(CommonImageModel::Flux2LoraAngles),
|
||||
TauriImageModel::Midjourney7 => Some(CommonImageModel::Midjourney7),
|
||||
TauriImageModel::Midjourney7Niji => Some(CommonImageModel::Midjourney7Niji),
|
||||
TauriImageModel::Midjourney8 => Some(CommonImageModel::Midjourney8),
|
||||
// Not accounted for yet
|
||||
TauriImageModel::GrokImage => None,
|
||||
TauriImageModel::Recraft3 => None,
|
||||
TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider.
|
||||
TauriImageModel::Midjourney => None,
|
||||
TauriImageModel::FluxProKontextMax => None,
|
||||
TauriImageModel::FluxDevJuggernaut => None,
|
||||
}
|
||||
|
||||
+1
-4
@@ -20,10 +20,7 @@ pub fn tauri_image_model_to_generation_model(model: TauriImageModel) -> Generati
|
||||
TauriImageModel::Seedream4 => GenerationModel::Seedream4,
|
||||
TauriImageModel::Seedream4p5 => GenerationModel::Seedream4p5,
|
||||
TauriImageModel::Seedream5Lite => GenerationModel::Seedream5Lite,
|
||||
TauriImageModel::Midjourney => GenerationModel::Midjourney,
|
||||
TauriImageModel::Midjourney7 => GenerationModel::Midjourney7,
|
||||
TauriImageModel::Midjourney7Niji => GenerationModel::Midjourney7Niji,
|
||||
TauriImageModel::Midjourney8 => GenerationModel::Midjourney8,
|
||||
TauriImageModel::Midjourney => GenerationModel::Flux1Dev, // Fallback
|
||||
TauriImageModel::FluxProKontextMax => GenerationModel::FluxProKontextMax,
|
||||
TauriImageModel::QwenEdit2511Angles => GenerationModel::QwenEdit2511Angles,
|
||||
TauriImageModel::Flux2LoraAngles => GenerationModel::Flux2LoraAngles,
|
||||
|
||||
+1
-4
@@ -23,13 +23,10 @@ pub fn tauri_image_model_to_router_model(model: TauriImageModel) -> Option<Route
|
||||
TauriImageModel::Seedream5Lite => Some(RouterImageModel::Seedream5Lite), // Text-to-Image
|
||||
TauriImageModel::QwenEdit2511Angles => Some(RouterImageModel::QwenEdit2511Angles),
|
||||
TauriImageModel::Flux2LoraAngles => Some(RouterImageModel::Flux2LoraAngles),
|
||||
TauriImageModel::Midjourney7 => Some(RouterImageModel::Midjourney7),
|
||||
TauriImageModel::Midjourney7Niji => Some(RouterImageModel::Midjourney7Niji),
|
||||
TauriImageModel::Midjourney8 => Some(RouterImageModel::Midjourney8),
|
||||
// Not accounted for yet
|
||||
TauriImageModel::GrokImage => None,
|
||||
TauriImageModel::Recraft3 => None,
|
||||
TauriImageModel::Midjourney => None, // NB: Generic Midjourney is served by the native Midjourney provider.
|
||||
TauriImageModel::Midjourney => None,
|
||||
TauriImageModel::FluxProKontextMax => None,
|
||||
TauriImageModel::FluxDevJuggernaut => None,
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
pub mod image;
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod tauri_video_model_to_generation_model;
|
||||
pub mod tauri_video_model_to_router_model;
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
use crate::core::commands::generate::generate_video::request::TauriVideoModel;
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
|
||||
/// Map TauriVideoModel to the GenerationModel used in events and the task database.
|
||||
/// NB: GenerationModel serializations are stored in the tasks database — they keep
|
||||
/// their legacy spellings for compatibility.
|
||||
pub fn tauri_video_model_to_generation_model(model: TauriVideoModel) -> GenerationModel {
|
||||
match model {
|
||||
TauriVideoModel::GrokVideo => GenerationModel::GrokVideo,
|
||||
TauriVideoModel::GrokImagineVideo1p5 => GenerationModel::GrokImagineVideo1p5,
|
||||
TauriVideoModel::HappyHorse1p0 => GenerationModel::HappyHorse1p0,
|
||||
TauriVideoModel::Kling16Pro => GenerationModel::Kling1_6,
|
||||
TauriVideoModel::Kling21Master => GenerationModel::Kling21Master,
|
||||
TauriVideoModel::Kling21Pro => GenerationModel::Kling21Pro,
|
||||
TauriVideoModel::Kling2p5TurboPro => GenerationModel::Kling2p5TurboPro,
|
||||
TauriVideoModel::Kling2p6Pro => GenerationModel::Kling2p6Pro,
|
||||
TauriVideoModel::Kling3p0Pro => GenerationModel::Kling3p0Pro,
|
||||
TauriVideoModel::Kling3p0Standard => GenerationModel::Kling3p0Standard,
|
||||
TauriVideoModel::Seedance10Lite => GenerationModel::Seedance10Lite,
|
||||
TauriVideoModel::Seedance1p5Pro => GenerationModel::Seedance1p5Pro,
|
||||
TauriVideoModel::Seedance2p0 => GenerationModel::Seedance2p0,
|
||||
TauriVideoModel::Seedance2p0Fast => GenerationModel::Seedance2p0Fast,
|
||||
TauriVideoModel::Sora2 => GenerationModel::Sora2,
|
||||
TauriVideoModel::Sora2Pro => GenerationModel::Sora2Pro,
|
||||
TauriVideoModel::Veo2 => GenerationModel::Veo2,
|
||||
TauriVideoModel::Veo3 => GenerationModel::Veo3,
|
||||
TauriVideoModel::Veo3Fast => GenerationModel::Veo3Fast,
|
||||
TauriVideoModel::Veo3p1 => GenerationModel::Veo3p1,
|
||||
TauriVideoModel::Veo3p1Fast => GenerationModel::Veo3p1Fast,
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
|
||||
use crate::core::commands::generate::generate_video::request::TauriVideoModel;
|
||||
|
||||
/// Map TauriVideoModel to the artcraft_router's RouterVideoModel.
|
||||
pub fn tauri_video_model_to_router_model(model: TauriVideoModel) -> RouterVideoModel {
|
||||
match model {
|
||||
TauriVideoModel::GrokVideo => RouterVideoModel::GrokImagineVideo,
|
||||
TauriVideoModel::GrokImagineVideo1p5 => RouterVideoModel::GrokImagineVideo1p5,
|
||||
TauriVideoModel::HappyHorse1p0 => RouterVideoModel::HappyHorse1p0,
|
||||
TauriVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro,
|
||||
TauriVideoModel::Kling21Master => RouterVideoModel::Kling21Master,
|
||||
TauriVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro,
|
||||
TauriVideoModel::Kling2p5TurboPro => RouterVideoModel::Kling2p5TurboPro,
|
||||
TauriVideoModel::Kling2p6Pro => RouterVideoModel::Kling2p6Pro,
|
||||
TauriVideoModel::Kling3p0Pro => RouterVideoModel::Kling3p0Pro,
|
||||
TauriVideoModel::Kling3p0Standard => RouterVideoModel::Kling3p0Standard,
|
||||
TauriVideoModel::Seedance10Lite => RouterVideoModel::Seedance10Lite,
|
||||
TauriVideoModel::Seedance1p5Pro => RouterVideoModel::Seedance1p5Pro,
|
||||
TauriVideoModel::Seedance2p0 => RouterVideoModel::Seedance2p0,
|
||||
TauriVideoModel::Seedance2p0Fast => RouterVideoModel::Seedance2p0Fast,
|
||||
TauriVideoModel::Sora2 => RouterVideoModel::Sora2,
|
||||
TauriVideoModel::Sora2Pro => RouterVideoModel::Sora2Pro,
|
||||
TauriVideoModel::Veo2 => RouterVideoModel::Veo2,
|
||||
TauriVideoModel::Veo3 => RouterVideoModel::Veo3,
|
||||
TauriVideoModel::Veo3Fast => RouterVideoModel::Veo3Fast,
|
||||
TauriVideoModel::Veo3p1 => RouterVideoModel::Veo3p1,
|
||||
TauriVideoModel::Veo3p1Fast => RouterVideoModel::Veo3p1Fast,
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,6 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::GrokImage) => Some(TaskModelType::GrokImage),
|
||||
Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3),
|
||||
Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo),
|
||||
Some(GenerationModel::GrokImagineVideo1p5) => Some(TaskModelType::GrokImagineVideo1p5),
|
||||
Some(GenerationModel::Kling21Pro) => Some(TaskModelType::Kling21Pro),
|
||||
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
|
||||
Some(GenerationModel::Kling2p5TurboPro) => Some(TaskModelType::Kling2p5TurboPro),
|
||||
@@ -111,9 +110,6 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::WorldlabsMarble0p1Mini) => Some(TaskModelType::WorldlabsMarble0p1Mini),
|
||||
Some(GenerationModel::WorldlabsMarble0p1Plus) => Some(TaskModelType::WorldlabsMarble0p1Plus),
|
||||
Some(GenerationModel::Midjourney) => Some(TaskModelType::Midjourney), // NB: This is a generic Midjourney model, version unknown.
|
||||
Some(GenerationModel::Midjourney7) => Some(TaskModelType::Midjourney7),
|
||||
Some(GenerationModel::Midjourney7Niji) => Some(TaskModelType::Midjourney7Niji),
|
||||
Some(GenerationModel::Midjourney8) => Some(TaskModelType::Midjourney8),
|
||||
|
||||
// TODO: These seem wrong -
|
||||
Some(GenerationModel::Kling1_6) => Some(TaskModelType::Kling16Pro), // NB: `VideoModel::Kling16Pro`.
|
||||
|
||||
+33
-18
@@ -1,14 +1,10 @@
|
||||
use enums::common::generation::common_model_type::CommonModelType;
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Unified image model enum covering text-to-image, image edit, and inpainting.
|
||||
///
|
||||
/// This is used in the Tauri command bridge.
|
||||
/// Don't change the serializations without coordinating with the frontend.
|
||||
///
|
||||
/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni
|
||||
/// identifiers (`CommonImageModel` serde strings). Variants whose legacy Tauri
|
||||
/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize.
|
||||
/// Once the frontend is 100% omni ids, the legacy renames can be dropped.
|
||||
#[derive(Deserialize, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TauriImageModel {
|
||||
@@ -18,11 +14,11 @@ pub enum TauriImageModel {
|
||||
Flux1Dev,
|
||||
#[serde(rename = "flux_1_schnell")]
|
||||
Flux1Schnell,
|
||||
#[serde(rename = "flux_pro_11", alias = "flux_pro_1p1")]
|
||||
#[serde(rename = "flux_pro_11")]
|
||||
FluxPro11,
|
||||
#[serde(rename = "flux_pro_11_ultra", alias = "flux_pro_1p1_ultra")]
|
||||
#[serde(rename = "flux_pro_11_ultra")]
|
||||
FluxPro11Ultra,
|
||||
#[serde(rename = "grok_image", alias = "grok_imagine_image")]
|
||||
#[serde(rename = "grok_image")]
|
||||
GrokImage,
|
||||
#[serde(rename = "recraft_3")]
|
||||
Recraft3,
|
||||
@@ -46,19 +42,9 @@ pub enum TauriImageModel {
|
||||
Seedream4p5,
|
||||
#[serde(rename = "seedream_5_lite")]
|
||||
Seedream5Lite,
|
||||
// Generic Midjourney experience, served via the native Midjourney provider.
|
||||
#[serde(rename = "midjourney")]
|
||||
Midjourney,
|
||||
|
||||
// Versioned Midjourney models, served by storyteller-web via the
|
||||
// Artcraft provider (dispatched through artcraft_router).
|
||||
#[serde(rename = "midjourney_7")]
|
||||
Midjourney7,
|
||||
#[serde(rename = "midjourney_7_niji")]
|
||||
Midjourney7Niji,
|
||||
#[serde(rename = "midjourney_8")]
|
||||
Midjourney8,
|
||||
|
||||
// Image edit models
|
||||
|
||||
#[serde(rename = "flux_pro_kontext_max")]
|
||||
@@ -75,3 +61,32 @@ pub enum TauriImageModel {
|
||||
#[serde(rename = "flux_pro_1")]
|
||||
FluxPro1,
|
||||
}
|
||||
|
||||
impl TauriImageModel {
|
||||
pub fn to_common_model_type(&self) -> CommonModelType {
|
||||
match self {
|
||||
Self::Flux1Dev => CommonModelType::Flux1Dev,
|
||||
Self::Flux1Schnell => CommonModelType::Flux1Schnell,
|
||||
Self::FluxPro11 => CommonModelType::FluxPro11,
|
||||
Self::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
|
||||
Self::GrokImage => CommonModelType::GrokImage,
|
||||
Self::Recraft3 => CommonModelType::Recraft3,
|
||||
Self::GptImage1 => CommonModelType::GptImage1,
|
||||
Self::GptImage1p5 => CommonModelType::GptImage1p5,
|
||||
Self::GptImage2 => CommonModelType::GptImage2,
|
||||
Self::Gemini25Flash => CommonModelType::NanoBanana,
|
||||
Self::NanoBanana => CommonModelType::NanoBanana,
|
||||
Self::NanoBanana2 => CommonModelType::NanoBanana2,
|
||||
Self::NanoBananaPro => CommonModelType::NanoBananaPro,
|
||||
Self::Seedream4 => CommonModelType::Seedream4,
|
||||
Self::Seedream4p5 => CommonModelType::Seedream4p5,
|
||||
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
|
||||
Self::Midjourney => CommonModelType::Midjourney,
|
||||
Self::FluxProKontextMax => CommonModelType::FluxProKontextMax,
|
||||
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
|
||||
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
|
||||
Self::FluxDevJuggernaut => CommonModelType::FluxDevJuggernaut,
|
||||
Self::FluxPro1 => CommonModelType::FluxPro1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-8
@@ -1,12 +1,13 @@
|
||||
use crate::core::api_adapters::models::video::tauri_video_model_to_generation_model::tauri_video_model_to_generation_model;
|
||||
use crate::core::api_adapters::models::video::tauri_video_model_to_router_model::tauri_video_model_to_router_model;
|
||||
use crate::core::commands::enqueue::generate_error::GenerateError;
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::commands::generate::generate_video::artcraft::get_storyteller_creds_or_error::get_storyteller_creds_or_error;
|
||||
use crate::core::commands::generate::generate_video::artcraft::handle_artcraft_video_via_router::handle_artcraft_video_via_router;
|
||||
use crate::core::commands::generate::generate_video::request::TauriGenerateVideoRequest;
|
||||
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, TauriVideoModel};
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
|
||||
use anyhow::anyhow;
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub async fn handle_video_artcraft(
|
||||
@@ -18,13 +19,30 @@ pub async fn handle_video_artcraft(
|
||||
|
||||
let creds = get_storyteller_creds_or_error(app, storyteller_creds_manager)?;
|
||||
|
||||
let model = match request.model {
|
||||
let (router_model, generation_model) = match request.model {
|
||||
None => return Err(GenerateError::no_model_specified()),
|
||||
Some(model) => model,
|
||||
Some(TauriVideoModel::HappyHorse1p0) => (RouterVideoModel::HappyHorse1p0, GenerationModel::HappyHorse1p0),
|
||||
Some(TauriVideoModel::Kling16Pro) => (RouterVideoModel::Kling16Pro, GenerationModel::Kling1_6),
|
||||
Some(TauriVideoModel::Kling21Master) => (RouterVideoModel::Kling21Master, GenerationModel::Kling21Master),
|
||||
Some(TauriVideoModel::Kling21Pro) => (RouterVideoModel::Kling21Pro, GenerationModel::Kling21Pro),
|
||||
Some(TauriVideoModel::Kling2p5TurboPro) => (RouterVideoModel::Kling2p5TurboPro, GenerationModel::Kling2p5TurboPro),
|
||||
Some(TauriVideoModel::Kling2p6Pro) => (RouterVideoModel::Kling2p6Pro, GenerationModel::Kling2p6Pro),
|
||||
Some(TauriVideoModel::Kling3p0Pro) => (RouterVideoModel::Kling3p0Pro, GenerationModel::Kling3p0Pro),
|
||||
Some(TauriVideoModel::Kling3p0Standard) => (RouterVideoModel::Kling3p0Standard, GenerationModel::Kling3p0Standard),
|
||||
Some(TauriVideoModel::Seedance10Lite) => (RouterVideoModel::Seedance10Lite, GenerationModel::Seedance10Lite),
|
||||
Some(TauriVideoModel::Seedance1p5Pro) => (RouterVideoModel::Seedance1p5Pro, GenerationModel::Seedance1p5Pro),
|
||||
Some(TauriVideoModel::Seedance2p0) => (RouterVideoModel::Seedance2p0, GenerationModel::Seedance2p0),
|
||||
Some(TauriVideoModel::Seedance2p0Fast) => (RouterVideoModel::Seedance2p0Fast, GenerationModel::Seedance2p0Fast),
|
||||
Some(TauriVideoModel::Sora2) => (RouterVideoModel::Sora2, GenerationModel::Sora2),
|
||||
Some(TauriVideoModel::Sora2Pro) => (RouterVideoModel::Sora2Pro, GenerationModel::Sora2Pro),
|
||||
Some(TauriVideoModel::Veo2) => (RouterVideoModel::Veo2, GenerationModel::Veo2),
|
||||
Some(TauriVideoModel::Veo3) => (RouterVideoModel::Veo3, GenerationModel::Veo3),
|
||||
Some(TauriVideoModel::Veo3Fast) => (RouterVideoModel::Veo3Fast, GenerationModel::Veo3Fast),
|
||||
Some(TauriVideoModel::Veo3p1) => (RouterVideoModel::Veo3p1, GenerationModel::Veo3p1),
|
||||
Some(TauriVideoModel::Veo3p1Fast) => (RouterVideoModel::Veo3p1Fast, GenerationModel::Veo3p1Fast),
|
||||
Some(other) => return Err(GenerateError::AnyhowError(
|
||||
anyhow!("wrong logic: another branch should handle this: {:?}", other))),
|
||||
};
|
||||
|
||||
let router_model = tauri_video_model_to_router_model(model);
|
||||
let generation_model = tauri_video_model_to_generation_model(model);
|
||||
|
||||
handle_artcraft_video_via_router(request, app_env_configs, &creds, router_model, generation_model).await
|
||||
}
|
||||
|
||||
-4
@@ -150,10 +150,6 @@ async fn handle_request(
|
||||
|
||||
let provider = match (model, request.provider) {
|
||||
(TauriVideoModel::GrokVideo, _) => GenerationProvider::Grok,
|
||||
// NB: Grok Imagine 1.5 is served via Artcraft (storyteller-web / artcraft_router)
|
||||
// for now — force it regardless of the requested provider. In the future we
|
||||
// may serve it natively via the Grok provider.
|
||||
(TauriVideoModel::GrokImagineVideo1p5, _) => GenerationProvider::Artcraft,
|
||||
_ => request.provider.unwrap_or(GenerationProvider::Artcraft),
|
||||
};
|
||||
|
||||
|
||||
+2
-9
@@ -1,7 +1,7 @@
|
||||
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
|
||||
use crate::core::commands::deprecated::image_edit::enqueue_edit_image_command::EnqueueEditImageCommand;
|
||||
use crate::core::commands::deprecated::image_inpaint::enqueue_image_inpaint_command::EnqueueInpaintImageCommand;
|
||||
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, TauriVideoModel, GrokAspectRatio, SoraOrientation};
|
||||
use crate::core::commands::generate::generate_video::request::{TauriGenerateVideoRequest, GrokAspectRatio, SoraOrientation};
|
||||
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
|
||||
use crate::core::events::functional_events::show_provider_login_modal_event::ShowProviderLoginModalEvent;
|
||||
use crate::core::events::generation_events::common::GenerationModel;
|
||||
@@ -91,16 +91,9 @@ pub async fn handle_grok_video(
|
||||
}
|
||||
};
|
||||
|
||||
// NB: Grok's consumer API doesn't take a version selector; this only affects
|
||||
// which model is recorded for the task queue / events.
|
||||
let generation_model = match request.model {
|
||||
Some(TauriVideoModel::GrokImagineVideo1p5) => GenerationModel::GrokImagineVideo1p5,
|
||||
_ => GenerationModel::GrokVideo,
|
||||
};
|
||||
|
||||
Ok(TaskEnqueueSuccess {
|
||||
provider: GenerationProvider::Grok,
|
||||
model: Some(generation_model),
|
||||
model: Some(GenerationModel::GrokVideo),
|
||||
provider_job_id: Some(post_id.to_string()),
|
||||
task_type: TaskType::VideoGeneration,
|
||||
maybe_queue_status_url: None,
|
||||
|
||||
@@ -10,27 +10,19 @@ use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
/// This is used in the Tauri command bridge.
|
||||
/// Don't change the serializations without coordinating with the frontend.
|
||||
///
|
||||
/// MIGRATION (2026-07): we're moving the frontend to send storyteller-web omni
|
||||
/// identifiers (`CommonVideoModel` serde strings). Variants whose legacy Tauri
|
||||
/// id differs carry the omni id as a `#[serde(alias)]` so BOTH deserialize.
|
||||
/// Once the frontend is 100% omni ids, the legacy renames can be dropped.
|
||||
#[derive(Deserialize, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TauriVideoModel {
|
||||
#[serde(rename = "grok_video", alias = "grok_imagine_video")]
|
||||
#[serde(rename = "grok_video")]
|
||||
GrokVideo,
|
||||
|
||||
#[serde(rename = "grok_imagine_video_1p5")]
|
||||
GrokImagineVideo1p5,
|
||||
|
||||
#[serde(rename = "kling_1.6_pro", alias = "kling_1p6_pro")]
|
||||
#[serde(rename = "kling_1.6_pro")]
|
||||
Kling16Pro,
|
||||
|
||||
#[serde(rename = "kling_2.1_pro", alias = "kling_2p1_pro")]
|
||||
#[serde(rename = "kling_2.1_pro")]
|
||||
Kling21Pro,
|
||||
|
||||
#[serde(rename = "kling_2.1_master", alias = "kling_2p1_master")]
|
||||
#[serde(rename = "kling_2.1_master")]
|
||||
Kling21Master,
|
||||
|
||||
#[serde(rename = "kling_2p5_turbo_pro")]
|
||||
@@ -48,7 +40,7 @@ pub enum TauriVideoModel {
|
||||
#[serde(rename = "happy_horse_1p0")]
|
||||
HappyHorse1p0,
|
||||
|
||||
#[serde(rename = "seedance_1.0_lite", alias = "seedance_1p0_lite")]
|
||||
#[serde(rename = "seedance_1.0_lite")]
|
||||
Seedance10Lite,
|
||||
|
||||
#[serde(rename = "seedance_1p5_pro")]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod generate_image;
|
||||
pub mod generate_video;
|
||||
pub mod common;
|
||||
pub mod models;
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
//! Tauri command: list available image models.
|
||||
//!
|
||||
//! Maps the API-client response into command-specific types (see `response_types`),
|
||||
//! retries the request up to `MAX_ATTEMPTS` times, and caches successful responses
|
||||
//! in-memory for `CACHE_TTL`.
|
||||
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{debug, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use tauri::State;
|
||||
|
||||
use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{omni_gen_list_image_models, OmniGenListImageModelsArgs, OmniGenImageModelsResponse};
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use crate::core::commands::generate::models::image::response_types::ListImageModelsResponse;
|
||||
use crate::core::commands::response::shorthand::ResponseOrErrorMessage;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const RETRY_BACKOFF: Duration = Duration::from_millis(250);
|
||||
const CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
struct CacheEntry {
|
||||
response: ListImageModelsResponse,
|
||||
loaded_at: Instant,
|
||||
}
|
||||
|
||||
static CACHE: Lazy<RwLock<Option<CacheEntry>>> = Lazy::new(|| RwLock::new(None));
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_image_models_command(
|
||||
app_env_configs: State<'_, AppEnvConfigs>,
|
||||
) -> ResponseOrErrorMessage<ListImageModelsResponse> {
|
||||
if let Some(cached) = cached_response() {
|
||||
debug!("list_image_models_command: serving cached response");
|
||||
return Ok(cached.into());
|
||||
}
|
||||
|
||||
match fetch_with_retry(&app_env_configs.storyteller_host).await {
|
||||
Ok(api_response) => {
|
||||
let response: ListImageModelsResponse = api_response.into();
|
||||
store_response(response.clone());
|
||||
Ok(response.into())
|
||||
}
|
||||
Err(error_message) => {
|
||||
warn!("list_image_models_command failed after {} attempts: {}", MAX_ATTEMPTS, error_message);
|
||||
if let Some(stale) = any_cached_response() {
|
||||
warn!("list_image_models_command: refresh failed; serving stale cached response");
|
||||
return Ok(stale.into());
|
||||
}
|
||||
Err(error_message.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_response() -> Option<ListImageModelsResponse> {
|
||||
let guard = CACHE.read().ok()?;
|
||||
let entry = guard.as_ref()?;
|
||||
if entry.loaded_at.elapsed() < CACHE_TTL {
|
||||
Some(entry.response.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached response regardless of age. Used as a fallback so that a failed
|
||||
/// refresh still returns the last-known-good data instead of an error.
|
||||
fn any_cached_response() -> Option<ListImageModelsResponse> {
|
||||
let guard = CACHE.read().ok()?;
|
||||
guard.as_ref().map(|entry| entry.response.clone())
|
||||
}
|
||||
|
||||
fn store_response(response: ListImageModelsResponse) {
|
||||
if let Ok(mut guard) = CACHE.write() {
|
||||
*guard = Some(CacheEntry { response, loaded_at: Instant::now() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the models list, retrying up to `MAX_ATTEMPTS` times on failure.
|
||||
async fn fetch_with_retry(api_host: &ApiHost) -> Result<OmniGenImageModelsResponse, String> {
|
||||
let mut last_error = "unknown error".to_string();
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match omni_gen_list_image_models(OmniGenListImageModelsArgs { api_host, maybe_creds: None, provider: None }).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(err) => {
|
||||
last_error = err.to_string();
|
||||
warn!("list_image_models attempt {}/{} failed: {}", attempt, MAX_ATTEMPTS, last_error);
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
tokio::time::sleep(RETRY_BACKOFF).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod list_image_models_command;
|
||||
pub mod response_types;
|
||||
@@ -1,524 +0,0 @@
|
||||
//! Command-specific response types for `list_image_models_command`.
|
||||
//!
|
||||
//! These are duplicated (never `artcraft_client` types), and every enum has an
|
||||
//! `Unknown(String)` catch-all so newer server variants are preserved instead of
|
||||
//! breaking an older desktop build. `From` impls map the API-client response over.
|
||||
|
||||
use serde_derive::Serialize;
|
||||
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_generation_provider::ApiClientGenerationProvider;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_model_creator::ApiClientModelCreator;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_image_model::ApiClientImageModel;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_aspect_ratio::ApiClientAspectRatio;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_resolution::ApiClientResolution;
|
||||
use artcraft_client::datatypes::common_enums::generation::api_client_quality::ApiClientQuality;
|
||||
use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{
|
||||
OmniGenImageModelDetails, OmniGenImageModelProviderDetails, OmniGenImageProviderModelDetails, OmniGenImageModelsResponse,
|
||||
};
|
||||
|
||||
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
|
||||
|
||||
impl SerializeMarker for ListImageModelsResponse {}
|
||||
|
||||
// ============================ Enums ============================
|
||||
|
||||
/// Command-specific copy of `ApiClientGenerationProvider` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsProvider {
|
||||
Artcraft,
|
||||
Fal,
|
||||
Grok,
|
||||
Midjourney,
|
||||
Sora,
|
||||
WorldLabs,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientGenerationProvider> for ListImageModelsProvider {
|
||||
fn from(value: ApiClientGenerationProvider) -> Self {
|
||||
match value {
|
||||
ApiClientGenerationProvider::Artcraft => Self::Artcraft,
|
||||
ApiClientGenerationProvider::Fal => Self::Fal,
|
||||
ApiClientGenerationProvider::Grok => Self::Grok,
|
||||
ApiClientGenerationProvider::Midjourney => Self::Midjourney,
|
||||
ApiClientGenerationProvider::Sora => Self::Sora,
|
||||
ApiClientGenerationProvider::WorldLabs => Self::WorldLabs,
|
||||
ApiClientGenerationProvider::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsProvider> for String {
|
||||
fn from(value: ListImageModelsProvider) -> Self {
|
||||
match value {
|
||||
ListImageModelsProvider::Artcraft => "artcraft".to_string(),
|
||||
ListImageModelsProvider::Fal => "fal".to_string(),
|
||||
ListImageModelsProvider::Grok => "grok".to_string(),
|
||||
ListImageModelsProvider::Midjourney => "midjourney".to_string(),
|
||||
ListImageModelsProvider::Sora => "sora".to_string(),
|
||||
ListImageModelsProvider::WorldLabs => "world_labs".to_string(),
|
||||
ListImageModelsProvider::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientModelCreator` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsModelCreator {
|
||||
Alibaba,
|
||||
ArtCraft,
|
||||
BlackForestLabs,
|
||||
Bytedance,
|
||||
Fal,
|
||||
Google,
|
||||
Grok,
|
||||
Hailuo,
|
||||
Higgsfield,
|
||||
Kling,
|
||||
Krea,
|
||||
Midjourney,
|
||||
OpenAi,
|
||||
OpenArt,
|
||||
Recraft,
|
||||
Replicate,
|
||||
Runway,
|
||||
Stability,
|
||||
Tencent,
|
||||
TensorArt,
|
||||
Vidu,
|
||||
WorldLabs,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientModelCreator> for ListImageModelsModelCreator {
|
||||
fn from(value: ApiClientModelCreator) -> Self {
|
||||
match value {
|
||||
ApiClientModelCreator::Alibaba => Self::Alibaba,
|
||||
ApiClientModelCreator::ArtCraft => Self::ArtCraft,
|
||||
ApiClientModelCreator::BlackForestLabs => Self::BlackForestLabs,
|
||||
ApiClientModelCreator::Bytedance => Self::Bytedance,
|
||||
ApiClientModelCreator::Fal => Self::Fal,
|
||||
ApiClientModelCreator::Google => Self::Google,
|
||||
ApiClientModelCreator::Grok => Self::Grok,
|
||||
ApiClientModelCreator::Hailuo => Self::Hailuo,
|
||||
ApiClientModelCreator::Higgsfield => Self::Higgsfield,
|
||||
ApiClientModelCreator::Kling => Self::Kling,
|
||||
ApiClientModelCreator::Krea => Self::Krea,
|
||||
ApiClientModelCreator::Midjourney => Self::Midjourney,
|
||||
ApiClientModelCreator::OpenAi => Self::OpenAi,
|
||||
ApiClientModelCreator::OpenArt => Self::OpenArt,
|
||||
ApiClientModelCreator::Recraft => Self::Recraft,
|
||||
ApiClientModelCreator::Replicate => Self::Replicate,
|
||||
ApiClientModelCreator::Runway => Self::Runway,
|
||||
ApiClientModelCreator::Stability => Self::Stability,
|
||||
ApiClientModelCreator::Tencent => Self::Tencent,
|
||||
ApiClientModelCreator::TensorArt => Self::TensorArt,
|
||||
ApiClientModelCreator::Vidu => Self::Vidu,
|
||||
ApiClientModelCreator::WorldLabs => Self::WorldLabs,
|
||||
ApiClientModelCreator::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsModelCreator> for String {
|
||||
fn from(value: ListImageModelsModelCreator) -> Self {
|
||||
match value {
|
||||
ListImageModelsModelCreator::Alibaba => "alibaba".to_string(),
|
||||
ListImageModelsModelCreator::ArtCraft => "artcraft".to_string(),
|
||||
ListImageModelsModelCreator::BlackForestLabs => "black_forest_labs".to_string(),
|
||||
ListImageModelsModelCreator::Bytedance => "bytedance".to_string(),
|
||||
ListImageModelsModelCreator::Fal => "fal".to_string(),
|
||||
ListImageModelsModelCreator::Google => "google".to_string(),
|
||||
ListImageModelsModelCreator::Grok => "grok".to_string(),
|
||||
ListImageModelsModelCreator::Hailuo => "hailuo".to_string(),
|
||||
ListImageModelsModelCreator::Higgsfield => "higgsfield".to_string(),
|
||||
ListImageModelsModelCreator::Kling => "kling".to_string(),
|
||||
ListImageModelsModelCreator::Krea => "krea".to_string(),
|
||||
ListImageModelsModelCreator::Midjourney => "midjourney".to_string(),
|
||||
ListImageModelsModelCreator::OpenAi => "open_ai".to_string(),
|
||||
ListImageModelsModelCreator::OpenArt => "open_art".to_string(),
|
||||
ListImageModelsModelCreator::Recraft => "recraft".to_string(),
|
||||
ListImageModelsModelCreator::Replicate => "replicate".to_string(),
|
||||
ListImageModelsModelCreator::Runway => "runway".to_string(),
|
||||
ListImageModelsModelCreator::Stability => "stability".to_string(),
|
||||
ListImageModelsModelCreator::Tencent => "tencent".to_string(),
|
||||
ListImageModelsModelCreator::TensorArt => "tensor_art".to_string(),
|
||||
ListImageModelsModelCreator::Vidu => "vidu".to_string(),
|
||||
ListImageModelsModelCreator::WorldLabs => "world_labs".to_string(),
|
||||
ListImageModelsModelCreator::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientImageModel` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsImageModel {
|
||||
Flux1Dev,
|
||||
Flux1Schnell,
|
||||
FluxPro11,
|
||||
FluxPro11Ultra,
|
||||
GptImage1,
|
||||
GptImage1p5,
|
||||
GptImage2,
|
||||
GrokImagineImage,
|
||||
GrokImagineImageQuality,
|
||||
Midjourney7,
|
||||
Midjourney7Niji,
|
||||
Midjourney8,
|
||||
NanoBanana,
|
||||
NanoBanana2,
|
||||
NanoBananaPro,
|
||||
Seedream4,
|
||||
Seedream4p5,
|
||||
Seedream5Lite,
|
||||
QwenEdit2511Angles,
|
||||
Flux2LoraAngles,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientImageModel> for ListImageModelsImageModel {
|
||||
fn from(value: ApiClientImageModel) -> Self {
|
||||
match value {
|
||||
ApiClientImageModel::Flux1Dev => Self::Flux1Dev,
|
||||
ApiClientImageModel::Flux1Schnell => Self::Flux1Schnell,
|
||||
ApiClientImageModel::FluxPro11 => Self::FluxPro11,
|
||||
ApiClientImageModel::FluxPro11Ultra => Self::FluxPro11Ultra,
|
||||
ApiClientImageModel::GptImage1 => Self::GptImage1,
|
||||
ApiClientImageModel::GptImage1p5 => Self::GptImage1p5,
|
||||
ApiClientImageModel::GptImage2 => Self::GptImage2,
|
||||
ApiClientImageModel::GrokImagineImage => Self::GrokImagineImage,
|
||||
ApiClientImageModel::GrokImagineImageQuality => Self::GrokImagineImageQuality,
|
||||
ApiClientImageModel::Midjourney7 => Self::Midjourney7,
|
||||
ApiClientImageModel::Midjourney7Niji => Self::Midjourney7Niji,
|
||||
ApiClientImageModel::Midjourney8 => Self::Midjourney8,
|
||||
ApiClientImageModel::NanoBanana => Self::NanoBanana,
|
||||
ApiClientImageModel::NanoBanana2 => Self::NanoBanana2,
|
||||
ApiClientImageModel::NanoBananaPro => Self::NanoBananaPro,
|
||||
ApiClientImageModel::Seedream4 => Self::Seedream4,
|
||||
ApiClientImageModel::Seedream4p5 => Self::Seedream4p5,
|
||||
ApiClientImageModel::Seedream5Lite => Self::Seedream5Lite,
|
||||
ApiClientImageModel::QwenEdit2511Angles => Self::QwenEdit2511Angles,
|
||||
ApiClientImageModel::Flux2LoraAngles => Self::Flux2LoraAngles,
|
||||
ApiClientImageModel::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsImageModel> for String {
|
||||
fn from(value: ListImageModelsImageModel) -> Self {
|
||||
match value {
|
||||
ListImageModelsImageModel::Flux1Dev => "flux_1_dev".to_string(),
|
||||
ListImageModelsImageModel::Flux1Schnell => "flux_1_schnell".to_string(),
|
||||
ListImageModelsImageModel::FluxPro11 => "flux_pro_1p1".to_string(),
|
||||
ListImageModelsImageModel::FluxPro11Ultra => "flux_pro_1p1_ultra".to_string(),
|
||||
ListImageModelsImageModel::GptImage1 => "gpt_image_1".to_string(),
|
||||
ListImageModelsImageModel::GptImage1p5 => "gpt_image_1p5".to_string(),
|
||||
ListImageModelsImageModel::GptImage2 => "gpt_image_2".to_string(),
|
||||
ListImageModelsImageModel::GrokImagineImage => "grok_imagine_image".to_string(),
|
||||
ListImageModelsImageModel::GrokImagineImageQuality => "grok_imagine_image_q".to_string(),
|
||||
ListImageModelsImageModel::Midjourney7 => "midjourney_7".to_string(),
|
||||
ListImageModelsImageModel::Midjourney7Niji => "midjourney_7_niji".to_string(),
|
||||
ListImageModelsImageModel::Midjourney8 => "midjourney_8".to_string(),
|
||||
ListImageModelsImageModel::NanoBanana => "nano_banana".to_string(),
|
||||
ListImageModelsImageModel::NanoBanana2 => "nano_banana_2".to_string(),
|
||||
ListImageModelsImageModel::NanoBananaPro => "nano_banana_pro".to_string(),
|
||||
ListImageModelsImageModel::Seedream4 => "seedream_4".to_string(),
|
||||
ListImageModelsImageModel::Seedream4p5 => "seedream_4p5".to_string(),
|
||||
ListImageModelsImageModel::Seedream5Lite => "seedream_5_lite".to_string(),
|
||||
ListImageModelsImageModel::QwenEdit2511Angles => "qwen_edit_2511_angles".to_string(),
|
||||
ListImageModelsImageModel::Flux2LoraAngles => "flux_2_lora_angles".to_string(),
|
||||
ListImageModelsImageModel::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientAspectRatio` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsAspectRatio {
|
||||
Auto,
|
||||
Square,
|
||||
WideThreeByTwo,
|
||||
WideFourByThree,
|
||||
WideFiveByFour,
|
||||
WideSixteenByNine,
|
||||
WideTwentyOneByNine,
|
||||
TallTwoByThree,
|
||||
TallThreeByFour,
|
||||
TallFourByFive,
|
||||
TallNineBySixteen,
|
||||
TallNineByTwentyOne,
|
||||
Wide,
|
||||
Tall,
|
||||
Auto2k,
|
||||
Auto3k,
|
||||
Auto4k,
|
||||
SquareHd,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientAspectRatio> for ListImageModelsAspectRatio {
|
||||
fn from(value: ApiClientAspectRatio) -> Self {
|
||||
match value {
|
||||
ApiClientAspectRatio::Auto => Self::Auto,
|
||||
ApiClientAspectRatio::Square => Self::Square,
|
||||
ApiClientAspectRatio::WideThreeByTwo => Self::WideThreeByTwo,
|
||||
ApiClientAspectRatio::WideFourByThree => Self::WideFourByThree,
|
||||
ApiClientAspectRatio::WideFiveByFour => Self::WideFiveByFour,
|
||||
ApiClientAspectRatio::WideSixteenByNine => Self::WideSixteenByNine,
|
||||
ApiClientAspectRatio::WideTwentyOneByNine => Self::WideTwentyOneByNine,
|
||||
ApiClientAspectRatio::TallTwoByThree => Self::TallTwoByThree,
|
||||
ApiClientAspectRatio::TallThreeByFour => Self::TallThreeByFour,
|
||||
ApiClientAspectRatio::TallFourByFive => Self::TallFourByFive,
|
||||
ApiClientAspectRatio::TallNineBySixteen => Self::TallNineBySixteen,
|
||||
ApiClientAspectRatio::TallNineByTwentyOne => Self::TallNineByTwentyOne,
|
||||
ApiClientAspectRatio::Wide => Self::Wide,
|
||||
ApiClientAspectRatio::Tall => Self::Tall,
|
||||
ApiClientAspectRatio::Auto2k => Self::Auto2k,
|
||||
ApiClientAspectRatio::Auto3k => Self::Auto3k,
|
||||
ApiClientAspectRatio::Auto4k => Self::Auto4k,
|
||||
ApiClientAspectRatio::SquareHd => Self::SquareHd,
|
||||
ApiClientAspectRatio::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsAspectRatio> for String {
|
||||
fn from(value: ListImageModelsAspectRatio) -> Self {
|
||||
match value {
|
||||
ListImageModelsAspectRatio::Auto => "auto".to_string(),
|
||||
ListImageModelsAspectRatio::Square => "square".to_string(),
|
||||
ListImageModelsAspectRatio::WideThreeByTwo => "wide_three_by_two".to_string(),
|
||||
ListImageModelsAspectRatio::WideFourByThree => "wide_four_by_three".to_string(),
|
||||
ListImageModelsAspectRatio::WideFiveByFour => "wide_five_by_four".to_string(),
|
||||
ListImageModelsAspectRatio::WideSixteenByNine => "wide_sixteen_by_nine".to_string(),
|
||||
ListImageModelsAspectRatio::WideTwentyOneByNine => "wide_twenty_one_by_nine".to_string(),
|
||||
ListImageModelsAspectRatio::TallTwoByThree => "tall_two_by_three".to_string(),
|
||||
ListImageModelsAspectRatio::TallThreeByFour => "tall_three_by_four".to_string(),
|
||||
ListImageModelsAspectRatio::TallFourByFive => "tall_four_by_five".to_string(),
|
||||
ListImageModelsAspectRatio::TallNineBySixteen => "tall_nine_by_sixteen".to_string(),
|
||||
ListImageModelsAspectRatio::TallNineByTwentyOne => "tall_nine_by_twenty_one".to_string(),
|
||||
ListImageModelsAspectRatio::Wide => "wide".to_string(),
|
||||
ListImageModelsAspectRatio::Tall => "tall".to_string(),
|
||||
ListImageModelsAspectRatio::Auto2k => "auto_2k".to_string(),
|
||||
ListImageModelsAspectRatio::Auto3k => "auto_3k".to_string(),
|
||||
ListImageModelsAspectRatio::Auto4k => "auto_4k".to_string(),
|
||||
ListImageModelsAspectRatio::SquareHd => "square_hd".to_string(),
|
||||
ListImageModelsAspectRatio::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientResolution` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsResolution {
|
||||
OneK,
|
||||
TwoK,
|
||||
ThreeK,
|
||||
FourK,
|
||||
HalfK,
|
||||
FourEightyP,
|
||||
SevenTwentyP,
|
||||
TenEightyP,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientResolution> for ListImageModelsResolution {
|
||||
fn from(value: ApiClientResolution) -> Self {
|
||||
match value {
|
||||
ApiClientResolution::OneK => Self::OneK,
|
||||
ApiClientResolution::TwoK => Self::TwoK,
|
||||
ApiClientResolution::ThreeK => Self::ThreeK,
|
||||
ApiClientResolution::FourK => Self::FourK,
|
||||
ApiClientResolution::HalfK => Self::HalfK,
|
||||
ApiClientResolution::FourEightyP => Self::FourEightyP,
|
||||
ApiClientResolution::SevenTwentyP => Self::SevenTwentyP,
|
||||
ApiClientResolution::TenEightyP => Self::TenEightyP,
|
||||
ApiClientResolution::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsResolution> for String {
|
||||
fn from(value: ListImageModelsResolution) -> Self {
|
||||
match value {
|
||||
ListImageModelsResolution::OneK => "one_k".to_string(),
|
||||
ListImageModelsResolution::TwoK => "two_k".to_string(),
|
||||
ListImageModelsResolution::ThreeK => "three_k".to_string(),
|
||||
ListImageModelsResolution::FourK => "four_k".to_string(),
|
||||
ListImageModelsResolution::HalfK => "half_k".to_string(),
|
||||
ListImageModelsResolution::FourEightyP => "four_eighty_p".to_string(),
|
||||
ListImageModelsResolution::SevenTwentyP => "seven_twenty_p".to_string(),
|
||||
ListImageModelsResolution::TenEightyP => "ten_eighty_p".to_string(),
|
||||
ListImageModelsResolution::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-specific copy of `ApiClientQuality` with an `Unknown` catch-all so newer
|
||||
/// server variants are preserved rather than dropped.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ListImageModelsQuality {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<ApiClientQuality> for ListImageModelsQuality {
|
||||
fn from(value: ApiClientQuality) -> Self {
|
||||
match value {
|
||||
ApiClientQuality::High => Self::High,
|
||||
ApiClientQuality::Medium => Self::Medium,
|
||||
ApiClientQuality::Low => Self::Low,
|
||||
ApiClientQuality::Unknown(other) => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ListImageModelsQuality> for String {
|
||||
fn from(value: ListImageModelsQuality) -> Self {
|
||||
match value {
|
||||
ListImageModelsQuality::High => "high".to_string(),
|
||||
ListImageModelsQuality::Medium => "medium".to_string(),
|
||||
ListImageModelsQuality::Low => "low".to_string(),
|
||||
ListImageModelsQuality::Unknown(other) => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Structs ============================
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsResponse {
|
||||
pub success: bool,
|
||||
pub models: Vec<ListImageModelsModelDetails>,
|
||||
pub providers: Vec<ListImageModelsProviderDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageModelsResponse> for ListImageModelsResponse {
|
||||
fn from(v: OmniGenImageModelsResponse) -> Self {
|
||||
Self {
|
||||
success: v.success,
|
||||
models: v.models.into_iter().map(Into::into).collect(),
|
||||
providers: v.providers.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsProviderDetails {
|
||||
pub provider: ListImageModelsProvider,
|
||||
pub models: Vec<ListImageModelsProviderModelDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageModelProviderDetails> for ListImageModelsProviderDetails {
|
||||
fn from(v: OmniGenImageModelProviderDetails) -> Self {
|
||||
Self {
|
||||
provider: v.provider.into(),
|
||||
models: v.models.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsProviderModelDetails {
|
||||
pub model: ListImageModelsImageModel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overrides: Option<ListImageModelsModelDetails>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageProviderModelDetails> for ListImageModelsProviderModelDetails {
|
||||
fn from(v: OmniGenImageProviderModelDetails) -> Self {
|
||||
Self {
|
||||
model: v.model.into(),
|
||||
overrides: v.overrides.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ListImageModelsModelDetails {
|
||||
pub model: ListImageModelsImageModel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_creator: Option<ListImageModelsModelCreator>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_prompt_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_prompt_max_length: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_text_prompt_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub negative_text_prompt_max_length: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_refs_supported: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_refs_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub has_fixed_editing_aspect_ratio: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_options: Option<Vec<ListImageModelsAspectRatio>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_default: Option<ListImageModelsAspectRatio>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio_default_when_editing: Option<ListImageModelsAspectRatio>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution_options: Option<Vec<ListImageModelsResolution>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution_default: Option<ListImageModelsResolution>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality_options: Option<Vec<ListImageModelsQuality>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_quality: Option<ListImageModelsQuality>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_min: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_max: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_options: Option<Vec<u16>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub batch_size_default: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<OmniGenImageModelDetails> for ListImageModelsModelDetails {
|
||||
fn from(v: OmniGenImageModelDetails) -> Self {
|
||||
Self {
|
||||
model: v.model.into(),
|
||||
model_creator: v.model_creator.map(Into::into),
|
||||
full_name: v.full_name,
|
||||
text_prompt_supported: v.text_prompt_supported,
|
||||
text_prompt_max_length: v.text_prompt_max_length,
|
||||
negative_text_prompt_supported: v.negative_text_prompt_supported,
|
||||
negative_text_prompt_max_length: v.negative_text_prompt_max_length,
|
||||
image_refs_supported: v.image_refs_supported,
|
||||
image_refs_max: v.image_refs_max,
|
||||
has_fixed_editing_aspect_ratio: v.has_fixed_editing_aspect_ratio,
|
||||
aspect_ratio_options: v.aspect_ratio_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
aspect_ratio_default: v.aspect_ratio_default.map(Into::into),
|
||||
aspect_ratio_default_when_editing: v.aspect_ratio_default_when_editing.map(Into::into),
|
||||
resolution_options: v.resolution_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
resolution_default: v.resolution_default.map(Into::into),
|
||||
quality_options: v.quality_options.map(|items| items.into_iter().map(Into::into).collect()),
|
||||
default_quality: v.default_quality.map(Into::into),
|
||||
batch_size_min: v.batch_size_min,
|
||||
batch_size_max: v.batch_size_max,
|
||||
batch_size_options: v.batch_size_options,
|
||||
batch_size_default: v.batch_size_default,
|
||||
is_disabled: v.is_disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user