diff --git a/.sqlx-mysql/query-008bb29097e0580b36adb309f72a593506d21bf6ae941a7ebcc52365c84a8b1b.json b/.sqlx-mysql/query-008bb29097e0580b36adb309f72a593506d21bf6ae941a7ebcc52365c84a8b1b.json new file mode 100644 index 00000000..f237a98b --- /dev/null +++ b/.sqlx-mysql/query-008bb29097e0580b36adb309f72a593506d21bf6ae941a7ebcc52365c84a8b1b.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `content_revisions` (`id`, `content_type`, `record_id`, `revision_number`, `snapshot`, `created_by`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "008bb29097e0580b36adb309f72a593506d21bf6ae941a7ebcc52365c84a8b1b" +} diff --git a/.sqlx-mysql/query-00f6bad9968219ec438c35106c208e4c4d2c1c45f2bbffa52e54ac4a34f96519.json b/.sqlx-mysql/query-00f6bad9968219ec438c35106c208e4c4d2c1c45f2bbffa52e54ac4a34f96519.json deleted file mode 100644 index bc19ae95..00000000 --- a/.sqlx-mysql/query-00f6bad9968219ec438c35106c208e4c4d2c1c45f2bbffa52e54ac4a34f96519.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO reusable_blocks (id, name, block_type, content, description, created_by, updated_by, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "00f6bad9968219ec438c35106c208e4c4d2c1c45f2bbffa52e54ac4a34f96519" -} diff --git a/.sqlx-mysql/query-0218fef502ae157fa71b54e879f03778459a13f196e241481b5ff2289c9861a1.json b/.sqlx-mysql/query-0218fef502ae157fa71b54e879f03778459a13f196e241481b5ff2289c9861a1.json deleted file mode 100644 index f4810c62..00000000 --- a/.sqlx-mysql/query-0218fef502ae157fa71b54e879f03778459a13f196e241481b5ff2289c9861a1.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO wallet_outbox (id, user_id, currency, amount, entry_type, tx_type, transaction_no, reference_type, reference_id, metadata, status, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "0218fef502ae157fa71b54e879f03778459a13f196e241481b5ff2289c9861a1" -} diff --git a/.sqlx-mysql/query-05732b48dbf7a440862fda2158c07b94ab60a33891316c437e88f8b2ac3aef5d.json b/.sqlx-mysql/query-05732b48dbf7a440862fda2158c07b94ab60a33891316c437e88f8b2ac3aef5d.json deleted file mode 100644 index 89bf1db4..00000000 --- a/.sqlx-mysql/query-05732b48dbf7a440862fda2158c07b94ab60a33891316c437e88f8b2ac3aef5d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO pages (id, title, slug, content, blocks, meta_title, meta_description, og_image, template, parent_id, sort_order, status, created_by, updated_by, cover_image, published_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", - "describe": { - "columns": [], - "parameters": { - "Right": 18 - }, - "nullable": [] - }, - "hash": "05732b48dbf7a440862fda2158c07b94ab60a33891316c437e88f8b2ac3aef5d" -} diff --git a/.sqlx-mysql/query-066ed6c05f37cd6ba35a78cd06fe4013328983283222f4e4e14c6b4fb688efc1.json b/.sqlx-mysql/query-066ed6c05f37cd6ba35a78cd06fe4013328983283222f4e4e14c6b4fb688efc1.json new file mode 100644 index 00000000..11db24ca --- /dev/null +++ b/.sqlx-mysql/query-066ed6c05f37cd6ba35a78cd06fe4013328983283222f4e4e14c6b4fb688efc1.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `api_tokens` (`id`, `user_id`, `name`, `description`, `token_hash`, `token_encrypted`, `scopes`, `expires_at`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "066ed6c05f37cd6ba35a78cd06fe4013328983283222f4e4e14c6b4fb688efc1" +} diff --git a/.sqlx-mysql/query-07eb8fa962146348f40945d43a4093233a6e3c1908a423b8758ff6c4e6e65697.json b/.sqlx-mysql/query-07eb8fa962146348f40945d43a4093233a6e3c1908a423b8758ff6c4e6e65697.json new file mode 100644 index 00000000..b9b1e62a --- /dev/null +++ b/.sqlx-mysql/query-07eb8fa962146348f40945d43a4093233a6e3c1908a423b8758ff6c4e6e65697.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `reusable_blocks` (`id`, `name`, `block_type`, `content`, `description`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "07eb8fa962146348f40945d43a4093233a6e3c1908a423b8758ff6c4e6e65697" +} diff --git a/.sqlx-mysql/query-0a8c9719e5108f9a4d9c6962fa3cf4e3145668e2182abf297a4d1d4721d9954a.json b/.sqlx-mysql/query-0a8c9719e5108f9a4d9c6962fa3cf4e3145668e2182abf297a4d1d4721d9954a.json new file mode 100644 index 00000000..254b8202 --- /dev/null +++ b/.sqlx-mysql/query-0a8c9719e5108f9a4d9c6962fa3cf4e3145668e2182abf297a4d1d4721d9954a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_transactions` (`id`, `payment_order_id`, `order_id`, `user_id`, `tx_type`, `amount`, `currency`, `provider_tx_id`, `status`, `raw_payload`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "0a8c9719e5108f9a4d9c6962fa3cf4e3145668e2182abf297a4d1d4721d9954a" +} diff --git a/.sqlx-mysql/query-0ae2f2a67fea764cb51fabd168f8909ddbe2e6dd1739005d7dfea832decf99f6.json b/.sqlx-mysql/query-0ae2f2a67fea764cb51fabd168f8909ddbe2e6dd1739005d7dfea832decf99f6.json deleted file mode 100644 index 97cf071c..00000000 --- a/.sqlx-mysql/query-0ae2f2a67fea764cb51fabd168f8909ddbe2e6dd1739005d7dfea832decf99f6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO wallet_transactions (id, wallet_id, user_id, entry_type, amount, balance_after, tx_type, currency, transaction_no, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "0ae2f2a67fea764cb51fabd168f8909ddbe2e6dd1739005d7dfea832decf99f6" -} diff --git a/.sqlx-mysql/query-0aebf25ff8e3470f655e0284c35b11a5584109a3180da235347841b3250de296.json b/.sqlx-mysql/query-0aebf25ff8e3470f655e0284c35b11a5584109a3180da235347841b3250de296.json deleted file mode 100644 index 578ff867..00000000 --- a/.sqlx-mysql/query-0aebf25ff8e3470f655e0284c35b11a5584109a3180da235347841b3250de296.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_categories (id, name, slug, description, cover_image, meta_title, meta_description, og_title, og_description, og_image, parent_id, sort_order, created_by, updated_by, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", - "describe": { - "columns": [], - "parameters": { - "Right": 16 - }, - "nullable": [] - }, - "hash": "0aebf25ff8e3470f655e0284c35b11a5584109a3180da235347841b3250de296" -} diff --git a/.sqlx-mysql/query-0b3a113ccbdcbb6b490fceab7076cd98d8eee83dbef642e02b040880cfdbe527.json b/.sqlx-mysql/query-0b3a113ccbdcbb6b490fceab7076cd98d8eee83dbef642e02b040880cfdbe527.json new file mode 100644 index 00000000..6d63702e --- /dev/null +++ b/.sqlx-mysql/query-0b3a113ccbdcbb6b490fceab7076cd98d8eee83dbef642e02b040880cfdbe527.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `wallet_outbox` (`id`, `user_id`, `currency`, `amount`, `entry_type`, `tx_type`, `transaction_no`, `reference_type`, `reference_id`, `metadata`, `status`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "0b3a113ccbdcbb6b490fceab7076cd98d8eee83dbef642e02b040880cfdbe527" +} diff --git a/.sqlx-mysql/query-0c3b817f309e557593582a60eec269870b461d808953304a141a5bf92a6e329f.json b/.sqlx-mysql/query-0c3b817f309e557593582a60eec269870b461d808953304a141a5bf92a6e329f.json new file mode 100644 index 00000000..0e18ce92 --- /dev/null +++ b/.sqlx-mysql/query-0c3b817f309e557593582a60eec269870b461d808953304a141a5bf92a6e329f.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_logs` (`id`, `request_id`, `user_id`, `token_id`, `source`, `channel_id`, `key_index`, `model_name`, `is_stream`, `prompt_tokens`, `completion_tokens`, `cache_read_tokens`, `cache_write_tokens`, `quota`, `detail`, `elapsed_ms`, `status_code`, `error_message`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 19 + }, + "nullable": [] + }, + "hash": "0c3b817f309e557593582a60eec269870b461d808953304a141a5bf92a6e329f" +} diff --git a/.sqlx-mysql/query-0d9cf697203f12beace57613de3119777c9bd76a94b971019c5f09a1b639b211.json b/.sqlx-mysql/query-0d9cf697203f12beace57613de3119777c9bd76a94b971019c5f09a1b639b211.json deleted file mode 100644 index 6cc9ecf9..00000000 --- a/.sqlx-mysql/query-0d9cf697203f12beace57613de3119777c9bd76a94b971019c5f09a1b639b211.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_refunds (id, payment_order_id, order_id, user_id, amount, currency, reason, provider_refund_id, status, payment_tx_id, metadata, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "0d9cf697203f12beace57613de3119777c9bd76a94b971019c5f09a1b639b211" -} diff --git a/.sqlx-mysql/query-0db51743f765df9c62f1ffcf664231f2705ed5bfd3acef58c99c734555a0a1ec.json b/.sqlx-mysql/query-0db51743f765df9c62f1ffcf664231f2705ed5bfd3acef58c99c734555a0a1ec.json new file mode 100644 index 00000000..428737dc --- /dev/null +++ b/.sqlx-mysql/query-0db51743f765df9c62f1ffcf664231f2705ed5bfd3acef58c99c734555a0a1ec.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_channels` (`id`, `provider`, `name`, `is_live`, `credentials`, `webhook_secret`, `settings`, `is_active`, `sort_order`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "0db51743f765df9c62f1ffcf664231f2705ed5bfd3acef58c99c734555a0a1ec" +} diff --git a/.sqlx-mysql/query-0e48b64b6b62dc90a733a3243f53664435d0f23191806ad6cc168bb160e622ca.json b/.sqlx-mysql/query-0e48b64b6b62dc90a733a3243f53664435d0f23191806ad6cc168bb160e622ca.json new file mode 100644 index 00000000..8d5cdfb7 --- /dev/null +++ b/.sqlx-mysql/query-0e48b64b6b62dc90a733a3243f53664435d0f23191806ad6cc168bb160e622ca.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `coupons` (`id`, `code`, `title`, `coupon_type`, `value`, `min_order`, `max_uses`, `used_count`, `max_uses_per_user`, `starts_at`, `expires_at`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "0e48b64b6b62dc90a733a3243f53664435d0f23191806ad6cc168bb160e622ca" +} diff --git a/.sqlx-mysql/query-0e65ccc5bd9750ba79ffcfac3103b57f1129b514c920afe834270739a6630a15.json b/.sqlx-mysql/query-0e65ccc5bd9750ba79ffcfac3103b57f1129b514c920afe834270739a6630a15.json new file mode 100644 index 00000000..815e4dcd --- /dev/null +++ b/.sqlx-mysql/query-0e65ccc5bd9750ba79ffcfac3103b57f1129b514c920afe834270739a6630a15.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_comments` (`id`, `product_id`, `order_id`, `user_id`, `rating`, `title`, `content`, `images`, `status`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "0e65ccc5bd9750ba79ffcfac3103b57f1129b514c920afe834270739a6630a15" +} diff --git a/.sqlx-mysql/query-0f7df02f4e4e2d810a2fec17a14bf23024dde72e97b064426ad3072f00ba0133.json b/.sqlx-mysql/query-0f7df02f4e4e2d810a2fec17a14bf23024dde72e97b064426ad3072f00ba0133.json deleted file mode 100644 index a11d1190..00000000 --- a/.sqlx-mysql/query-0f7df02f4e4e2d810a2fec17a14bf23024dde72e97b064426ad3072f00ba0133.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO user_credentials (id, user_id, auth_type, identifier, credential_data, verified, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - "describe": { - "columns": [], - "parameters": { - "Right": 8 - }, - "nullable": [] - }, - "hash": "0f7df02f4e4e2d810a2fec17a14bf23024dde72e97b064426ad3072f00ba0133" -} diff --git a/.sqlx-mysql/query-1255531a58a24e68aae5c6248964623a05d2f1ca4516fe11e30244c78e015bab.json b/.sqlx-mysql/query-1255531a58a24e68aae5c6248964623a05d2f1ca4516fe11e30244c78e015bab.json new file mode 100644 index 00000000..bebd8bda --- /dev/null +++ b/.sqlx-mysql/query-1255531a58a24e68aae5c6248964623a05d2f1ca4516fe11e30244c78e015bab.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `shipping_templates` (`id`, `name`, `type`, `first_unit`, `first_price`, `additional_unit`, `additional_price`, `free_shipping_amount`, `regions`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "1255531a58a24e68aae5c6248964623a05d2f1ca4516fe11e30244c78e015bab" +} diff --git a/.sqlx-mysql/query-132d2146e8d655e950a090d5851bd3159d0a672386d6e83a9f7058a868f776e0.json b/.sqlx-mysql/query-132d2146e8d655e950a090d5851bd3159d0a672386d6e83a9f7058a868f776e0.json new file mode 100644 index 00000000..aa74fcec --- /dev/null +++ b/.sqlx-mysql/query-132d2146e8d655e950a090d5851bd3159d0a672386d6e83a9f7058a868f776e0.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_agents` (`id`, `user_id`, `name`, `system_prompt`, `provider`, `model`, `temperature`, `max_iterations`, `tools`, `memory_enabled`, `params`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "132d2146e8d655e950a090d5851bd3159d0a672386d6e83a9f7058a868f776e0" +} diff --git a/.sqlx-mysql/query-13d326ca41c1ccd5dde5fc46c9014eeb10050ec463344d7559b4584488b6d6cd.json b/.sqlx-mysql/query-13d326ca41c1ccd5dde5fc46c9014eeb10050ec463344d7559b4584488b6d6cd.json new file mode 100644 index 00000000..e9807300 --- /dev/null +++ b/.sqlx-mysql/query-13d326ca41c1ccd5dde5fc46c9014eeb10050ec463344d7559b4584488b6d6cd.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_refunds` (`id`, `payment_order_id`, `order_id`, `user_id`, `amount`, `currency`, `reason`, `provider_refund_id`, `status`, `payment_tx_id`, `metadata`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "13d326ca41c1ccd5dde5fc46c9014eeb10050ec463344d7559b4584488b6d6cd" +} diff --git a/.sqlx-mysql/query-14997d4d48ef091021b470549b98044e905a2505e00a31cef49ca71f3e9de374.json b/.sqlx-mysql/query-14997d4d48ef091021b470549b98044e905a2505e00a31cef49ca71f3e9de374.json deleted file mode 100644 index 956d6810..00000000 --- a/.sqlx-mysql/query-14997d4d48ef091021b470549b98044e905a2505e00a31cef49ca71f3e9de374.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO posts (id, title, slug, content, excerpt, cover_image, image_ids, status, created_by, updated_by, category_id, meta_title, meta_description, og_title, og_description, og_image, canonical_url, published_at, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", - "describe": { - "columns": [], - "parameters": { - "Right": 21 - }, - "nullable": [] - }, - "hash": "14997d4d48ef091021b470549b98044e905a2505e00a31cef49ca71f3e9de374" -} diff --git a/.sqlx-mysql/query-1518fec9a4ca05ecc6d88bdb621d00f89c9a9d529b0c63c90b17d1efadb45eaf.json b/.sqlx-mysql/query-1518fec9a4ca05ecc6d88bdb621d00f89c9a9d529b0c63c90b17d1efadb45eaf.json deleted file mode 100644 index 7ac36d8a..00000000 --- a/.sqlx-mysql/query-1518fec9a4ca05ecc6d88bdb621d00f89c9a9d529b0c63c90b17d1efadb45eaf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO products (id, category_id, title, description, cover_url, product_type, fulfillment_type, delivery_hook, weight, price, currency, attributes, sort_order, slug, content, image_ids, original_price, specs, unit, min_purchase, max_purchase, virtual_sales, meta_title, meta_description, og_title, og_description, og_image, stock, cost_price, sale_price, has_variants, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33)", - "describe": { - "columns": [], - "parameters": { - "Right": 33 - }, - "nullable": [] - }, - "hash": "1518fec9a4ca05ecc6d88bdb621d00f89c9a9d529b0c63c90b17d1efadb45eaf" -} diff --git a/.sqlx-mysql/query-15958b27a50ae6952a8aea322388509321e89efebc0390b61066077c81f02ed4.json b/.sqlx-mysql/query-15958b27a50ae6952a8aea322388509321e89efebc0390b61066077c81f02ed4.json new file mode 100644 index 00000000..6d49a676 --- /dev/null +++ b/.sqlx-mysql/query-15958b27a50ae6952a8aea322388509321e89efebc0390b61066077c81f02ed4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `comments` (`id`, `post_id`, `created_by`, `updated_by`, `nickname`, `email`, `content`, `parent_id`, `created_at`, `updated_at`, `status`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "15958b27a50ae6952a8aea322388509321e89efebc0390b61066077c81f02ed4" +} diff --git a/.sqlx-mysql/query-15e22b4f55900a9c31cec2835e042d0f665e1715de020857b8b24f36ae707ea6.json b/.sqlx-mysql/query-15e22b4f55900a9c31cec2835e042d0f665e1715de020857b8b24f36ae707ea6.json new file mode 100644 index 00000000..93cf7cb7 --- /dev/null +++ b/.sqlx-mysql/query-15e22b4f55900a9c31cec2835e042d0f665e1715de020857b8b24f36ae707ea6.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_sessions` (`id`, `agent_id`, `user_id`, `title`, `status`, `last_seq`, `created_at`, `updated_at`, `last_active_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "15e22b4f55900a9c31cec2835e042d0f665e1715de020857b8b24f36ae707ea6" +} diff --git a/.sqlx-mysql/query-15fbab69b6772afc2ab0721f46d20dda595d81ea29240b77610733828819b56b.json b/.sqlx-mysql/query-15fbab69b6772afc2ab0721f46d20dda595d81ea29240b77610733828819b56b.json new file mode 100644 index 00000000..3d7c765a --- /dev/null +++ b/.sqlx-mysql/query-15fbab69b6772afc2ab0721f46d20dda595d81ea29240b77610733828819b56b.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `user_roles` (`id`, `tenant_id`, `user_id`, `role_id`, `created_at`) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "15fbab69b6772afc2ab0721f46d20dda595d81ea29240b77610733828819b56b" +} diff --git a/.sqlx-mysql/query-176ad453da277a6ee8eaa0c4ed91097b03eb5e84a5a7395a3eebf364f6131939.json b/.sqlx-mysql/query-176ad453da277a6ee8eaa0c4ed91097b03eb5e84a5a7395a3eebf364f6131939.json new file mode 100644 index 00000000..52d912ce --- /dev/null +++ b/.sqlx-mysql/query-176ad453da277a6ee8eaa0c4ed91097b03eb5e84a5a7395a3eebf364f6131939.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `workflow_instances` (`id`, `definition_id`, `status`, `context`, `triggered_by`, `started_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "176ad453da277a6ee8eaa0c4ed91097b03eb5e84a5a7395a3eebf364f6131939" +} diff --git a/.sqlx-mysql/query-17aba75c79608431ab8a629e18ea88b38c9d80e5b1f99fab1446645774844a9b.json b/.sqlx-mysql/query-17aba75c79608431ab8a629e18ea88b38c9d80e5b1f99fab1446645774844a9b.json deleted file mode 100644 index d3a23865..00000000 --- a/.sqlx-mysql/query-17aba75c79608431ab8a629e18ea88b38c9d80e5b1f99fab1446645774844a9b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO cron_schedules (id, label, job_type, payload, cron_expr, enabled, next_run_at, plugin_id, exec_kind, handler_id, params, script_lang, script_source, script_entry, use_shell, timeout_secs, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", - "describe": { - "columns": [], - "parameters": { - "Right": 18 - }, - "nullable": [] - }, - "hash": "17aba75c79608431ab8a629e18ea88b38c9d80e5b1f99fab1446645774844a9b" -} diff --git a/.sqlx-mysql/query-19fa9bb6894b2ebb1ef45fdd3de33e90982004c99a5b5c44e7b149632cf57ffc.json b/.sqlx-mysql/query-19fa9bb6894b2ebb1ef45fdd3de33e90982004c99a5b5c44e7b149632cf57ffc.json new file mode 100644 index 00000000..50ef7fae --- /dev/null +++ b/.sqlx-mysql/query-19fa9bb6894b2ebb1ef45fdd3de33e90982004c99a5b5c44e7b149632cf57ffc.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `password_reset_tokens` (`id`, `user_id`, `token`, `expires_at`, `created_at`) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "19fa9bb6894b2ebb1ef45fdd3de33e90982004c99a5b5c44e7b149632cf57ffc" +} diff --git a/.sqlx-mysql/query-1a804863d5148834f4290bb2de855d13787e0d15c492dffe8b9e3449217e4290.json b/.sqlx-mysql/query-1a804863d5148834f4290bb2de855d13787e0d15c492dffe8b9e3449217e4290.json deleted file mode 100644 index 487b1839..00000000 --- a/.sqlx-mysql/query-1a804863d5148834f4290bb2de855d13787e0d15c492dffe8b9e3449217e4290.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO order_items (id, order_id, product_id, variant_id, title, description, sku, unit_price, quantity, subtotal, tax_amount, cover_url, attributes, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "1a804863d5148834f4290bb2de855d13787e0d15c492dffe8b9e3449217e4290" -} diff --git a/.sqlx-mysql/query-1a9005cdc9874fd6dba6a57c4d24aa177c2695cbbcf3ed819e957225faca3984.json b/.sqlx-mysql/query-1a9005cdc9874fd6dba6a57c4d24aa177c2695cbbcf3ed819e957225faca3984.json new file mode 100644 index 00000000..8385239d --- /dev/null +++ b/.sqlx-mysql/query-1a9005cdc9874fd6dba6a57c4d24aa177c2695cbbcf3ed819e957225faca3984.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `itg_channels` (`id`, `app_id`, `channel_key`, `provider`, `display_name`, `mode`, `transport`, `framing`, `codec`, `endpoint`, `verify_kind`, `verify_config`, `credentials`, `mapping`, `normalizer_plugin`, `pull_semantics`, `pull_config`, `stream_config`, `ack_kind`, `redelivery_max`, `backpressure`, `target_type`, `route_extra`, `status`, `enabled`, `version`, `shadow`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 30 + }, + "nullable": [] + }, + "hash": "1a9005cdc9874fd6dba6a57c4d24aa177c2695cbbcf3ed819e957225faca3984" +} diff --git a/.sqlx-mysql/query-1b2cfe34aca82f161903a831ca60aae8866a69faee34ec74ee6bdbc24a731ec1.json b/.sqlx-mysql/query-1b2cfe34aca82f161903a831ca60aae8866a69faee34ec74ee6bdbc24a731ec1.json new file mode 100644 index 00000000..d20d403a --- /dev/null +++ b/.sqlx-mysql/query-1b2cfe34aca82f161903a831ca60aae8866a69faee34ec74ee6bdbc24a731ec1.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `audit_log` (`id`, `actor_id`, `actor_role`, `action`, `subject`, `subject_id`, `detail`, `ip_address`, `user_agent`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "1b2cfe34aca82f161903a831ca60aae8866a69faee34ec74ee6bdbc24a731ec1" +} diff --git a/.sqlx-mysql/query-1d5778e10a7793a37d585de2067f020060034da4444407dcd4e0ca15243e3bce.json b/.sqlx-mysql/query-1d5778e10a7793a37d585de2067f020060034da4444407dcd4e0ca15243e3bce.json new file mode 100644 index 00000000..dade662a --- /dev/null +++ b/.sqlx-mysql/query-1d5778e10a7793a37d585de2067f020060034da4444407dcd4e0ca15243e3bce.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_channels` (`id`, `provider`, `name`, `is_live`, `credentials`, `webhook_secret`, `settings`, `is_active`, `sort_order`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "1d5778e10a7793a37d585de2067f020060034da4444407dcd4e0ca15243e3bce" +} diff --git a/.sqlx-mysql/query-1d8d42ecbc7ada2d7a901cf7c35e3a8d6d003a2c534a84e4df1a354b3d65b5b6.json b/.sqlx-mysql/query-1d8d42ecbc7ada2d7a901cf7c35e3a8d6d003a2c534a84e4df1a354b3d65b5b6.json new file mode 100644 index 00000000..e76e6a3c --- /dev/null +++ b/.sqlx-mysql/query-1d8d42ecbc7ada2d7a901cf7c35e3a8d6d003a2c534a84e4df1a354b3d65b5b6.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `cron_schedules` (`id`, `label`, `job_type`, `payload`, `cron_expr`, `enabled`, `next_run_at`, `plugin_id`, `exec_kind`, `handler_id`, `params`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "1d8d42ecbc7ada2d7a901cf7c35e3a8d6d003a2c534a84e4df1a354b3d65b5b6" +} diff --git a/.sqlx-mysql/query-1e1792414b6404d99a5be854ebf90ab2ffd0ec63c7a5bbc1f53473fd94b4ebdb.json b/.sqlx-mysql/query-1e1792414b6404d99a5be854ebf90ab2ffd0ec63c7a5bbc1f53473fd94b4ebdb.json new file mode 100644 index 00000000..484987d4 --- /dev/null +++ b/.sqlx-mysql/query-1e1792414b6404d99a5be854ebf90ab2ffd0ec63c7a5bbc1f53473fd94b4ebdb.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `order_items` (`id`, `order_id`, `product_id`, `variant_id`, `title`, `description`, `sku`, `unit_price`, `quantity`, `subtotal`, `tax_amount`, `cover_url`, `attributes`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "1e1792414b6404d99a5be854ebf90ab2ffd0ec63c7a5bbc1f53473fd94b4ebdb" +} diff --git a/.sqlx-mysql/query-1fd8ce093c7d811d3f3256cda164189ffc85872376e00e68d4b5ada685b35f2d.json b/.sqlx-mysql/query-1fd8ce093c7d811d3f3256cda164189ffc85872376e00e68d4b5ada685b35f2d.json deleted file mode 100644 index cf05cca7..00000000 --- a/.sqlx-mysql/query-1fd8ce093c7d811d3f3256cda164189ffc85872376e00e68d4b5ada685b35f2d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO posts (id, title, slug, content, excerpt, cover_image, image_ids, status, created_by, updated_by, category_id, meta_title, meta_description, og_title, og_description, og_image, canonical_url, published_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", - "describe": { - "columns": [], - "parameters": { - "Right": 20 - }, - "nullable": [] - }, - "hash": "1fd8ce093c7d811d3f3256cda164189ffc85872376e00e68d4b5ada685b35f2d" -} diff --git a/.sqlx-mysql/query-206ce47841a358a42e389045e779bedb28d04862fa971c0e968d125985820056.json b/.sqlx-mysql/query-206ce47841a358a42e389045e779bedb28d04862fa971c0e968d125985820056.json new file mode 100644 index 00000000..a8d220f1 --- /dev/null +++ b/.sqlx-mysql/query-206ce47841a358a42e389045e779bedb28d04862fa971c0e968d125985820056.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_refunds` (`id`, `payment_order_id`, `order_id`, `user_id`, `amount`, `currency`, `reason`, `provider_refund_id`, `status`, `metadata`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "206ce47841a358a42e389045e779bedb28d04862fa971c0e968d125985820056" +} diff --git a/.sqlx-mysql/query-23cd50a91dba636533568f42d48eb00ba9c37e8a0bf98abc87065956d6933911.json b/.sqlx-mysql/query-23cd50a91dba636533568f42d48eb00ba9c37e8a0bf98abc87065956d6933911.json deleted file mode 100644 index a0cb20e4..00000000 --- a/.sqlx-mysql/query-23cd50a91dba636533568f42d48eb00ba9c37e8a0bf98abc87065956d6933911.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO coupons (id, code, title, coupon_type, value, min_order, max_uses, used_count, max_uses_per_user, starts_at, expires_at, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "23cd50a91dba636533568f42d48eb00ba9c37e8a0bf98abc87065956d6933911" -} diff --git a/.sqlx-mysql/query-270d45e64d65be7d4c8f07e714756d5d7b72f51966e380c48f7f00067b519bfd.json b/.sqlx-mysql/query-270d45e64d65be7d4c8f07e714756d5d7b72f51966e380c48f7f00067b519bfd.json new file mode 100644 index 00000000..c3ea8876 --- /dev/null +++ b/.sqlx-mysql/query-270d45e64d65be7d4c8f07e714756d5d7b72f51966e380c48f7f00067b519bfd.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `wallet_transactions` (`id`, `wallet_id`, `user_id`, `entry_type`, `amount`, `balance_after`, `tx_type`, `currency`, `transaction_no`, `related_tx_id`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "270d45e64d65be7d4c8f07e714756d5d7b72f51966e380c48f7f00067b519bfd" +} diff --git a/.sqlx-mysql/query-2883df84746cf3aa0baaaa4e9fa38a6c8a72d52158a52558f84a33267fefb520.json b/.sqlx-mysql/query-2883df84746cf3aa0baaaa4e9fa38a6c8a72d52158a52558f84a33267fefb520.json new file mode 100644 index 00000000..2c569305 --- /dev/null +++ b/.sqlx-mysql/query-2883df84746cf3aa0baaaa4e9fa38a6c8a72d52158a52558f84a33267fefb520.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_variants` (`id`, `product_id`, `sku`, `title`, `price`, `original_price`, `stock`, `attributes`, `image_url`, `weight`, `sort_order`, `is_active`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 15 + }, + "nullable": [] + }, + "hash": "2883df84746cf3aa0baaaa4e9fa38a6c8a72d52158a52558f84a33267fefb520" +} diff --git a/.sqlx-mysql/query-28a6772da782aeab44de5b1ab38dcfdaf596ff1b172b29d02008f38ec680befc.json b/.sqlx-mysql/query-28a6772da782aeab44de5b1ab38dcfdaf596ff1b172b29d02008f38ec680befc.json deleted file mode 100644 index 9862ef30..00000000 --- a/.sqlx-mysql/query-28a6772da782aeab44de5b1ab38dcfdaf596ff1b172b29d02008f38ec680befc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO audit_log (id, actor_id, actor_role, action, subject, subject_id, detail, ip_address, user_agent, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "28a6772da782aeab44de5b1ab38dcfdaf596ff1b172b29d02008f38ec680befc" -} diff --git a/.sqlx-mysql/query-2919b493c38a046808ace3dfc0bb9f3b75ed64146de924cd5410374b6e560ffb.json b/.sqlx-mysql/query-2919b493c38a046808ace3dfc0bb9f3b75ed64146de924cd5410374b6e560ffb.json deleted file mode 100644 index 29e0ea7e..00000000 --- a/.sqlx-mysql/query-2919b493c38a046808ace3dfc0bb9f3b75ed64146de924cd5410374b6e560ffb.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO pages (id, title, slug, content, blocks, meta_title, meta_description, og_image, template, parent_id, sort_order, status, created_by, updated_by, cover_image, published_at, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", - "describe": { - "columns": [], - "parameters": { - "Right": 19 - }, - "nullable": [] - }, - "hash": "2919b493c38a046808ace3dfc0bb9f3b75ed64146de924cd5410374b6e560ffb" -} diff --git a/.sqlx-mysql/query-295429ee6c6e6a6a53e2942286825522d8a6d4ae3cd90d12955d9083b4532ca9.json b/.sqlx-mysql/query-295429ee6c6e6a6a53e2942286825522d8a6d4ae3cd90d12955d9083b4532ca9.json deleted file mode 100644 index 51c54b1f..00000000 --- a/.sqlx-mysql/query-295429ee6c6e6a6a53e2942286825522d8a6d4ae3cd90d12955d9083b4532ca9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO oauth_states (id, provider, code_verifier, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "295429ee6c6e6a6a53e2942286825522d8a6d4ae3cd90d12955d9083b4532ca9" -} diff --git a/.sqlx-mysql/query-29e8b7f5d9a760ee921d31c77b086b6aa0e431cdfaae85af9995572517b82dbc.json b/.sqlx-mysql/query-29e8b7f5d9a760ee921d31c77b086b6aa0e431cdfaae85af9995572517b82dbc.json deleted file mode 100644 index 6a1cf8fd..00000000 --- a/.sqlx-mysql/query-29e8b7f5d9a760ee921d31c77b086b6aa0e431cdfaae85af9995572517b82dbc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO users (id, username, created_at, updated_at, status, registered_via, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "29e8b7f5d9a760ee921d31c77b086b6aa0e431cdfaae85af9995572517b82dbc" -} diff --git a/.sqlx-mysql/query-29ec564f478f99e306a62409f06d01d638cce4e957266188c7828dc83b9c1c95.json b/.sqlx-mysql/query-29ec564f478f99e306a62409f06d01d638cce4e957266188c7828dc83b9c1c95.json deleted file mode 100644 index 82e9fb51..00000000 --- a/.sqlx-mysql/query-29ec564f478f99e306a62409f06d01d638cce4e957266188c7828dc83b9c1c95.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO cart_items (id, user_id, product_id, variant_id, quantity, attributes, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - "describe": { - "columns": [], - "parameters": { - "Right": 8 - }, - "nullable": [] - }, - "hash": "29ec564f478f99e306a62409f06d01d638cce4e957266188c7828dc83b9c1c95" -} diff --git a/.sqlx-mysql/query-2a1ef290bb3a3af0c99e8af64b3264962d840c1045697cd50979cbf1b7851944.json b/.sqlx-mysql/query-2a1ef290bb3a3af0c99e8af64b3264962d840c1045697cd50979cbf1b7851944.json deleted file mode 100644 index 7d714a81..00000000 --- a/.sqlx-mysql/query-2a1ef290bb3a3af0c99e8af64b3264962d840c1045697cd50979cbf1b7851944.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO user_roles (id, tenant_id, user_id, role_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "2a1ef290bb3a3af0c99e8af64b3264962d840c1045697cd50979cbf1b7851944" -} diff --git a/.sqlx-mysql/query-2a99974dd884c439c750b857f54631d370f2c20ca7e76b43f226a4dd09c130f0.json b/.sqlx-mysql/query-2a99974dd884c439c750b857f54631d370f2c20ca7e76b43f226a4dd09c130f0.json deleted file mode 100644 index 593724d3..00000000 --- a/.sqlx-mysql/query-2a99974dd884c439c750b857f54631d370f2c20ca7e76b43f226a4dd09c130f0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO order_items (id, order_id, product_id, variant_id, title, description, sku, unit_price, quantity, subtotal, tax_amount, cover_url, attributes, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", - "describe": { - "columns": [], - "parameters": { - "Right": 15 - }, - "nullable": [] - }, - "hash": "2a99974dd884c439c750b857f54631d370f2c20ca7e76b43f226a4dd09c130f0" -} diff --git a/.sqlx-mysql/query-2bbb8b0e04cd87d4a0a2ed3b5ab180097e010c09ea6ee4293000006b34287ae4.json b/.sqlx-mysql/query-2bbb8b0e04cd87d4a0a2ed3b5ab180097e010c09ea6ee4293000006b34287ae4.json deleted file mode 100644 index 41c00c27..00000000 --- a/.sqlx-mysql/query-2bbb8b0e04cd87d4a0a2ed3b5ab180097e010c09ea6ee4293000006b34287ae4.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO webhook_subscriptions (id, name, url, secret, events, enabled, description, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "2bbb8b0e04cd87d4a0a2ed3b5ab180097e010c09ea6ee4293000006b34287ae4" -} diff --git a/.sqlx-mysql/query-2ef602ad84401b9348d21b1bd132aead3e7758b38e3f38f455e235315b2a3587.json b/.sqlx-mysql/query-2ef602ad84401b9348d21b1bd132aead3e7758b38e3f38f455e235315b2a3587.json new file mode 100644 index 00000000..0d25a5ff --- /dev/null +++ b/.sqlx-mysql/query-2ef602ad84401b9348d21b1bd132aead3e7758b38e3f38f455e235315b2a3587.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `wallet_outbox` (`id`, `user_id`, `currency`, `amount`, `entry_type`, `tx_type`, `transaction_no`, `reference_type`, `reference_id`, `metadata`, `status`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "2ef602ad84401b9348d21b1bd132aead3e7758b38e3f38f455e235315b2a3587" +} diff --git a/.sqlx-mysql/query-30b3e633f137345161d1dcd19ba11833ecb46fef745ab93bef2ddd269711b0d0.json b/.sqlx-mysql/query-30b3e633f137345161d1dcd19ba11833ecb46fef745ab93bef2ddd269711b0d0.json new file mode 100644 index 00000000..aa226731 --- /dev/null +++ b/.sqlx-mysql/query-30b3e633f137345161d1dcd19ba11833ecb46fef745ab93bef2ddd269711b0d0.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `posts` (`id`, `title`, `slug`, `content`, `excerpt`, `cover_image`, `image_ids`, `status`, `created_by`, `updated_by`, `category_id`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `canonical_url`, `published_at`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 21 + }, + "nullable": [] + }, + "hash": "30b3e633f137345161d1dcd19ba11833ecb46fef745ab93bef2ddd269711b0d0" +} diff --git a/.sqlx-mysql/query-32d82daee7679614978ebc87fe801ccd40d62d3a581f9da0389cc6d0cf055e6e.json b/.sqlx-mysql/query-32d82daee7679614978ebc87fe801ccd40d62d3a581f9da0389cc6d0cf055e6e.json new file mode 100644 index 00000000..b17273b5 --- /dev/null +++ b/.sqlx-mysql/query-32d82daee7679614978ebc87fe801ccd40d62d3a581f9da0389cc6d0cf055e6e.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `categories` (`id`, `name`, `slug`, `description`, `parent_id`, `sort_order`, `cover_image`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `created_by`, `updated_by`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 17 + }, + "nullable": [] + }, + "hash": "32d82daee7679614978ebc87fe801ccd40d62d3a581f9da0389cc6d0cf055e6e" +} diff --git a/.sqlx-mysql/query-35cc4ceeefd6872400468763f6f70d25cf03b0adef1a1d93b2ef58a9a3a3512d.json b/.sqlx-mysql/query-35cc4ceeefd6872400468763f6f70d25cf03b0adef1a1d93b2ef58a9a3a3512d.json new file mode 100644 index 00000000..22694448 --- /dev/null +++ b/.sqlx-mysql/query-35cc4ceeefd6872400468763f6f70d25cf03b0adef1a1d93b2ef58a9a3a3512d.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `tenants` (`id`, `name`, `domain`, `config`, `status`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "35cc4ceeefd6872400468763f6f70d25cf03b0adef1a1d93b2ef58a9a3a3512d" +} diff --git a/.sqlx-mysql/query-35f2e11086a92c38a586001bde805faf78f0d4e0451dfe529c2b2378003dc0eb.json b/.sqlx-mysql/query-35f2e11086a92c38a586001bde805faf78f0d4e0451dfe529c2b2378003dc0eb.json new file mode 100644 index 00000000..eefeb5ef --- /dev/null +++ b/.sqlx-mysql/query-35f2e11086a92c38a586001bde805faf78f0d4e0451dfe529c2b2378003dc0eb.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `media` (`id`, `user_id`, `filename`, `filepath`, `mimetype`, `size`, `width`, `height`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "35f2e11086a92c38a586001bde805faf78f0d4e0451dfe529c2b2378003dc0eb" +} diff --git a/.sqlx-mysql/query-362c1442fcd9f53d7b9cc6abc3084d294d3f07b880d377ef4b820b3c928d12bd.json b/.sqlx-mysql/query-362c1442fcd9f53d7b9cc6abc3084d294d3f07b880d377ef4b820b3c928d12bd.json deleted file mode 100644 index 19a54cc3..00000000 --- a/.sqlx-mysql/query-362c1442fcd9f53d7b9cc6abc3084d294d3f07b880d377ef4b820b3c928d12bd.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO wallet_transactions (id, tenant_id, wallet_id, user_id, entry_type, amount, balance_after, tx_type, currency, transaction_no, related_tx_id, reference_type, reference_id, counterparty_wallet_id, metadata, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", - "describe": { - "columns": [], - "parameters": { - "Right": 16 - }, - "nullable": [] - }, - "hash": "362c1442fcd9f53d7b9cc6abc3084d294d3f07b880d377ef4b820b3c928d12bd" -} diff --git a/.sqlx-mysql/query-362c9160f86d76e01b904d1b26f406f27c0df8dde13f225c3ae3dbe3718d40c0.json b/.sqlx-mysql/query-362c9160f86d76e01b904d1b26f406f27c0df8dde13f225c3ae3dbe3718d40c0.json deleted file mode 100644 index 190b1ec5..00000000 --- a/.sqlx-mysql/query-362c9160f86d76e01b904d1b26f406f27c0df8dde13f225c3ae3dbe3718d40c0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_favorites (id, user_id, product_id, created_at) VALUES (?1, ?2, ?3, ?4)", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "362c9160f86d76e01b904d1b26f406f27c0df8dde13f225c3ae3dbe3718d40c0" -} diff --git a/.sqlx-mysql/query-372b32862ce6720c26a7a6441544b443db7304465f7d6df20fbcef39e0b537b2.json b/.sqlx-mysql/query-372b32862ce6720c26a7a6441544b443db7304465f7d6df20fbcef39e0b537b2.json new file mode 100644 index 00000000..629cd7d9 --- /dev/null +++ b/.sqlx-mysql/query-372b32862ce6720c26a7a6441544b443db7304465f7d6df20fbcef39e0b537b2.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `app_ct_refs` (`id`, `app_id`, `ct_table`, `schema_toml`, `version`, `created_at`) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "372b32862ce6720c26a7a6441544b443db7304465f7d6df20fbcef39e0b537b2" +} diff --git a/.sqlx-mysql/query-39831cd20e7b2b0fa78f71f172851a6ed7183c8cfc6d3ed9fb4657461e1578cb.json b/.sqlx-mysql/query-39831cd20e7b2b0fa78f71f172851a6ed7183c8cfc6d3ed9fb4657461e1578cb.json new file mode 100644 index 00000000..94296417 --- /dev/null +++ b/.sqlx-mysql/query-39831cd20e7b2b0fa78f71f172851a6ed7183c8cfc6d3ed9fb4657461e1578cb.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_logs` (`id`, `request_id`, `user_id`, `token_id`, `source`, `channel_id`, `key_index`, `model_name`, `is_stream`, `prompt_tokens`, `completion_tokens`, `cache_read_tokens`, `cache_write_tokens`, `quota`, `detail`, `elapsed_ms`, `status_code`, `error_message`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "39831cd20e7b2b0fa78f71f172851a6ed7183c8cfc6d3ed9fb4657461e1578cb" +} diff --git a/.sqlx-mysql/query-3af958b1cafebd4bdbe84a11a198072866c31eb22e3dd09ef9ddc17a0c94ad80.json b/.sqlx-mysql/query-3af958b1cafebd4bdbe84a11a198072866c31eb22e3dd09ef9ddc17a0c94ad80.json deleted file mode 100644 index ce23ecad..00000000 --- a/.sqlx-mysql/query-3af958b1cafebd4bdbe84a11a198072866c31eb22e3dd09ef9ddc17a0c94ad80.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO cron_execution_log (id, schedule_id, job_type, label, status, started_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "3af958b1cafebd4bdbe84a11a198072866c31eb22e3dd09ef9ddc17a0c94ad80" -} diff --git a/.sqlx-mysql/query-3bf4c96a5e4898c8d0019c61b83afc03c20d6bd0855487ab3d9703264b248b69.json b/.sqlx-mysql/query-3bf4c96a5e4898c8d0019c61b83afc03c20d6bd0855487ab3d9703264b248b69.json new file mode 100644 index 00000000..5749b035 --- /dev/null +++ b/.sqlx-mysql/query-3bf4c96a5e4898c8d0019c61b83afc03c20d6bd0855487ab3d9703264b248b69.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `shipping_templates` (`id`, `name`, `type`, `first_unit`, `first_price`, `additional_unit`, `additional_price`, `free_shipping_amount`, `regions`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "3bf4c96a5e4898c8d0019c61b83afc03c20d6bd0855487ab3d9703264b248b69" +} diff --git a/.sqlx-mysql/query-3ce6fa217db9587dcc99b63bdbddff9e0e25bbfefff115efee864f63de858f52.json b/.sqlx-mysql/query-3ce6fa217db9587dcc99b63bdbddff9e0e25bbfefff115efee864f63de858f52.json new file mode 100644 index 00000000..6b6e077f --- /dev/null +++ b/.sqlx-mysql/query-3ce6fa217db9587dcc99b63bdbddff9e0e25bbfefff115efee864f63de858f52.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `taggings` (`id`, `tag_id`, `taggable_type`, `taggable_id`, `tenant_id`) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "3ce6fa217db9587dcc99b63bdbddff9e0e25bbfefff115efee864f63de858f52" +} diff --git a/.sqlx-mysql/query-3cf93109b422c1801e281f4e7b8ee91c2d6ffaf2ba3d80e400a62deaa69d99b5.json b/.sqlx-mysql/query-3cf93109b422c1801e281f4e7b8ee91c2d6ffaf2ba3d80e400a62deaa69d99b5.json new file mode 100644 index 00000000..b9360119 --- /dev/null +++ b/.sqlx-mysql/query-3cf93109b422c1801e281f4e7b8ee91c2d6ffaf2ba3d80e400a62deaa69d99b5.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_wiki_pages` (`id`, `kb_id`, `title`, `slug`, `status`, `content`, `summary`, `linked_page_ids`, `created_by`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "3cf93109b422c1801e281f4e7b8ee91c2d6ffaf2ba3d80e400a62deaa69d99b5" +} diff --git a/.sqlx-mysql/query-3df96e1f45779f46b28661df222d54b38636a3ca5450ada6e86c1349b90d135b.json b/.sqlx-mysql/query-3df96e1f45779f46b28661df222d54b38636a3ca5450ada6e86c1349b90d135b.json deleted file mode 100644 index b12405b6..00000000 --- a/.sqlx-mysql/query-3df96e1f45779f46b28661df222d54b38636a3ca5450ada6e86c1349b90d135b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO api_tokens (id, user_id, name, description, token_hash, token_encrypted, scopes, expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "3df96e1f45779f46b28661df222d54b38636a3ca5450ada6e86c1349b90d135b" -} diff --git a/.sqlx-mysql/query-3fe0c48c7da4c7de37e24a85c8d50b8ca34e14cc61193b276981fe8b21509f3b.json b/.sqlx-mysql/query-3fe0c48c7da4c7de37e24a85c8d50b8ca34e14cc61193b276981fe8b21509f3b.json new file mode 100644 index 00000000..833c0fa0 --- /dev/null +++ b/.sqlx-mysql/query-3fe0c48c7da4c7de37e24a85c8d50b8ca34e14cc61193b276981fe8b21509f3b.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `workflow_definitions` (`id`, `name`, `description`, `steps`, `initial_step`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "3fe0c48c7da4c7de37e24a85c8d50b8ca34e14cc61193b276981fe8b21509f3b" +} diff --git a/.sqlx-mysql/query-40de0b65a8c93dd7b7fdcb0f2b86f2597e80d7a9a8172ec75e35e12f2884899a.json b/.sqlx-mysql/query-40de0b65a8c93dd7b7fdcb0f2b86f2597e80d7a9a8172ec75e35e12f2884899a.json new file mode 100644 index 00000000..8852083e --- /dev/null +++ b/.sqlx-mysql/query-40de0b65a8c93dd7b7fdcb0f2b86f2597e80d7a9a8172ec75e35e12f2884899a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `webhook_deliveries` (`id`, `webhook_id`, `event`, `status`, `status_code`, `error`, `duration_ms`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "40de0b65a8c93dd7b7fdcb0f2b86f2597e80d7a9a8172ec75e35e12f2884899a" +} diff --git a/.sqlx-mysql/query-4102ff057176472775f55126c4d91a598f454c0ff87f7d68c363682e0ba16265.json b/.sqlx-mysql/query-4102ff057176472775f55126c4d91a598f454c0ff87f7d68c363682e0ba16265.json new file mode 100644 index 00000000..7826e7c3 --- /dev/null +++ b/.sqlx-mysql/query-4102ff057176472775f55126c4d91a598f454c0ff87f7d68c363682e0ba16265.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `orders` (`id`, `user_id`, `order_no`, `subtotal`, `discount_amount`, `shipping_amount`, `total_amount`, `currency`, `buyer_name`, `buyer_phone`, `buyer_email`, `shipping_address`, `remark`, `tax_amount`, `coupon_id`, `shipping_address_id`, `billing_address_id`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "4102ff057176472775f55126c4d91a598f454c0ff87f7d68c363682e0ba16265" +} diff --git a/.sqlx-mysql/query-410b34079c9d2e443b87e0b5b0c7f15513e3372366172ef010cf08e498994c8b.json b/.sqlx-mysql/query-410b34079c9d2e443b87e0b5b0c7f15513e3372366172ef010cf08e498994c8b.json deleted file mode 100644 index d549b910..00000000 --- a/.sqlx-mysql/query-410b34079c9d2e443b87e0b5b0c7f15513e3372366172ef010cf08e498994c8b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO permissions (id, role_id, action, subject, fields, conditions, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "410b34079c9d2e443b87e0b5b0c7f15513e3372366172ef010cf08e498994c8b" -} diff --git a/.sqlx-mysql/query-413fd4d171ff85e073fc19aa7d1c55daf2af68798d01c105963f27edd115682e.json b/.sqlx-mysql/query-413fd4d171ff85e073fc19aa7d1c55daf2af68798d01c105963f27edd115682e.json deleted file mode 100644 index 61442e1d..00000000 --- a/.sqlx-mysql/query-413fd4d171ff85e073fc19aa7d1c55daf2af68798d01c105963f27edd115682e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO categories (id, name, slug, description, parent_id, sort_order, cover_image, meta_title, meta_description, og_title, og_description, og_image, created_by, updated_by, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", - "describe": { - "columns": [], - "parameters": { - "Right": 16 - }, - "nullable": [] - }, - "hash": "413fd4d171ff85e073fc19aa7d1c55daf2af68798d01c105963f27edd115682e" -} diff --git a/.sqlx-mysql/query-42c239c023850cd8c0b7be15683e71b209eb382669358ab79762ed23198fbdfb.json b/.sqlx-mysql/query-42c239c023850cd8c0b7be15683e71b209eb382669358ab79762ed23198fbdfb.json deleted file mode 100644 index 5021a00c..00000000 --- a/.sqlx-mysql/query-42c239c023850cd8c0b7be15683e71b209eb382669358ab79762ed23198fbdfb.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO kb_documents (id, kb_id, tenant_id, title, source, storage_key, mime_type, size, created_by, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "42c239c023850cd8c0b7be15683e71b209eb382669358ab79762ed23198fbdfb" -} diff --git a/.sqlx-mysql/query-4384972ed44753b56c9f5d579a78f3dcf46330e987f50ab408b4320b1c821ccf.json b/.sqlx-mysql/query-4384972ed44753b56c9f5d579a78f3dcf46330e987f50ab408b4320b1c821ccf.json new file mode 100644 index 00000000..03177725 --- /dev/null +++ b/.sqlx-mysql/query-4384972ed44753b56c9f5d579a78f3dcf46330e987f50ab408b4320b1c821ccf.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_orders` (`id`, `user_id`, `order_id`, `title`, `amount`, `currency`, `channel_id`, `provider`, `reference_type`, `reference_id`, `return_url`, `idempotency_key`, `client_ip`, `client_language`, `client_country`, `client_user_agent`, `channel_selected_by`, `metadata`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "4384972ed44753b56c9f5d579a78f3dcf46330e987f50ab408b4320b1c821ccf" +} diff --git a/.sqlx-mysql/query-4566bb0f438b62f9fdd252d491e0c345cdac3b393434691d926a7186a77edf7c.json b/.sqlx-mysql/query-4566bb0f438b62f9fdd252d491e0c345cdac3b393434691d926a7186a77edf7c.json deleted file mode 100644 index cfc19cdc..00000000 --- a/.sqlx-mysql/query-4566bb0f438b62f9fdd252d491e0c345cdac3b393434691d926a7186a77edf7c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO itg_channels (id, app_id, channel_key, provider, display_name, mode, transport, framing, codec, endpoint, verify_kind, verify_config, credentials, mapping, normalizer_plugin, pull_semantics, pull_config, stream_config, ack_kind, redelivery_max, backpressure, target_type, route_extra, status, enabled, version, shadow, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30)", - "describe": { - "columns": [], - "parameters": { - "Right": 30 - }, - "nullable": [] - }, - "hash": "4566bb0f438b62f9fdd252d491e0c345cdac3b393434691d926a7186a77edf7c" -} diff --git a/.sqlx-mysql/query-458aaaa26f968320740bbeb0e16683d6b0f6adde8e4703cbf7b8070e787025b4.json b/.sqlx-mysql/query-458aaaa26f968320740bbeb0e16683d6b0f6adde8e4703cbf7b8070e787025b4.json new file mode 100644 index 00000000..a4ec3a44 --- /dev/null +++ b/.sqlx-mysql/query-458aaaa26f968320740bbeb0e16683d6b0f6adde8e4703cbf7b8070e787025b4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_categories` (`id`, `name`, `slug`, `description`, `cover_image`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `parent_id`, `sort_order`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 16 + }, + "nullable": [] + }, + "hash": "458aaaa26f968320740bbeb0e16683d6b0f6adde8e4703cbf7b8070e787025b4" +} diff --git a/.sqlx-mysql/query-467f99fd54110355b20240baf11b04eecbea48c0df4b31e797530285795c4c3b.json b/.sqlx-mysql/query-467f99fd54110355b20240baf11b04eecbea48c0df4b31e797530285795c4c3b.json deleted file mode 100644 index 939fce74..00000000 --- a/.sqlx-mysql/query-467f99fd54110355b20240baf11b04eecbea48c0df4b31e797530285795c4c3b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO apps (id, app_id, version, status, source, source_ref, signature_ok, install_log, last_error, tenant_scope, options, installed_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "467f99fd54110355b20240baf11b04eecbea48c0df4b31e797530285795c4c3b" -} diff --git a/.sqlx-mysql/query-4767b239a00c7fca7918eff54b4df24bce3efba7b59f4b7e5eee97f4bb3cfb09.json b/.sqlx-mysql/query-4767b239a00c7fca7918eff54b4df24bce3efba7b59f4b7e5eee97f4bb3cfb09.json deleted file mode 100644 index 1fe138f0..00000000 --- a/.sqlx-mysql/query-4767b239a00c7fca7918eff54b4df24bce3efba7b59f4b7e5eee97f4bb3cfb09.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO user_device_codes (id, user_id, code, access_token, refresh_token, expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "4767b239a00c7fca7918eff54b4df24bce3efba7b59f4b7e5eee97f4bb3cfb09" -} diff --git a/.sqlx-mysql/query-47fccdef0a8f5bd9c25b2e586dfd8662c21c55e64ac7d86c394eb919071822df.json b/.sqlx-mysql/query-47fccdef0a8f5bd9c25b2e586dfd8662c21c55e64ac7d86c394eb919071822df.json deleted file mode 100644 index 97c086f1..00000000 --- a/.sqlx-mysql/query-47fccdef0a8f5bd9c25b2e586dfd8662c21c55e64ac7d86c394eb919071822df.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO itg_api_clients (id, client_key, display_name, base_url, auth, credentials, rate_limit, ops, enabled, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "47fccdef0a8f5bd9c25b2e586dfd8662c21c55e64ac7d86c394eb919071822df" -} diff --git a/.sqlx-mysql/query-4a3efe17ed3e0fa24aaa21c06471dd240ff7cfcbc2f44cdba6ece1675b5064ed.json b/.sqlx-mysql/query-4a3efe17ed3e0fa24aaa21c06471dd240ff7cfcbc2f44cdba6ece1675b5064ed.json deleted file mode 100644 index 97fb5118..00000000 --- a/.sqlx-mysql/query-4a3efe17ed3e0fa24aaa21c06471dd240ff7cfcbc2f44cdba6ece1675b5064ed.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_categories (id, name, slug, description, cover_image, meta_title, meta_description, og_title, og_description, og_image, parent_id, sort_order, created_by, updated_by, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", - "describe": { - "columns": [], - "parameters": { - "Right": 17 - }, - "nullable": [] - }, - "hash": "4a3efe17ed3e0fa24aaa21c06471dd240ff7cfcbc2f44cdba6ece1675b5064ed" -} diff --git a/.sqlx-mysql/query-4abe269febbc73d0757a6c494fc3960e6f98ad4e0b32b297959f10a51ef7b627.json b/.sqlx-mysql/query-4abe269febbc73d0757a6c494fc3960e6f98ad4e0b32b297959f10a51ef7b627.json new file mode 100644 index 00000000..c6ea7b0a --- /dev/null +++ b/.sqlx-mysql/query-4abe269febbc73d0757a6c494fc3960e6f98ad4e0b32b297959f10a51ef7b627.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_refunds` (`id`, `payment_order_id`, `order_id`, `user_id`, `amount`, `currency`, `reason`, `provider_refund_id`, `status`, `metadata`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "4abe269febbc73d0757a6c494fc3960e6f98ad4e0b32b297959f10a51ef7b627" +} diff --git a/.sqlx-mysql/query-4b1caea19b56520e3e83bcde7e3f5328089e02fb7f0bbd93277c0a5294651c40.json b/.sqlx-mysql/query-4b1caea19b56520e3e83bcde7e3f5328089e02fb7f0bbd93277c0a5294651c40.json deleted file mode 100644 index ab71b5c4..00000000 --- a/.sqlx-mysql/query-4b1caea19b56520e3e83bcde7e3f5328089e02fb7f0bbd93277c0a5294651c40.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO itg_channels (id, app_id, channel_key, provider, display_name, mode, transport, framing, codec, endpoint, verify_kind, verify_config, credentials, mapping, normalizer_plugin, pull_semantics, pull_config, stream_config, ack_kind, redelivery_max, backpressure, target_type, route_extra, status, enabled, version, shadow, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29)", - "describe": { - "columns": [], - "parameters": { - "Right": 29 - }, - "nullable": [] - }, - "hash": "4b1caea19b56520e3e83bcde7e3f5328089e02fb7f0bbd93277c0a5294651c40" -} diff --git a/.sqlx-mysql/query-4b747395c48682c15da802e576e637dfa6d697206896e963abd2281ba910619a.json b/.sqlx-mysql/query-4b747395c48682c15da802e576e637dfa6d697206896e963abd2281ba910619a.json deleted file mode 100644 index ba75dfd5..00000000 --- a/.sqlx-mysql/query-4b747395c48682c15da802e576e637dfa6d697206896e963abd2281ba910619a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_transactions (id, payment_order_id, order_id, user_id, tx_type, amount, currency, provider_tx_id, status, raw_payload, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "4b747395c48682c15da802e576e637dfa6d697206896e963abd2281ba910619a" -} diff --git a/.sqlx-mysql/query-4b984369fe3430c5f7d59d1ffd099d0503dff4f5ee538287dcfc7cf33dc00c37.json b/.sqlx-mysql/query-4b984369fe3430c5f7d59d1ffd099d0503dff4f5ee538287dcfc7cf33dc00c37.json new file mode 100644 index 00000000..cbd34df1 --- /dev/null +++ b/.sqlx-mysql/query-4b984369fe3430c5f7d59d1ffd099d0503dff4f5ee538287dcfc7cf33dc00c37.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_chunks` (`id`, `kb_id`, `doc_id`, `faq_id`, `wiki_page_id`, `kind`, `parent_id`, `seq`, `content`, `breadcrumb`, `byte_start`, `byte_end`, `questions`, `embedding`, `embedding_model`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 16 + }, + "nullable": [] + }, + "hash": "4b984369fe3430c5f7d59d1ffd099d0503dff4f5ee538287dcfc7cf33dc00c37" +} diff --git a/.sqlx-mysql/query-4be72c342832bc472a2b9f4fb3e17e1fe2bcb22ec317f62869f3e037c7167dbb.json b/.sqlx-mysql/query-4be72c342832bc472a2b9f4fb3e17e1fe2bcb22ec317f62869f3e037c7167dbb.json new file mode 100644 index 00000000..f178741c --- /dev/null +++ b/.sqlx-mysql/query-4be72c342832bc472a2b9f4fb3e17e1fe2bcb22ec317f62869f3e037c7167dbb.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `roles` (`id`, `name`, `description`, `is_system`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "4be72c342832bc472a2b9f4fb3e17e1fe2bcb22ec317f62869f3e037c7167dbb" +} diff --git a/.sqlx-mysql/query-4bf6caaf1078e497274de0cc1fc417f1e2505057b7ed3e56f3921191befa2205.json b/.sqlx-mysql/query-4bf6caaf1078e497274de0cc1fc417f1e2505057b7ed3e56f3921191befa2205.json deleted file mode 100644 index d7793754..00000000 --- a/.sqlx-mysql/query-4bf6caaf1078e497274de0cc1fc417f1e2505057b7ed3e56f3921191befa2205.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_variants (id, product_id, sku, title, price, original_price, stock, attributes, image_url, weight, sort_order, is_active, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", - "describe": { - "columns": [], - "parameters": { - "Right": 15 - }, - "nullable": [] - }, - "hash": "4bf6caaf1078e497274de0cc1fc417f1e2505057b7ed3e56f3921191befa2205" -} diff --git a/.sqlx-mysql/query-4c266659ebf5124ad0565d9fe1d32a1f2ede0939b7356425f078ddebe07f166b.json b/.sqlx-mysql/query-4c266659ebf5124ad0565d9fe1d32a1f2ede0939b7356425f078ddebe07f166b.json new file mode 100644 index 00000000..abe6d979 --- /dev/null +++ b/.sqlx-mysql/query-4c266659ebf5124ad0565d9fe1d32a1f2ede0939b7356425f078ddebe07f166b.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_memories` (`id`, `agent_id`, `user_id`, `session_id`, `mem_key`, `content`, `category`, `importance`, `pinned`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "4c266659ebf5124ad0565d9fe1d32a1f2ede0939b7356425f078ddebe07f166b" +} diff --git a/.sqlx-mysql/query-4d173dc00623dbb15d982fa725d1082a3459e408b4d3e9d7c1f4dd6408897010.json b/.sqlx-mysql/query-4d173dc00623dbb15d982fa725d1082a3459e408b4d3e9d7c1f4dd6408897010.json deleted file mode 100644 index 6a69a470..00000000 --- a/.sqlx-mysql/query-4d173dc00623dbb15d982fa725d1082a3459e408b4d3e9d7c1f4dd6408897010.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO media (id, user_id, filename, filepath, mimetype, size, width, height, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "4d173dc00623dbb15d982fa725d1082a3459e408b4d3e9d7c1f4dd6408897010" -} diff --git a/.sqlx-mysql/query-4e0ab26a724f4ad897035b545bc612d9cd83a6c3633fa8d5969725214fe846f2.json b/.sqlx-mysql/query-4e0ab26a724f4ad897035b545bc612d9cd83a6c3633fa8d5969725214fe846f2.json deleted file mode 100644 index 06b63727..00000000 --- a/.sqlx-mysql/query-4e0ab26a724f4ad897035b545bc612d9cd83a6c3633fa8d5969725214fe846f2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_messages (id, session_id, seq, role, kind, content, tool_calls, tool_call_id, tool_name, tool_success, tool_error, tool_elapsed_ms, tool_truncated, reasoning_content, call_usage, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", - "describe": { - "columns": [], - "parameters": { - "Right": 17 - }, - "nullable": [] - }, - "hash": "4e0ab26a724f4ad897035b545bc612d9cd83a6c3633fa8d5969725214fe846f2" -} diff --git a/.sqlx-mysql/query-4e73218061928f9fa806dd04875286b9eb8f1ef9865b8d448d7d55e2924e873d.json b/.sqlx-mysql/query-4e73218061928f9fa806dd04875286b9eb8f1ef9865b8d448d7d55e2924e873d.json deleted file mode 100644 index d4240621..00000000 --- a/.sqlx-mysql/query-4e73218061928f9fa806dd04875286b9eb8f1ef9865b8d448d7d55e2924e873d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO jobs (id, job_type, payload, status, max_attempts, run_after, cron_schedule_id, cron_log_id, priority, timeout_secs, dedup_key, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "4e73218061928f9fa806dd04875286b9eb8f1ef9865b8d448d7d55e2924e873d" -} diff --git a/.sqlx-mysql/query-540daf950b8cfa7ff6afb3e50b8f7d99af90247d5eaa5a87fc8b81a01dbbb3df.json b/.sqlx-mysql/query-540daf950b8cfa7ff6afb3e50b8f7d99af90247d5eaa5a87fc8b81a01dbbb3df.json deleted file mode 100644 index ecc2bc9c..00000000 --- a/.sqlx-mysql/query-540daf950b8cfa7ff6afb3e50b8f7d99af90247d5eaa5a87fc8b81a01dbbb3df.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO users (id, tenant_id, username, created_at, updated_at, status, registered_via) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "540daf950b8cfa7ff6afb3e50b8f7d99af90247d5eaa5a87fc8b81a01dbbb3df" -} diff --git a/.sqlx-mysql/query-541b3f1e23ba6b37069173a43792d500b59c4bda5e0d900f538d3bde7c6c159a.json b/.sqlx-mysql/query-541b3f1e23ba6b37069173a43792d500b59c4bda5e0d900f538d3bde7c6c159a.json deleted file mode 100644 index 89b5fab9..00000000 --- a/.sqlx-mysql/query-541b3f1e23ba6b37069173a43792d500b59c4bda5e0d900f538d3bde7c6c159a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO app_ct_refs (id, app_id, ct_table, schema_toml, version, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "541b3f1e23ba6b37069173a43792d500b59c4bda5e0d900f538d3bde7c6c159a" -} diff --git a/.sqlx-mysql/query-5707440d151bfeafe996bd14802a74be873e72f7e222020ee15ed5211adb9615.json b/.sqlx-mysql/query-5707440d151bfeafe996bd14802a74be873e72f7e222020ee15ed5211adb9615.json new file mode 100644 index 00000000..ec8564a2 --- /dev/null +++ b/.sqlx-mysql/query-5707440d151bfeafe996bd14802a74be873e72f7e222020ee15ed5211adb9615.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `users` (`id`, `username`, `created_at`, `updated_at`, `status`, `registered_via`) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "5707440d151bfeafe996bd14802a74be873e72f7e222020ee15ed5211adb9615" +} diff --git a/.sqlx-mysql/query-57c76d4195d5a4778ffdd1374bd9160013287aa0cbbab8c5204b5dbe3e54a3c3.json b/.sqlx-mysql/query-57c76d4195d5a4778ffdd1374bd9160013287aa0cbbab8c5204b5dbe3e54a3c3.json deleted file mode 100644 index 4260b4c5..00000000 --- a/.sqlx-mysql/query-57c76d4195d5a4778ffdd1374bd9160013287aa0cbbab8c5204b5dbe3e54a3c3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO oauth_accounts (id, user_id, provider, provider_user_id, email, display_name, avatar_url, access_token, refresh_token, token_expires_at, profile, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "57c76d4195d5a4778ffdd1374bd9160013287aa0cbbab8c5204b5dbe3e54a3c3" -} diff --git a/.sqlx-mysql/query-586414e6a79736b8bec1ce93ce5841474e02a2c92cc2391623ec13a3a55c22d2.json b/.sqlx-mysql/query-586414e6a79736b8bec1ce93ce5841474e02a2c92cc2391623ec13a3a55c22d2.json new file mode 100644 index 00000000..b221c24c --- /dev/null +++ b/.sqlx-mysql/query-586414e6a79736b8bec1ce93ce5841474e02a2c92cc2391623ec13a3a55c22d2.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `order_items` (`id`, `order_id`, `product_id`, `variant_id`, `title`, `description`, `sku`, `unit_price`, `quantity`, `subtotal`, `tax_amount`, `cover_url`, `attributes`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 15 + }, + "nullable": [] + }, + "hash": "586414e6a79736b8bec1ce93ce5841474e02a2c92cc2391623ec13a3a55c22d2" +} diff --git a/.sqlx-mysql/query-5b585db1363cc1abea34aa95ffb209031b74c7233cbba37bbd6792ecca9150e2.json b/.sqlx-mysql/query-5b585db1363cc1abea34aa95ffb209031b74c7233cbba37bbd6792ecca9150e2.json deleted file mode 100644 index a755754d..00000000 --- a/.sqlx-mysql/query-5b585db1363cc1abea34aa95ffb209031b74c7233cbba37bbd6792ecca9150e2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO workflow_definitions (id, name, description, steps, initial_step, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "5b585db1363cc1abea34aa95ffb209031b74c7233cbba37bbd6792ecca9150e2" -} diff --git a/.sqlx-mysql/query-5b780dfa43760a2d3c3335277d4c404113144fdcefcec7d52e7bc7ecfee00c20.json b/.sqlx-mysql/query-5b780dfa43760a2d3c3335277d4c404113144fdcefcec7d52e7bc7ecfee00c20.json new file mode 100644 index 00000000..ca266fed --- /dev/null +++ b/.sqlx-mysql/query-5b780dfa43760a2d3c3335277d4c404113144fdcefcec7d52e7bc7ecfee00c20.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_memories` (`id`, `agent_id`, `user_id`, `session_id`, `mem_key`, `content`, `category`, `importance`, `pinned`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "5b780dfa43760a2d3c3335277d4c404113144fdcefcec7d52e7bc7ecfee00c20" +} diff --git a/.sqlx-mysql/query-5cdfe7c1f74f11ce11c5ff7564adf74dbd2f165090191fe767638988f50d04f7.json b/.sqlx-mysql/query-5cdfe7c1f74f11ce11c5ff7564adf74dbd2f165090191fe767638988f50d04f7.json new file mode 100644 index 00000000..bd593392 --- /dev/null +++ b/.sqlx-mysql/query-5cdfe7c1f74f11ce11c5ff7564adf74dbd2f165090191fe767638988f50d04f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `cart_items` (`id`, `user_id`, `product_id`, `variant_id`, `quantity`, `attributes`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "5cdfe7c1f74f11ce11c5ff7564adf74dbd2f165090191fe767638988f50d04f7" +} diff --git a/.sqlx-mysql/query-5ce3f0fefb05bf54aeca419eda8c0d35586cd07a24fd6232df173e3ffc2b69f7.json b/.sqlx-mysql/query-5ce3f0fefb05bf54aeca419eda8c0d35586cd07a24fd6232df173e3ffc2b69f7.json deleted file mode 100644 index fc255b1a..00000000 --- a/.sqlx-mysql/query-5ce3f0fefb05bf54aeca419eda8c0d35586cd07a24fd6232df173e3ffc2b69f7.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_transactions (id, payment_order_id, order_id, user_id, tx_type, amount, currency, provider_tx_id, status, raw_payload, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "5ce3f0fefb05bf54aeca419eda8c0d35586cd07a24fd6232df173e3ffc2b69f7" -} diff --git a/.sqlx-mysql/query-5e9605bd63329bb489428f0a91cb86cf05f8c670c4760ce727add4a53a77a75d.json b/.sqlx-mysql/query-5e9605bd63329bb489428f0a91cb86cf05f8c670c4760ce727add4a53a77a75d.json deleted file mode 100644 index 89ca33ec..00000000 --- a/.sqlx-mysql/query-5e9605bd63329bb489428f0a91cb86cf05f8c670c4760ce727add4a53a77a75d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO taggings (id, tag_id, taggable_type, taggable_id, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "5e9605bd63329bb489428f0a91cb86cf05f8c670c4760ce727add4a53a77a75d" -} diff --git a/.sqlx-mysql/query-60dc717206e2519ccacbbb8c4e9f273da0d49943754ebf8e715988cea21fe3ae.json b/.sqlx-mysql/query-60dc717206e2519ccacbbb8c4e9f273da0d49943754ebf8e715988cea21fe3ae.json deleted file mode 100644 index b6e1683d..00000000 --- a/.sqlx-mysql/query-60dc717206e2519ccacbbb8c4e9f273da0d49943754ebf8e715988cea21fe3ae.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO user_addresses (id, user_id, label, recipient_name, phone, country, province, city, district, address_line1, address_line2, postal_code, is_default, address_type, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", - "describe": { - "columns": [], - "parameters": { - "Right": 16 - }, - "nullable": [] - }, - "hash": "60dc717206e2519ccacbbb8c4e9f273da0d49943754ebf8e715988cea21fe3ae" -} diff --git a/.sqlx-mysql/query-613e7465f9259a6b45088b37712c02527017a18c8b8dc89b73f0a99a1e481357.json b/.sqlx-mysql/query-613e7465f9259a6b45088b37712c02527017a18c8b8dc89b73f0a99a1e481357.json new file mode 100644 index 00000000..c5c21463 --- /dev/null +++ b/.sqlx-mysql/query-613e7465f9259a6b45088b37712c02527017a18c8b8dc89b73f0a99a1e481357.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `user_addresses` (`id`, `user_id`, `label`, `recipient_name`, `phone`, `country`, `province`, `city`, `district`, `address_line1`, `address_line2`, `postal_code`, `is_default`, `address_type`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 16 + }, + "nullable": [] + }, + "hash": "613e7465f9259a6b45088b37712c02527017a18c8b8dc89b73f0a99a1e481357" +} diff --git a/.sqlx-mysql/query-619bbdf9be92fbc7252df50e6900b32a67eedf844d63c3192a1134bcc84d780b.json b/.sqlx-mysql/query-619bbdf9be92fbc7252df50e6900b32a67eedf844d63c3192a1134bcc84d780b.json deleted file mode 100644 index dd244c3e..00000000 --- a/.sqlx-mysql/query-619bbdf9be92fbc7252df50e6900b32a67eedf844d63c3192a1134bcc84d780b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO comments (id, post_id, created_by, updated_by, nickname, email, content, parent_id, created_at, updated_at, status) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "619bbdf9be92fbc7252df50e6900b32a67eedf844d63c3192a1134bcc84d780b" -} diff --git a/.sqlx-mysql/query-62b0787c5618e2a0422918472beed1335dd9bd42f214f59e32be682e215585e8.json b/.sqlx-mysql/query-62b0787c5618e2a0422918472beed1335dd9bd42f214f59e32be682e215585e8.json new file mode 100644 index 00000000..7d9979ef --- /dev/null +++ b/.sqlx-mysql/query-62b0787c5618e2a0422918472beed1335dd9bd42f214f59e32be682e215585e8.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `user_device_codes` (`id`, `user_id`, `code`, `access_token`, `refresh_token`, `expires_at`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "62b0787c5618e2a0422918472beed1335dd9bd42f214f59e32be682e215585e8" +} diff --git a/.sqlx-mysql/query-634cde6eaff6fa809b88be78a14673fa3ca047207cfde108a00c34fc3335ba3b.json b/.sqlx-mysql/query-634cde6eaff6fa809b88be78a14673fa3ca047207cfde108a00c34fc3335ba3b.json new file mode 100644 index 00000000..41f2d090 --- /dev/null +++ b/.sqlx-mysql/query-634cde6eaff6fa809b88be78a14673fa3ca047207cfde108a00c34fc3335ba3b.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `jobs` (`id`, `job_type`, `payload`, `status`, `max_attempts`, `run_after`, `cron_schedule_id`, `cron_log_id`, `priority`, `timeout_secs`, `dedup_key`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "634cde6eaff6fa809b88be78a14673fa3ca047207cfde108a00c34fc3335ba3b" +} diff --git a/.sqlx-mysql/query-643a4f56b641dc844ff21acdfa7e4d13b3b18f33e83ad9b99b0a1d0227a9cffb.json b/.sqlx-mysql/query-643a4f56b641dc844ff21acdfa7e4d13b3b18f33e83ad9b99b0a1d0227a9cffb.json new file mode 100644 index 00000000..970e1c36 --- /dev/null +++ b/.sqlx-mysql/query-643a4f56b641dc844ff21acdfa7e4d13b3b18f33e83ad9b99b0a1d0227a9cffb.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `content_revisions` (`content_type`, `record_id`, `revision_number`, `snapshot`, `created_by`) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "643a4f56b641dc844ff21acdfa7e4d13b3b18f33e83ad9b99b0a1d0227a9cffb" +} diff --git a/.sqlx-mysql/query-644c3bd122fd893b4f21221b6323578010e49272cfbb012aa35dcaf62e4f4988.json b/.sqlx-mysql/query-644c3bd122fd893b4f21221b6323578010e49272cfbb012aa35dcaf62e4f4988.json deleted file mode 100644 index c5add0d6..00000000 --- a/.sqlx-mysql/query-644c3bd122fd893b4f21221b6323578010e49272cfbb012aa35dcaf62e4f4988.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_comments (id, product_id, order_id, user_id, rating, title, content, images, status, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "644c3bd122fd893b4f21221b6323578010e49272cfbb012aa35dcaf62e4f4988" -} diff --git a/.sqlx-mysql/query-64e59f3a8539aed4beba31f43bbadecae050cdcad5dbedcba3416a79fce86036.json b/.sqlx-mysql/query-64e59f3a8539aed4beba31f43bbadecae050cdcad5dbedcba3416a79fce86036.json new file mode 100644 index 00000000..49506675 --- /dev/null +++ b/.sqlx-mysql/query-64e59f3a8539aed4beba31f43bbadecae050cdcad5dbedcba3416a79fce86036.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `webhook_subscriptions` (`id`, `name`, `url`, `secret`, `events`, `enabled`, `description`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "64e59f3a8539aed4beba31f43bbadecae050cdcad5dbedcba3416a79fce86036" +} diff --git a/.sqlx-mysql/query-684642237c10edcb617a4a7af9fc654c9cf6a84870f035994aacdca12add153d.json b/.sqlx-mysql/query-684642237c10edcb617a4a7af9fc654c9cf6a84870f035994aacdca12add153d.json new file mode 100644 index 00000000..44a0f61e --- /dev/null +++ b/.sqlx-mysql/query-684642237c10edcb617a4a7af9fc654c9cf6a84870f035994aacdca12add153d.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `plugin_storage` (`plugin_id`, `storage_key`, `value`, `expires_at`, `updated_at`) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `expires_at` = VALUES(`expires_at`), `updated_at` = VALUES(`updated_at`)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "684642237c10edcb617a4a7af9fc654c9cf6a84870f035994aacdca12add153d" +} diff --git a/.sqlx-mysql/query-689a6f70c4cb8dd1c6cf5d0c2daac32b86b78b07c63035550c1f4d409274c83f.json b/.sqlx-mysql/query-689a6f70c4cb8dd1c6cf5d0c2daac32b86b78b07c63035550c1f4d409274c83f.json new file mode 100644 index 00000000..6c3c1eb5 --- /dev/null +++ b/.sqlx-mysql/query-689a6f70c4cb8dd1c6cf5d0c2daac32b86b78b07c63035550c1f4d409274c83f.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `orders` (`id`, `user_id`, `order_no`, `subtotal`, `discount_amount`, `shipping_amount`, `total_amount`, `currency`, `buyer_name`, `buyer_phone`, `buyer_email`, `shipping_address`, `remark`, `tax_amount`, `coupon_id`, `shipping_address_id`, `billing_address_id`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 19 + }, + "nullable": [] + }, + "hash": "689a6f70c4cb8dd1c6cf5d0c2daac32b86b78b07c63035550c1f4d409274c83f" +} diff --git a/.sqlx-mysql/query-6b15cd2e3955588956ba22aa093cb605e246efe53cc29338388d8728fa432e57.json b/.sqlx-mysql/query-6b15cd2e3955588956ba22aa093cb605e246efe53cc29338388d8728fa432e57.json deleted file mode 100644 index 13697db8..00000000 --- a/.sqlx-mysql/query-6b15cd2e3955588956ba22aa093cb605e246efe53cc29338388d8728fa432e57.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_refunds (id, payment_order_id, order_id, user_id, amount, currency, reason, provider_refund_id, status, metadata, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "6b15cd2e3955588956ba22aa093cb605e246efe53cc29338388d8728fa432e57" -} diff --git a/.sqlx-mysql/query-6c992194adc80ae22a9df8f76b52e202958f9a73a4abef3e9f9a2e803623ab29.json b/.sqlx-mysql/query-6c992194adc80ae22a9df8f76b52e202958f9a73a4abef3e9f9a2e803623ab29.json new file mode 100644 index 00000000..66b8cb38 --- /dev/null +++ b/.sqlx-mysql/query-6c992194adc80ae22a9df8f76b52e202958f9a73a4abef3e9f9a2e803623ab29.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `permissions` (`id`, `role_id`, `action`, `subject`, `fields`, `conditions`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "6c992194adc80ae22a9df8f76b52e202958f9a73a4abef3e9f9a2e803623ab29" +} diff --git a/.sqlx-mysql/query-6d834ba00b4b7e63c6f7d16aefbff2e990240245c650afca2d827893e0b9ca5f.json b/.sqlx-mysql/query-6d834ba00b4b7e63c6f7d16aefbff2e990240245c650afca2d827893e0b9ca5f.json deleted file mode 100644 index 848a2d85..00000000 --- a/.sqlx-mysql/query-6d834ba00b4b7e63c6f7d16aefbff2e990240245c650afca2d827893e0b9ca5f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_agents (id, user_id, name, system_prompt, provider, model, temperature, max_iterations, tools, memory_enabled, params, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "6d834ba00b4b7e63c6f7d16aefbff2e990240245c650afca2d827893e0b9ca5f" -} diff --git a/.sqlx-mysql/query-6e29ef7bd2852175269936b10162aa44319f859cf6b96e23faadfaedde151206.json b/.sqlx-mysql/query-6e29ef7bd2852175269936b10162aa44319f859cf6b96e23faadfaedde151206.json new file mode 100644 index 00000000..9d6e1720 --- /dev/null +++ b/.sqlx-mysql/query-6e29ef7bd2852175269936b10162aa44319f859cf6b96e23faadfaedde151206.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `refresh_tokens` (`id`, `user_id`, `token`, `expires_at`, `created_at`) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "6e29ef7bd2852175269936b10162aa44319f859cf6b96e23faadfaedde151206" +} diff --git a/.sqlx-mysql/query-7015463e67a587c677bf5f192ba2aa2b75302feb99d520fdef4bb3a709d28379.json b/.sqlx-mysql/query-7015463e67a587c677bf5f192ba2aa2b75302feb99d520fdef4bb3a709d28379.json deleted file mode 100644 index 90b380d7..00000000 --- a/.sqlx-mysql/query-7015463e67a587c677bf5f192ba2aa2b75302feb99d520fdef4bb3a709d28379.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO tags (id, name, slug, created_by, updated_by, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "7015463e67a587c677bf5f192ba2aa2b75302feb99d520fdef4bb3a709d28379" -} diff --git a/.sqlx-mysql/query-7051eadf2365278e99621914872204ec02d5abccf54481ef54e68eccf7ae11f9.json b/.sqlx-mysql/query-7051eadf2365278e99621914872204ec02d5abccf54481ef54e68eccf7ae11f9.json deleted file mode 100644 index 1a5cf358..00000000 --- a/.sqlx-mysql/query-7051eadf2365278e99621914872204ec02d5abccf54481ef54e68eccf7ae11f9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO reusable_blocks (id, name, block_type, content, description, created_by, updated_by, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "7051eadf2365278e99621914872204ec02d5abccf54481ef54e68eccf7ae11f9" -} diff --git a/.sqlx-mysql/query-70e572793c84d42e1a734edd7244decda367afdf727f26c41467d8ede3b0a4c8.json b/.sqlx-mysql/query-70e572793c84d42e1a734edd7244decda367afdf727f26c41467d8ede3b0a4c8.json deleted file mode 100644 index d18e5a87..00000000 --- a/.sqlx-mysql/query-70e572793c84d42e1a734edd7244decda367afdf727f26c41467d8ede3b0a4c8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO coupons (id, code, title, coupon_type, value, min_order, max_uses, used_count, max_uses_per_user, starts_at, expires_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "70e572793c84d42e1a734edd7244decda367afdf727f26c41467d8ede3b0a4c8" -} diff --git a/.sqlx-mysql/query-76dfec232fba0205292eb44f9cfbce3ef7990746beae4dfba722537a1b4dcae2.json b/.sqlx-mysql/query-76dfec232fba0205292eb44f9cfbce3ef7990746beae4dfba722537a1b4dcae2.json new file mode 100644 index 00000000..cae5fe6e --- /dev/null +++ b/.sqlx-mysql/query-76dfec232fba0205292eb44f9cfbce3ef7990746beae4dfba722537a1b4dcae2.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `media` (`id`, `user_id`, `filename`, `filepath`, `mimetype`, `size`, `width`, `height`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "76dfec232fba0205292eb44f9cfbce3ef7990746beae4dfba722537a1b4dcae2" +} diff --git a/.sqlx-mysql/query-78b4b7396b4c1c1d9fad170c2a0dc9ef2b6beb40bee741f0a113d0e2bb2045fa.json b/.sqlx-mysql/query-78b4b7396b4c1c1d9fad170c2a0dc9ef2b6beb40bee741f0a113d0e2bb2045fa.json new file mode 100644 index 00000000..dbef1f41 --- /dev/null +++ b/.sqlx-mysql/query-78b4b7396b4c1c1d9fad170c2a0dc9ef2b6beb40bee741f0a113d0e2bb2045fa.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `tags` (`id`, `name`, `slug`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "78b4b7396b4c1c1d9fad170c2a0dc9ef2b6beb40bee741f0a113d0e2bb2045fa" +} diff --git a/.sqlx-mysql/query-792ddaf385d9581c448d4ef42f4979045fa23eaca5558777afec6858f56d4f76.json b/.sqlx-mysql/query-792ddaf385d9581c448d4ef42f4979045fa23eaca5558777afec6858f56d4f76.json new file mode 100644 index 00000000..e1dfe644 --- /dev/null +++ b/.sqlx-mysql/query-792ddaf385d9581c448d4ef42f4979045fa23eaca5558777afec6858f56d4f76.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `webhook_subscriptions` (`id`, `name`, `url`, `secret`, `events`, `enabled`, `description`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "792ddaf385d9581c448d4ef42f4979045fa23eaca5558777afec6858f56d4f76" +} diff --git a/.sqlx-mysql/query-7aac439c515b6f5044c8a1884a7f10a8cc52a3711641595046a8084d496685f8.json b/.sqlx-mysql/query-7aac439c515b6f5044c8a1884a7f10a8cc52a3711641595046a8084d496685f8.json new file mode 100644 index 00000000..38d0cb87 --- /dev/null +++ b/.sqlx-mysql/query-7aac439c515b6f5044c8a1884a7f10a8cc52a3711641595046a8084d496685f8.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_wiki_sources` (`id`, `page_id`, `page_revision`, `doc_id`, `span_start`, `span_end`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "7aac439c515b6f5044c8a1884a7f10a8cc52a3711641595046a8084d496685f8" +} diff --git a/.sqlx-mysql/query-7d64eba454f8dfff7ba527dc888487e8b0bce2ece4aa8b1b37cdba68a63e4a5a.json b/.sqlx-mysql/query-7d64eba454f8dfff7ba527dc888487e8b0bce2ece4aa8b1b37cdba68a63e4a5a.json new file mode 100644 index 00000000..6d12f217 --- /dev/null +++ b/.sqlx-mysql/query-7d64eba454f8dfff7ba527dc888487e8b0bce2ece4aa8b1b37cdba68a63e4a5a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_runs` (`id`, `tenant_id`, `kind`, `trigger_src`, `kb_id`, `doc_id`, `agent_id`, `session_id`, `job_id`, `attempt`, `status`, `latency_ms`, `error`, `config_snapshot`, `stages`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 17 + }, + "nullable": [] + }, + "hash": "7d64eba454f8dfff7ba527dc888487e8b0bce2ece4aa8b1b37cdba68a63e4a5a" +} diff --git a/.sqlx-mysql/query-7e106ee0128fc4597f89a594f3f72b504edf1ea8c4d20a60117b4c0a37e4ba33.json b/.sqlx-mysql/query-7e106ee0128fc4597f89a594f3f72b504edf1ea8c4d20a60117b4c0a37e4ba33.json deleted file mode 100644 index 796efd37..00000000 --- a/.sqlx-mysql/query-7e106ee0128fc4597f89a594f3f72b504edf1ea8c4d20a60117b4c0a37e4ba33.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO wallets (id, user_id, currency, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "7e106ee0128fc4597f89a594f3f72b504edf1ea8c4d20a60117b4c0a37e4ba33" -} diff --git a/.sqlx-mysql/query-7fb134809986dbcbf2a0a961565e1c6e266f2e8d6d8703d3489bd63e7a38339e.json b/.sqlx-mysql/query-7fb134809986dbcbf2a0a961565e1c6e266f2e8d6d8703d3489bd63e7a38339e.json new file mode 100644 index 00000000..e420bf0d --- /dev/null +++ b/.sqlx-mysql/query-7fb134809986dbcbf2a0a961565e1c6e266f2e8d6d8703d3489bd63e7a38339e.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_categories` (`id`, `name`, `slug`, `description`, `cover_image`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `parent_id`, `sort_order`, `created_by`, `updated_by`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 17 + }, + "nullable": [] + }, + "hash": "7fb134809986dbcbf2a0a961565e1c6e266f2e8d6d8703d3489bd63e7a38339e" +} diff --git a/.sqlx-mysql/query-7ff8f20ebf8351301c4f0037d1a3f578aa0c8399231b3b2987776db14e51b9d2.json b/.sqlx-mysql/query-7ff8f20ebf8351301c4f0037d1a3f578aa0c8399231b3b2987776db14e51b9d2.json new file mode 100644 index 00000000..91ba91ea --- /dev/null +++ b/.sqlx-mysql/query-7ff8f20ebf8351301c4f0037d1a3f578aa0c8399231b3b2987776db14e51b9d2.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_documents` (`id`, `kb_id`, `title`, `source`, `storage_key`, `mime_type`, `size`, `created_by`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "7ff8f20ebf8351301c4f0037d1a3f578aa0c8399231b3b2987776db14e51b9d2" +} diff --git a/.sqlx-mysql/query-81363143b3f061edbb43de27b35e4ea5fef8ab09ca089855c2e0ad15f96cfd2c.json b/.sqlx-mysql/query-81363143b3f061edbb43de27b35e4ea5fef8ab09ca089855c2e0ad15f96cfd2c.json new file mode 100644 index 00000000..45c3b55b --- /dev/null +++ b/.sqlx-mysql/query-81363143b3f061edbb43de27b35e4ea5fef8ab09ca089855c2e0ad15f96cfd2c.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `workflow_step_logs` (`id`, `instance_id`, `step_id`, `step_name`, `status`, `input`, `started_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "81363143b3f061edbb43de27b35e4ea5fef8ab09ca089855c2e0ad15f96cfd2c" +} diff --git a/.sqlx-mysql/query-816d3e64cf350633d70c14241f20487a9210946793a8a41d6e2e7b8536c59559.json b/.sqlx-mysql/query-816d3e64cf350633d70c14241f20487a9210946793a8a41d6e2e7b8536c59559.json deleted file mode 100644 index c3f925b7..00000000 --- a/.sqlx-mysql/query-816d3e64cf350633d70c14241f20487a9210946793a8a41d6e2e7b8536c59559.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_orders (id, user_id, order_id, title, amount, currency, channel_id, provider, reference_type, reference_id, return_url, idempotency_key, client_ip, client_language, client_country, client_user_agent, channel_selected_by, metadata, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", - "describe": { - "columns": [], - "parameters": { - "Right": 20 - }, - "nullable": [] - }, - "hash": "816d3e64cf350633d70c14241f20487a9210946793a8a41d6e2e7b8536c59559" -} diff --git a/.sqlx-mysql/query-81e24b21d22ac35dd5e3e0a3188c64a2e35f05f93dc43e365d41b81ca1f9200a.json b/.sqlx-mysql/query-81e24b21d22ac35dd5e3e0a3188c64a2e35f05f93dc43e365d41b81ca1f9200a.json new file mode 100644 index 00000000..bee52318 --- /dev/null +++ b/.sqlx-mysql/query-81e24b21d22ac35dd5e3e0a3188c64a2e35f05f93dc43e365d41b81ca1f9200a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `itg_channels` (`id`, `app_id`, `channel_key`, `provider`, `display_name`, `mode`, `transport`, `framing`, `codec`, `endpoint`, `verify_kind`, `verify_config`, `credentials`, `mapping`, `normalizer_plugin`, `pull_semantics`, `pull_config`, `stream_config`, `ack_kind`, `redelivery_max`, `backpressure`, `target_type`, `route_extra`, `status`, `enabled`, `version`, `shadow`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 29 + }, + "nullable": [] + }, + "hash": "81e24b21d22ac35dd5e3e0a3188c64a2e35f05f93dc43e365d41b81ca1f9200a" +} diff --git a/.sqlx-mysql/query-8214d0a7a910996bf596f3630d26db5e8f4b6b23f76db54fa464188d32d3ee8b.json b/.sqlx-mysql/query-8214d0a7a910996bf596f3630d26db5e8f4b6b23f76db54fa464188d32d3ee8b.json deleted file mode 100644 index 8d31825a..00000000 --- a/.sqlx-mysql/query-8214d0a7a910996bf596f3630d26db5e8f4b6b23f76db54fa464188d32d3ee8b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_favorites (id, user_id, product_id, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "8214d0a7a910996bf596f3630d26db5e8f4b6b23f76db54fa464188d32d3ee8b" -} diff --git a/.sqlx-mysql/query-82e94df5f0f7cee8b48a9e6d6101dcf109ec97a5e3dcdb330ee1ae90e021839f.json b/.sqlx-mysql/query-82e94df5f0f7cee8b48a9e6d6101dcf109ec97a5e3dcdb330ee1ae90e021839f.json new file mode 100644 index 00000000..dc09eefc --- /dev/null +++ b/.sqlx-mysql/query-82e94df5f0f7cee8b48a9e6d6101dcf109ec97a5e3dcdb330ee1ae90e021839f.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `categories` (`id`, `name`, `slug`, `description`, `parent_id`, `sort_order`, `cover_image`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 16 + }, + "nullable": [] + }, + "hash": "82e94df5f0f7cee8b48a9e6d6101dcf109ec97a5e3dcdb330ee1ae90e021839f" +} diff --git a/.sqlx-mysql/query-840e5fbc7c16b6c851f4a84dad5dfdf95115ab50fb24a2aeb3870956a1497960.json b/.sqlx-mysql/query-840e5fbc7c16b6c851f4a84dad5dfdf95115ab50fb24a2aeb3870956a1497960.json new file mode 100644 index 00000000..5e6e3bc0 --- /dev/null +++ b/.sqlx-mysql/query-840e5fbc7c16b6c851f4a84dad5dfdf95115ab50fb24a2aeb3870956a1497960.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `pages` (`id`, `title`, `slug`, `content`, `blocks`, `meta_title`, `meta_description`, `og_image`, `template`, `parent_id`, `sort_order`, `status`, `created_by`, `updated_by`, `cover_image`, `published_at`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 19 + }, + "nullable": [] + }, + "hash": "840e5fbc7c16b6c851f4a84dad5dfdf95115ab50fb24a2aeb3870956a1497960" +} diff --git a/.sqlx-mysql/query-84aea1d0d6e6afe5219bc446abf6e6030f6e59a993749357d518d1c1ef9b5a6c.json b/.sqlx-mysql/query-84aea1d0d6e6afe5219bc446abf6e6030f6e59a993749357d518d1c1ef9b5a6c.json deleted file mode 100644 index badf49c8..00000000 --- a/.sqlx-mysql/query-84aea1d0d6e6afe5219bc446abf6e6030f6e59a993749357d518d1c1ef9b5a6c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_sessions (id, agent_id, user_id, title, status, last_seq, created_at, updated_at, last_active_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "84aea1d0d6e6afe5219bc446abf6e6030f6e59a993749357d518d1c1ef9b5a6c" -} diff --git a/.sqlx-mysql/query-8536e75e37b9c140002f224528129a3d85dc52936951dd65734949c07b94253f.json b/.sqlx-mysql/query-8536e75e37b9c140002f224528129a3d85dc52936951dd65734949c07b94253f.json new file mode 100644 index 00000000..4808e66b --- /dev/null +++ b/.sqlx-mysql/query-8536e75e37b9c140002f224528129a3d85dc52936951dd65734949c07b94253f.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `posts` (`id`, `title`, `slug`, `content`, `excerpt`, `cover_image`, `image_ids`, `status`, `created_by`, `updated_by`, `category_id`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `canonical_url`, `published_at`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "8536e75e37b9c140002f224528129a3d85dc52936951dd65734949c07b94253f" +} diff --git a/.sqlx-mysql/query-856e567f6cccd8efc97cb10f18d5fc4ba1a151646c05e6b8275ed47caa129fc8.json b/.sqlx-mysql/query-856e567f6cccd8efc97cb10f18d5fc4ba1a151646c05e6b8275ed47caa129fc8.json deleted file mode 100644 index 777901b3..00000000 --- a/.sqlx-mysql/query-856e567f6cccd8efc97cb10f18d5fc4ba1a151646c05e6b8275ed47caa129fc8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_variants (id, product_id, sku, title, price, original_price, stock, attributes, image_url, weight, sort_order, is_active, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "856e567f6cccd8efc97cb10f18d5fc4ba1a151646c05e6b8275ed47caa129fc8" -} diff --git a/.sqlx-mysql/query-85ddf6296df989403dd05ce8fce41dc77b26b68ca0ab101df4cf3a7ab21fe502.json b/.sqlx-mysql/query-85ddf6296df989403dd05ce8fce41dc77b26b68ca0ab101df4cf3a7ab21fe502.json deleted file mode 100644 index 579e5935..00000000 --- a/.sqlx-mysql/query-85ddf6296df989403dd05ce8fce41dc77b26b68ca0ab101df4cf3a7ab21fe502.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_channels (id, provider, name, is_live, credentials, webhook_secret, settings, is_active, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "85ddf6296df989403dd05ce8fce41dc77b26b68ca0ab101df4cf3a7ab21fe502" -} diff --git a/.sqlx-mysql/query-87ae1e2cc0e0256e376abc1e5244a3904aff1045814e31e03ce210630860a1f6.json b/.sqlx-mysql/query-87ae1e2cc0e0256e376abc1e5244a3904aff1045814e31e03ce210630860a1f6.json new file mode 100644 index 00000000..7ed8640b --- /dev/null +++ b/.sqlx-mysql/query-87ae1e2cc0e0256e376abc1e5244a3904aff1045814e31e03ce210630860a1f6.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `itg_api_clients` (`id`, `client_key`, `display_name`, `base_url`, `auth`, `credentials`, `rate_limit`, `ops`, `enabled`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "87ae1e2cc0e0256e376abc1e5244a3904aff1045814e31e03ce210630860a1f6" +} diff --git a/.sqlx-mysql/query-887bc38b80899c5e7ac91bfb2add2b26e5ecb6b50fa014a6aa21659c0a8e5228.json b/.sqlx-mysql/query-887bc38b80899c5e7ac91bfb2add2b26e5ecb6b50fa014a6aa21659c0a8e5228.json new file mode 100644 index 00000000..9009cf4b --- /dev/null +++ b/.sqlx-mysql/query-887bc38b80899c5e7ac91bfb2add2b26e5ecb6b50fa014a6aa21659c0a8e5228.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `cron_schedules` (`id`, `label`, `job_type`, `payload`, `cron_expr`, `enabled`, `next_run_at`, `plugin_id`, `exec_kind`, `handler_id`, `params`, `script_lang`, `script_source`, `script_entry`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 16 + }, + "nullable": [] + }, + "hash": "887bc38b80899c5e7ac91bfb2add2b26e5ecb6b50fa014a6aa21659c0a8e5228" +} diff --git a/.sqlx-mysql/query-895eb07c1002a17a3dbdaa51ced9c6446dbddb46dc50e93c3eca1b72be6ecdcd.json b/.sqlx-mysql/query-895eb07c1002a17a3dbdaa51ced9c6446dbddb46dc50e93c3eca1b72be6ecdcd.json new file mode 100644 index 00000000..395ada61 --- /dev/null +++ b/.sqlx-mysql/query-895eb07c1002a17a3dbdaa51ced9c6446dbddb46dc50e93c3eca1b72be6ecdcd.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `products` (`id`, `category_id`, `title`, `description`, `cover_url`, `product_type`, `fulfillment_type`, `delivery_hook`, `weight`, `price`, `currency`, `attributes`, `sort_order`, `slug`, `content`, `image_ids`, `original_price`, `specs`, `unit`, `min_purchase`, `max_purchase`, `virtual_sales`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `stock`, `cost_price`, `sale_price`, `has_variants`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 33 + }, + "nullable": [] + }, + "hash": "895eb07c1002a17a3dbdaa51ced9c6446dbddb46dc50e93c3eca1b72be6ecdcd" +} diff --git a/.sqlx-mysql/query-8a3d02278ef22120d26957fe604cd0cb95058b1baab16f8f457a52a7c5be02a6.json b/.sqlx-mysql/query-8a3d02278ef22120d26957fe604cd0cb95058b1baab16f8f457a52a7c5be02a6.json deleted file mode 100644 index 4bbbb166..00000000 --- a/.sqlx-mysql/query-8a3d02278ef22120d26957fe604cd0cb95058b1baab16f8f457a52a7c5be02a6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO categories (id, name, slug, description, parent_id, sort_order, cover_image, meta_title, meta_description, og_title, og_description, og_image, created_by, updated_by, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", - "describe": { - "columns": [], - "parameters": { - "Right": 17 - }, - "nullable": [] - }, - "hash": "8a3d02278ef22120d26957fe604cd0cb95058b1baab16f8f457a52a7c5be02a6" -} diff --git a/.sqlx-mysql/query-8b4690ba53bdc8815dec7ce06c77505b8fd06507bdc16a31aaabdc21cbc33c5c.json b/.sqlx-mysql/query-8b4690ba53bdc8815dec7ce06c77505b8fd06507bdc16a31aaabdc21cbc33c5c.json deleted file mode 100644 index c0997e88..00000000 --- a/.sqlx-mysql/query-8b4690ba53bdc8815dec7ce06c77505b8fd06507bdc16a31aaabdc21cbc33c5c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "8b4690ba53bdc8815dec7ce06c77505b8fd06507bdc16a31aaabdc21cbc33c5c" -} diff --git a/.sqlx-mysql/query-8cabd066e4170413b7d0291f14ba1f43ea4cfd76d1b96f5ce7684efde79e9312.json b/.sqlx-mysql/query-8cabd066e4170413b7d0291f14ba1f43ea4cfd76d1b96f5ce7684efde79e9312.json new file mode 100644 index 00000000..198757c7 --- /dev/null +++ b/.sqlx-mysql/query-8cabd066e4170413b7d0291f14ba1f43ea4cfd76d1b96f5ce7684efde79e9312.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `currencies` (`id`, `tenant_id`, `code`, `name`, `decimals`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "8cabd066e4170413b7d0291f14ba1f43ea4cfd76d1b96f5ce7684efde79e9312" +} diff --git a/.sqlx-mysql/query-8d3810386941bcc9d19160db06d27ed9e199a44d5c6261f924efc092973eaac0.json b/.sqlx-mysql/query-8d3810386941bcc9d19160db06d27ed9e199a44d5c6261f924efc092973eaac0.json deleted file mode 100644 index 1c71c2db..00000000 --- a/.sqlx-mysql/query-8d3810386941bcc9d19160db06d27ed9e199a44d5c6261f924efc092973eaac0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO workflow_step_logs (id, instance_id, step_id, step_name, status, input, started_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "8d3810386941bcc9d19160db06d27ed9e199a44d5c6261f924efc092973eaac0" -} diff --git a/.sqlx-mysql/query-8edbc7cc3fb7bc15ec066e92e6273bf8ac9fbe7a22630b8d9d6f1a39594610eb.json b/.sqlx-mysql/query-8edbc7cc3fb7bc15ec066e92e6273bf8ac9fbe7a22630b8d9d6f1a39594610eb.json new file mode 100644 index 00000000..966aa2df --- /dev/null +++ b/.sqlx-mysql/query-8edbc7cc3fb7bc15ec066e92e6273bf8ac9fbe7a22630b8d9d6f1a39594610eb.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `itg_api_clients` (`id`, `client_key`, `display_name`, `base_url`, `auth`, `credentials`, `rate_limit`, `ops`, `enabled`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "8edbc7cc3fb7bc15ec066e92e6273bf8ac9fbe7a22630b8d9d6f1a39594610eb" +} diff --git a/.sqlx-mysql/query-8f84d596eb12222a16f3137fb2f5e22d8585d2550bb5ea22761294cc57432d9f.json b/.sqlx-mysql/query-8f84d596eb12222a16f3137fb2f5e22d8585d2550bb5ea22761294cc57432d9f.json deleted file mode 100644 index 16c0b267..00000000 --- a/.sqlx-mysql/query-8f84d596eb12222a16f3137fb2f5e22d8585d2550bb5ea22761294cc57432d9f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO media (id, user_id, filename, filepath, mimetype, size, width, height, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "8f84d596eb12222a16f3137fb2f5e22d8585d2550bb5ea22761294cc57432d9f" -} diff --git a/.sqlx-mysql/query-8fd44d384d684c28d5c9af1517f9b82c8841e1aa3cdd1f5d6eef1a4e1f1373b2.json b/.sqlx-mysql/query-8fd44d384d684c28d5c9af1517f9b82c8841e1aa3cdd1f5d6eef1a4e1f1373b2.json new file mode 100644 index 00000000..cb39fa1d --- /dev/null +++ b/.sqlx-mysql/query-8fd44d384d684c28d5c9af1517f9b82c8841e1aa3cdd1f5d6eef1a4e1f1373b2.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `wallet_transactions` (`id`, `wallet_id`, `user_id`, `entry_type`, `amount`, `balance_after`, `tx_type`, `currency`, `transaction_no`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "8fd44d384d684c28d5c9af1517f9b82c8841e1aa3cdd1f5d6eef1a4e1f1373b2" +} diff --git a/.sqlx-mysql/query-91bbe2ab0b98c326c53ab39f4547ecd48eb69ed9b54a23b342ccd7fca17343bc.json b/.sqlx-mysql/query-91bbe2ab0b98c326c53ab39f4547ecd48eb69ed9b54a23b342ccd7fca17343bc.json new file mode 100644 index 00000000..309a910f --- /dev/null +++ b/.sqlx-mysql/query-91bbe2ab0b98c326c53ab39f4547ecd48eb69ed9b54a23b342ccd7fca17343bc.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_knowledge_bases` (`id`, `name`, `description`, `slug`, `kind`, `indexing_strategy`, `embedding_model`, `embedding_dim`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "91bbe2ab0b98c326c53ab39f4547ecd48eb69ed9b54a23b342ccd7fca17343bc" +} diff --git a/.sqlx-mysql/query-92cd64125ae58d502f5af6770222289a0d4df8e89acd9a4317431033cf02d583.json b/.sqlx-mysql/query-92cd64125ae58d502f5af6770222289a0d4df8e89acd9a4317431033cf02d583.json deleted file mode 100644 index 9f3ec963..00000000 --- a/.sqlx-mysql/query-92cd64125ae58d502f5af6770222289a0d4df8e89acd9a4317431033cf02d583.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO kb_chunks (id, kb_id, doc_id, faq_id, wiki_page_id, kind, parent_id, seq, content, breadcrumb, byte_start, byte_end, questions, embedding, embedding_model, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", - "describe": { - "columns": [], - "parameters": { - "Right": 16 - }, - "nullable": [] - }, - "hash": "92cd64125ae58d502f5af6770222289a0d4df8e89acd9a4317431033cf02d583" -} diff --git a/.sqlx-mysql/query-93c11f744897ea7403054bafc7f44002859e31d6d0899a644a3cd9c62c8dab67.json b/.sqlx-mysql/query-93c11f744897ea7403054bafc7f44002859e31d6d0899a644a3cd9c62c8dab67.json deleted file mode 100644 index 07bf8649..00000000 --- a/.sqlx-mysql/query-93c11f744897ea7403054bafc7f44002859e31d6d0899a644a3cd9c62c8dab67.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO wallet_transactions (id, wallet_id, user_id, entry_type, amount, balance_after, tx_type, currency, transaction_no, related_tx_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "93c11f744897ea7403054bafc7f44002859e31d6d0899a644a3cd9c62c8dab67" -} diff --git a/.sqlx-mysql/query-943f1db531b69c702501283fdaaed62fb054e9add442bc1a0ccd851d493c1292.json b/.sqlx-mysql/query-943f1db531b69c702501283fdaaed62fb054e9add442bc1a0ccd851d493c1292.json deleted file mode 100644 index 44d9c466..00000000 --- a/.sqlx-mysql/query-943f1db531b69c702501283fdaaed62fb054e9add442bc1a0ccd851d493c1292.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO products (id, category_id, title, description, cover_url, product_type, fulfillment_type, delivery_hook, weight, price, currency, attributes, sort_order, slug, content, image_ids, original_price, specs, unit, min_purchase, max_purchase, virtual_sales, meta_title, meta_description, og_title, og_description, og_image, stock, cost_price, sale_price, has_variants, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34)", - "describe": { - "columns": [], - "parameters": { - "Right": 34 - }, - "nullable": [] - }, - "hash": "943f1db531b69c702501283fdaaed62fb054e9add442bc1a0ccd851d493c1292" -} diff --git a/.sqlx-mysql/query-97829829cd0cb4cba104522aaadacdef534b7bed029494180df79eeb01d11b86.json b/.sqlx-mysql/query-97829829cd0cb4cba104522aaadacdef534b7bed029494180df79eeb01d11b86.json deleted file mode 100644 index 516bd603..00000000 --- a/.sqlx-mysql/query-97829829cd0cb4cba104522aaadacdef534b7bed029494180df79eeb01d11b86.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO orders (id, user_id, order_no, subtotal, discount_amount, shipping_amount, total_amount, currency, buyer_name, buyer_phone, buyer_email, shipping_address, remark, tax_amount, coupon_id, shipping_address_id, billing_address_id, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", - "describe": { - "columns": [], - "parameters": { - "Right": 20 - }, - "nullable": [] - }, - "hash": "97829829cd0cb4cba104522aaadacdef534b7bed029494180df79eeb01d11b86" -} diff --git a/.sqlx-mysql/query-9809fae846997424ccd2d37c90622d568b3c71b31c662b543a90d57d2f0075e9.json b/.sqlx-mysql/query-9809fae846997424ccd2d37c90622d568b3c71b31c662b543a90d57d2f0075e9.json new file mode 100644 index 00000000..636ce1aa --- /dev/null +++ b/.sqlx-mysql/query-9809fae846997424ccd2d37c90622d568b3c71b31c662b543a90d57d2f0075e9.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `coupons` (`id`, `code`, `title`, `coupon_type`, `value`, `min_order`, `max_uses`, `used_count`, `max_uses_per_user`, `starts_at`, `expires_at`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "9809fae846997424ccd2d37c90622d568b3c71b31c662b543a90d57d2f0075e9" +} diff --git a/.sqlx-mysql/query-993e6fa2ea86bf9dd8333b682e9bb2c728dc7cd3f2e7e8787d26c4f73dec405c.json b/.sqlx-mysql/query-993e6fa2ea86bf9dd8333b682e9bb2c728dc7cd3f2e7e8787d26c4f73dec405c.json new file mode 100644 index 00000000..30e7d29b --- /dev/null +++ b/.sqlx-mysql/query-993e6fa2ea86bf9dd8333b682e9bb2c728dc7cd3f2e7e8787d26c4f73dec405c.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `user_credentials` (`id`, `user_id`, `auth_type`, `identifier`, `credential_data`, `verified`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "993e6fa2ea86bf9dd8333b682e9bb2c728dc7cd3f2e7e8787d26c4f73dec405c" +} diff --git a/.sqlx-mysql/query-99ac192c3e793ea8eb050970ea4287bd5f82e3d43705ae6240838e91f9f5acc4.json b/.sqlx-mysql/query-99ac192c3e793ea8eb050970ea4287bd5f82e3d43705ae6240838e91f9f5acc4.json new file mode 100644 index 00000000..1a885cd2 --- /dev/null +++ b/.sqlx-mysql/query-99ac192c3e793ea8eb050970ea4287bd5f82e3d43705ae6240838e91f9f5acc4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_tokens` (`id`, `user_id`, `name`, `key_hash`, `status`, `remain_quota`, `unlimited_quota`, `expired_at`, `allowed_models`, `allowed_ips`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "99ac192c3e793ea8eb050970ea4287bd5f82e3d43705ae6240838e91f9f5acc4" +} diff --git a/.sqlx-mysql/query-9b99a14fd1f97d84704a85c0ecabc8fe7621e783dc86af4af9ccaccf2263b0b3.json b/.sqlx-mysql/query-9b99a14fd1f97d84704a85c0ecabc8fe7621e783dc86af4af9ccaccf2263b0b3.json deleted file mode 100644 index 46c8f460..00000000 --- a/.sqlx-mysql/query-9b99a14fd1f97d84704a85c0ecabc8fe7621e783dc86af4af9ccaccf2263b0b3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO roles (id, name, description, is_system, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "9b99a14fd1f97d84704a85c0ecabc8fe7621e783dc86af4af9ccaccf2263b0b3" -} diff --git a/.sqlx-mysql/query-9f2b0f4b8dd82f81ffc0802eca98ff38ae754f197a493183d29003daf53c1912.json b/.sqlx-mysql/query-9f2b0f4b8dd82f81ffc0802eca98ff38ae754f197a493183d29003daf53c1912.json deleted file mode 100644 index d526dc72..00000000 --- a/.sqlx-mysql/query-9f2b0f4b8dd82f81ffc0802eca98ff38ae754f197a493183d29003daf53c1912.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO orders (id, user_id, order_no, subtotal, discount_amount, shipping_amount, total_amount, currency, buyer_name, buyer_phone, buyer_email, shipping_address, remark, tax_amount, coupon_id, shipping_address_id, billing_address_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", - "describe": { - "columns": [], - "parameters": { - "Right": 19 - }, - "nullable": [] - }, - "hash": "9f2b0f4b8dd82f81ffc0802eca98ff38ae754f197a493183d29003daf53c1912" -} diff --git a/.sqlx-mysql/query-a208da98c5632b9eb873e8d2f076d90c0f662309539c4e7aeac772c6c1a5b24e.json b/.sqlx-mysql/query-a208da98c5632b9eb873e8d2f076d90c0f662309539c4e7aeac772c6c1a5b24e.json new file mode 100644 index 00000000..de4ccbd6 --- /dev/null +++ b/.sqlx-mysql/query-a208da98c5632b9eb873e8d2f076d90c0f662309539c4e7aeac772c6c1a5b24e.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_models` (`id`, `name`, `model_type`, `price_mode`, `model_ratio`, `completion_ratio`, `cache_ratio`, `cache_write_ratio`, `call_price`, `params`, `status`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "a208da98c5632b9eb873e8d2f076d90c0f662309539c4e7aeac772c6c1a5b24e" +} diff --git a/.sqlx-mysql/query-a491367e80c52edad44008bb84ee2433835fad532e294c9e676a3985008d7ca4.json b/.sqlx-mysql/query-a491367e80c52edad44008bb84ee2433835fad532e294c9e676a3985008d7ca4.json new file mode 100644 index 00000000..ca909d50 --- /dev/null +++ b/.sqlx-mysql/query-a491367e80c52edad44008bb84ee2433835fad532e294c9e676a3985008d7ca4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_channels` (`id`, `name`, `provider`, `base_url`, `api_keys`, `key_mode`, `status`, `models`, `model_mapping`, `priority`, `weight`, `channel_groups`, `auto_ban`, `param_override`, `header_override`, `config`, `test_model`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "a491367e80c52edad44008bb84ee2433835fad532e294c9e676a3985008d7ca4" +} diff --git a/.sqlx-mysql/query-a59c89fc04a16acca91715ef62fc747cb9685f805763b8628f8a7433bddbd17e.json b/.sqlx-mysql/query-a59c89fc04a16acca91715ef62fc747cb9685f805763b8628f8a7433bddbd17e.json deleted file mode 100644 index 53a59767..00000000 --- a/.sqlx-mysql/query-a59c89fc04a16acca91715ef62fc747cb9685f805763b8628f8a7433bddbd17e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO content_revisions (id, content_type, record_id, revision_number, snapshot, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "a59c89fc04a16acca91715ef62fc747cb9685f805763b8628f8a7433bddbd17e" -} diff --git a/.sqlx-mysql/query-a7ec5794b6cd37c1318ecccc96fb1a3a58b9b9577a955d654cc198e5283f9e31.json b/.sqlx-mysql/query-a7ec5794b6cd37c1318ecccc96fb1a3a58b9b9577a955d654cc198e5283f9e31.json new file mode 100644 index 00000000..2dd11839 --- /dev/null +++ b/.sqlx-mysql/query-a7ec5794b6cd37c1318ecccc96fb1a3a58b9b9577a955d654cc198e5283f9e31.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `cart_items` (`id`, `user_id`, `product_id`, `variant_id`, `quantity`, `attributes`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "a7ec5794b6cd37c1318ecccc96fb1a3a58b9b9577a955d654cc198e5283f9e31" +} diff --git a/.sqlx-mysql/query-a8aab8f5ac1e5603b1326e8235478719b1f9f8bf4fc91f52a1757f06a5b3afc9.json b/.sqlx-mysql/query-a8aab8f5ac1e5603b1326e8235478719b1f9f8bf4fc91f52a1757f06a5b3afc9.json deleted file mode 100644 index d3f9773e..00000000 --- a/.sqlx-mysql/query-a8aab8f5ac1e5603b1326e8235478719b1f9f8bf4fc91f52a1757f06a5b3afc9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO users (id, username, created_at, updated_at, status, registered_via) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "a8aab8f5ac1e5603b1326e8235478719b1f9f8bf4fc91f52a1757f06a5b3afc9" -} diff --git a/.sqlx-mysql/query-a9f3579fc327fad0377051402d61272644c6d0521b0217d32199150c7b5d84be.json b/.sqlx-mysql/query-a9f3579fc327fad0377051402d61272644c6d0521b0217d32199150c7b5d84be.json deleted file mode 100644 index 1ac6ca41..00000000 --- a/.sqlx-mysql/query-a9f3579fc327fad0377051402d61272644c6d0521b0217d32199150c7b5d84be.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO comments (id, post_id, created_by, updated_by, nickname, email, content, parent_id, created_at, updated_at, status, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "a9f3579fc327fad0377051402d61272644c6d0521b0217d32199150c7b5d84be" -} diff --git a/.sqlx-mysql/query-acadb66024ea14bee925ff20f3873bcab990ef65358dcb099427a468a039ec27.json b/.sqlx-mysql/query-acadb66024ea14bee925ff20f3873bcab990ef65358dcb099427a468a039ec27.json new file mode 100644 index 00000000..12668a22 --- /dev/null +++ b/.sqlx-mysql/query-acadb66024ea14bee925ff20f3873bcab990ef65358dcb099427a468a039ec27.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `products` (`id`, `category_id`, `title`, `description`, `cover_url`, `product_type`, `fulfillment_type`, `delivery_hook`, `weight`, `price`, `currency`, `attributes`, `sort_order`, `slug`, `content`, `image_ids`, `original_price`, `specs`, `unit`, `min_purchase`, `max_purchase`, `virtual_sales`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `stock`, `cost_price`, `sale_price`, `has_variants`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 34 + }, + "nullable": [] + }, + "hash": "acadb66024ea14bee925ff20f3873bcab990ef65358dcb099427a468a039ec27" +} diff --git a/.sqlx-mysql/query-ad5b266a6cb1ef5829dc1c20795559e9732b78d90183a72109537bacd7ca3fb6.json b/.sqlx-mysql/query-ad5b266a6cb1ef5829dc1c20795559e9732b78d90183a72109537bacd7ca3fb6.json new file mode 100644 index 00000000..00b32161 --- /dev/null +++ b/.sqlx-mysql/query-ad5b266a6cb1ef5829dc1c20795559e9732b78d90183a72109537bacd7ca3fb6.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_refunds` (`id`, `payment_order_id`, `order_id`, `user_id`, `amount`, `currency`, `reason`, `provider_refund_id`, `status`, `payment_tx_id`, `metadata`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "ad5b266a6cb1ef5829dc1c20795559e9732b78d90183a72109537bacd7ca3fb6" +} diff --git a/.sqlx-mysql/query-ad774fbed6cae7374e7b6a846b8f47c323d58ecb7b5a5720a887e00cfe66041c.json b/.sqlx-mysql/query-ad774fbed6cae7374e7b6a846b8f47c323d58ecb7b5a5720a887e00cfe66041c.json deleted file mode 100644 index 34a463a0..00000000 --- a/.sqlx-mysql/query-ad774fbed6cae7374e7b6a846b8f47c323d58ecb7b5a5720a887e00cfe66041c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO plugin_storage (plugin_id, storage_key, value, expires_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(plugin_id, storage_key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at, updated_at = excluded.updated_at", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "ad774fbed6cae7374e7b6a846b8f47c323d58ecb7b5a5720a887e00cfe66041c" -} diff --git a/.sqlx-mysql/query-afb592b104aaf102a60201745acf8b8cd458d45f33314cade43c1f5abc224bc8.json b/.sqlx-mysql/query-afb592b104aaf102a60201745acf8b8cd458d45f33314cade43c1f5abc224bc8.json new file mode 100644 index 00000000..a813511c --- /dev/null +++ b/.sqlx-mysql/query-afb592b104aaf102a60201745acf8b8cd458d45f33314cade43c1f5abc224bc8.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_documents` (`id`, `kb_id`, `title`, `source`, `storage_key`, `mime_type`, `size`, `created_by`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "afb592b104aaf102a60201745acf8b8cd458d45f33314cade43c1f5abc224bc8" +} diff --git a/.sqlx-mysql/query-b099dcc0d81fb281e3116a19b5dfd976b0649521feb42c881acd8f8be09406f0.json b/.sqlx-mysql/query-b099dcc0d81fb281e3116a19b5dfd976b0649521feb42c881acd8f8be09406f0.json new file mode 100644 index 00000000..967317ac --- /dev/null +++ b/.sqlx-mysql/query-b099dcc0d81fb281e3116a19b5dfd976b0649521feb42c881acd8f8be09406f0.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_transactions` (`id`, `payment_order_id`, `order_id`, `user_id`, `tx_type`, `amount`, `currency`, `provider_tx_id`, `status`, `raw_payload`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "b099dcc0d81fb281e3116a19b5dfd976b0649521feb42c881acd8f8be09406f0" +} diff --git a/.sqlx-mysql/query-b3673f12e337207fcf6457f04b677927bbf48c2aa6c24825e6bc7434b17a8f6d.json b/.sqlx-mysql/query-b3673f12e337207fcf6457f04b677927bbf48c2aa6c24825e6bc7434b17a8f6d.json new file mode 100644 index 00000000..9bfa1f37 --- /dev/null +++ b/.sqlx-mysql/query-b3673f12e337207fcf6457f04b677927bbf48c2aa6c24825e6bc7434b17a8f6d.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_faqs` (`id`, `kb_id`, `standard_question`, `similar_questions`, `answers`, `enabled`, `created_by`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "b3673f12e337207fcf6457f04b677927bbf48c2aa6c24825e6bc7434b17a8f6d" +} diff --git a/.sqlx-mysql/query-b48d717f1f3713fcc2d11baa83d8ce24d1b84cb148c87fdcbda5f69e9c267743.json b/.sqlx-mysql/query-b48d717f1f3713fcc2d11baa83d8ce24d1b84cb148c87fdcbda5f69e9c267743.json deleted file mode 100644 index dd8ff85a..00000000 --- a/.sqlx-mysql/query-b48d717f1f3713fcc2d11baa83d8ce24d1b84cb148c87fdcbda5f69e9c267743.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO wallet_outbox (id, user_id, currency, amount, entry_type, tx_type, transaction_no, reference_type, reference_id, metadata, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "b48d717f1f3713fcc2d11baa83d8ce24d1b84cb148c87fdcbda5f69e9c267743" -} diff --git a/.sqlx-mysql/query-b551e00473a3158a3fe1dbc12874f922c72acf86acdbb932a6c6229fbb5af4c0.json b/.sqlx-mysql/query-b551e00473a3158a3fe1dbc12874f922c72acf86acdbb932a6c6229fbb5af4c0.json deleted file mode 100644 index fee62bdc..00000000 --- a/.sqlx-mysql/query-b551e00473a3158a3fe1dbc12874f922c72acf86acdbb932a6c6229fbb5af4c0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO webhook_subscriptions (id, name, url, secret, events, enabled, description, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "b551e00473a3158a3fe1dbc12874f922c72acf86acdbb932a6c6229fbb5af4c0" -} diff --git a/.sqlx-mysql/query-b639f68669f95f788fc14c2819e0a7c786032576dd87990c42d1fa23d37bd730.json b/.sqlx-mysql/query-b639f68669f95f788fc14c2819e0a7c786032576dd87990c42d1fa23d37bd730.json new file mode 100644 index 00000000..cecc2f4c --- /dev/null +++ b/.sqlx-mysql/query-b639f68669f95f788fc14c2819e0a7c786032576dd87990c42d1fa23d37bd730.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `email_verification_tokens` (`id`, `user_id`, `token`, `email`, `expires_at`, `created_at`) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "b639f68669f95f788fc14c2819e0a7c786032576dd87990c42d1fa23d37bd730" +} diff --git a/.sqlx-mysql/query-b67017fa5ba21ec3631b52aab7589449cc4fb56bad823d32937ee0c5c29941e2.json b/.sqlx-mysql/query-b67017fa5ba21ec3631b52aab7589449cc4fb56bad823d32937ee0c5c29941e2.json new file mode 100644 index 00000000..677ff953 --- /dev/null +++ b/.sqlx-mysql/query-b67017fa5ba21ec3631b52aab7589449cc4fb56bad823d32937ee0c5c29941e2.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_tokens` (`id`, `user_id`, `name`, `key_hash`, `status`, `remain_quota`, `unlimited_quota`, `expired_at`, `allowed_models`, `allowed_ips`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "b67017fa5ba21ec3631b52aab7589449cc4fb56bad823d32937ee0c5c29941e2" +} diff --git a/.sqlx-mysql/query-b7608863481787c3ea957d810f884d1676ab13fa91d9719846014ea41b86f603.json b/.sqlx-mysql/query-b7608863481787c3ea957d810f884d1676ab13fa91d9719846014ea41b86f603.json new file mode 100644 index 00000000..7865a83e --- /dev/null +++ b/.sqlx-mysql/query-b7608863481787c3ea957d810f884d1676ab13fa91d9719846014ea41b86f603.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_query_logs` (`id`, `kb_id`, `question`, `answer`, `cited_units`, `status`, `top_score`, `user_id`, `rewritten_question`, `kb_ids`, `latency_ms`, `run_id`, `error`, `source`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 15 + }, + "nullable": [] + }, + "hash": "b7608863481787c3ea957d810f884d1676ab13fa91d9719846014ea41b86f603" +} diff --git a/.sqlx-mysql/query-b76f4e37b51afe15519f57c4046282090c0b874557bf646ef9b2e7c623c28cde.json b/.sqlx-mysql/query-b76f4e37b51afe15519f57c4046282090c0b874557bf646ef9b2e7c623c28cde.json deleted file mode 100644 index 98172c80..00000000 --- a/.sqlx-mysql/query-b76f4e37b51afe15519f57c4046282090c0b874557bf646ef9b2e7c623c28cde.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO cart_items (id, user_id, product_id, variant_id, quantity, attributes, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "b76f4e37b51afe15519f57c4046282090c0b874557bf646ef9b2e7c623c28cde" -} diff --git a/.sqlx-mysql/query-b7fedb64332ed9ffc88d75747e5f8a96167be644b10b315da82fe25a926ab56f.json b/.sqlx-mysql/query-b7fedb64332ed9ffc88d75747e5f8a96167be644b10b315da82fe25a926ab56f.json new file mode 100644 index 00000000..47a58f42 --- /dev/null +++ b/.sqlx-mysql/query-b7fedb64332ed9ffc88d75747e5f8a96167be644b10b315da82fe25a926ab56f.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `users` (`id`, `username`, `created_at`, `updated_at`, `status`, `registered_via`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "b7fedb64332ed9ffc88d75747e5f8a96167be644b10b315da82fe25a926ab56f" +} diff --git a/.sqlx-mysql/query-b8c1052e2e35b3c762d36e1123d9b0ec18617974eea17f926a4ea4064549854f.json b/.sqlx-mysql/query-b8c1052e2e35b3c762d36e1123d9b0ec18617974eea17f926a4ea4064549854f.json deleted file mode 100644 index 5109baaa..00000000 --- a/.sqlx-mysql/query-b8c1052e2e35b3c762d36e1123d9b0ec18617974eea17f926a4ea4064549854f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_orders (id, user_id, order_id, title, amount, currency, channel_id, provider, reference_type, reference_id, return_url, idempotency_key, client_ip, client_language, client_country, client_user_agent, channel_selected_by, metadata, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", - "describe": { - "columns": [], - "parameters": { - "Right": 21 - }, - "nullable": [] - }, - "hash": "b8c1052e2e35b3c762d36e1123d9b0ec18617974eea17f926a4ea4064549854f" -} diff --git a/.sqlx-mysql/query-bc7ddaca8a13c9efbd3bb5e34ad5a54230203a1e51a54b03f7a9b9dd622132fb.json b/.sqlx-mysql/query-bc7ddaca8a13c9efbd3bb5e34ad5a54230203a1e51a54b03f7a9b9dd622132fb.json deleted file mode 100644 index b790fb2d..00000000 --- a/.sqlx-mysql/query-bc7ddaca8a13c9efbd3bb5e34ad5a54230203a1e51a54b03f7a9b9dd622132fb.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO shipping_templates (id, name, type, first_unit, first_price, additional_unit, additional_price, free_shipping_amount, regions, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "bc7ddaca8a13c9efbd3bb5e34ad5a54230203a1e51a54b03f7a9b9dd622132fb" -} diff --git a/.sqlx-mysql/query-bccaa81b9d8979c9313a585be1ab21a2a87033ee69ee5a5e9498062e4e49722c.json b/.sqlx-mysql/query-bccaa81b9d8979c9313a585be1ab21a2a87033ee69ee5a5e9498062e4e49722c.json deleted file mode 100644 index 0481cbb3..00000000 --- a/.sqlx-mysql/query-bccaa81b9d8979c9313a585be1ab21a2a87033ee69ee5a5e9498062e4e49722c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO tags (id, name, slug, created_by, updated_by, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - "describe": { - "columns": [], - "parameters": { - "Right": 8 - }, - "nullable": [] - }, - "hash": "bccaa81b9d8979c9313a585be1ab21a2a87033ee69ee5a5e9498062e4e49722c" -} diff --git a/.sqlx-mysql/query-bdea715397ea3e78d1d5e4fc2616ec18b6779fcb557c9ccacdc4de2acce74bdc.json b/.sqlx-mysql/query-bdea715397ea3e78d1d5e4fc2616ec18b6779fcb557c9ccacdc4de2acce74bdc.json new file mode 100644 index 00000000..67f77e22 --- /dev/null +++ b/.sqlx-mysql/query-bdea715397ea3e78d1d5e4fc2616ec18b6779fcb557c9ccacdc4de2acce74bdc.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_favorites` (`id`, `user_id`, `product_id`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "bdea715397ea3e78d1d5e4fc2616ec18b6779fcb557c9ccacdc4de2acce74bdc" +} diff --git a/.sqlx-mysql/query-bdf78ec8c38d85e65f6129ff8a265ef33253198e83844ba48c9f2d3f94a54ba3.json b/.sqlx-mysql/query-bdf78ec8c38d85e65f6129ff8a265ef33253198e83844ba48c9f2d3f94a54ba3.json new file mode 100644 index 00000000..c1c8b394 --- /dev/null +++ b/.sqlx-mysql/query-bdf78ec8c38d85e65f6129ff8a265ef33253198e83844ba48c9f2d3f94a54ba3.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_favorites` (`id`, `user_id`, `product_id`, `created_at`) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "bdf78ec8c38d85e65f6129ff8a265ef33253198e83844ba48c9f2d3f94a54ba3" +} diff --git a/.sqlx-mysql/query-be4324ba43c80e343b654c60768b612494c1374783989d5a1353a99cb39cbe90.json b/.sqlx-mysql/query-be4324ba43c80e343b654c60768b612494c1374783989d5a1353a99cb39cbe90.json deleted file mode 100644 index ba2e8223..00000000 --- a/.sqlx-mysql/query-be4324ba43c80e343b654c60768b612494c1374783989d5a1353a99cb39cbe90.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_channels (id, provider, name, is_live, credentials, webhook_secret, settings, is_active, sort_order, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "be4324ba43c80e343b654c60768b612494c1374783989d5a1353a99cb39cbe90" -} diff --git a/.sqlx-mysql/query-c2ad5b81e8dbab0c513e46a2e0f01e7f4b88faf7277f580b0115c3006579b41e.json b/.sqlx-mysql/query-c2ad5b81e8dbab0c513e46a2e0f01e7f4b88faf7277f580b0115c3006579b41e.json new file mode 100644 index 00000000..df02eb91 --- /dev/null +++ b/.sqlx-mysql/query-c2ad5b81e8dbab0c513e46a2e0f01e7f4b88faf7277f580b0115c3006579b41e.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_comments` (`id`, `product_id`, `order_id`, `user_id`, `rating`, `title`, `content`, `images`, `status`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "c2ad5b81e8dbab0c513e46a2e0f01e7f4b88faf7277f580b0115c3006579b41e" +} diff --git a/.sqlx-mysql/query-c3860ee21b1ff0552ba3d6f58bb4eacef34e4a9b8af28786a32cf60160b2b14d.json b/.sqlx-mysql/query-c3860ee21b1ff0552ba3d6f58bb4eacef34e4a9b8af28786a32cf60160b2b14d.json deleted file mode 100644 index 420840cd..00000000 --- a/.sqlx-mysql/query-c3860ee21b1ff0552ba3d6f58bb4eacef34e4a9b8af28786a32cf60160b2b14d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO cron_schedules (id, label, job_type, payload, cron_expr, enabled, next_run_at, plugin_id, exec_kind, handler_id, params, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - "describe": { - "columns": [], - "parameters": { - "Right": 13 - }, - "nullable": [] - }, - "hash": "c3860ee21b1ff0552ba3d6f58bb4eacef34e4a9b8af28786a32cf60160b2b14d" -} diff --git a/.sqlx-mysql/query-c78e8396f3ba8b01bf5acaee1e4fe5dbe67bda983ff018de9948ac6eae5719a7.json b/.sqlx-mysql/query-c78e8396f3ba8b01bf5acaee1e4fe5dbe67bda983ff018de9948ac6eae5719a7.json deleted file mode 100644 index 208b6026..00000000 --- a/.sqlx-mysql/query-c78e8396f3ba8b01bf5acaee1e4fe5dbe67bda983ff018de9948ac6eae5719a7.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO currencies (id, tenant_id, code, name, decimals, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "c78e8396f3ba8b01bf5acaee1e4fe5dbe67bda983ff018de9948ac6eae5719a7" -} diff --git a/.sqlx-mysql/query-c83bcb80850708a7dadd4465ec8162d5fb575989499443a7042dbe5baccd7816.json b/.sqlx-mysql/query-c83bcb80850708a7dadd4465ec8162d5fb575989499443a7042dbe5baccd7816.json new file mode 100644 index 00000000..2e913edb --- /dev/null +++ b/.sqlx-mysql/query-c83bcb80850708a7dadd4465ec8162d5fb575989499443a7042dbe5baccd7816.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_sessions` (`id`, `agent_id`, `user_id`, `title`, `status`, `last_seq`, `created_at`, `updated_at`, `last_active_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "c83bcb80850708a7dadd4465ec8162d5fb575989499443a7042dbe5baccd7816" +} diff --git a/.sqlx-mysql/query-ca026c702e1f298987fddaeef413f80ef1145b31d56d43bb780b8db500f4c6c2.json b/.sqlx-mysql/query-ca026c702e1f298987fddaeef413f80ef1145b31d56d43bb780b8db500f4c6c2.json deleted file mode 100644 index b13f3af1..00000000 --- a/.sqlx-mysql/query-ca026c702e1f298987fddaeef413f80ef1145b31d56d43bb780b8db500f4c6c2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO user_addresses (id, user_id, label, recipient_name, phone, country, province, city, district, address_line1, address_line2, postal_code, is_default, address_type, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", - "describe": { - "columns": [], - "parameters": { - "Right": 17 - }, - "nullable": [] - }, - "hash": "ca026c702e1f298987fddaeef413f80ef1145b31d56d43bb780b8db500f4c6c2" -} diff --git a/.sqlx-mysql/query-ca8321b1b47aaf0b8674cfb7b9ed947663826a5bdb5dd7e65afcd8386a023425.json b/.sqlx-mysql/query-ca8321b1b47aaf0b8674cfb7b9ed947663826a5bdb5dd7e65afcd8386a023425.json deleted file mode 100644 index baf3af4f..00000000 --- a/.sqlx-mysql/query-ca8321b1b47aaf0b8674cfb7b9ed947663826a5bdb5dd7e65afcd8386a023425.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_messages (id, session_id, seq, role, kind, content, tool_calls, tool_call_id, tool_name, tool_success, tool_error, tool_elapsed_ms, tool_truncated, reasoning_content, call_usage, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", - "describe": { - "columns": [], - "parameters": { - "Right": 16 - }, - "nullable": [] - }, - "hash": "ca8321b1b47aaf0b8674cfb7b9ed947663826a5bdb5dd7e65afcd8386a023425" -} diff --git a/.sqlx-mysql/query-caa27bcd40aeaf73d1a52196b9e0a9d2aa411068feec59c4fae92758cc3ce9d8.json b/.sqlx-mysql/query-caa27bcd40aeaf73d1a52196b9e0a9d2aa411068feec59c4fae92758cc3ce9d8.json new file mode 100644 index 00000000..0a750f33 --- /dev/null +++ b/.sqlx-mysql/query-caa27bcd40aeaf73d1a52196b9e0a9d2aa411068feec59c4fae92758cc3ce9d8.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `users` (`id`, `tenant_id`, `username`, `created_at`, `updated_at`, `status`, `registered_via`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "caa27bcd40aeaf73d1a52196b9e0a9d2aa411068feec59c4fae92758cc3ce9d8" +} diff --git a/.sqlx-mysql/query-cb30413bb7f176feb4d30d4266a00a1f90c9897188d7089d8d2dbefa7c4bc67c.json b/.sqlx-mysql/query-cb30413bb7f176feb4d30d4266a00a1f90c9897188d7089d8d2dbefa7c4bc67c.json deleted file mode 100644 index 487490a1..00000000 --- a/.sqlx-mysql/query-cb30413bb7f176feb4d30d4266a00a1f90c9897188d7089d8d2dbefa7c4bc67c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_memories (id, agent_id, user_id, session_id, mem_key, content, category, importance, pinned, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "cb30413bb7f176feb4d30d4266a00a1f90c9897188d7089d8d2dbefa7c4bc67c" -} diff --git a/.sqlx-mysql/query-cc14b25ef4e2fad4ff555d00a188ef3180d44a471724aedc6dcf9c0274d331ab.json b/.sqlx-mysql/query-cc14b25ef4e2fad4ff555d00a188ef3180d44a471724aedc6dcf9c0274d331ab.json deleted file mode 100644 index 54f44d55..00000000 --- a/.sqlx-mysql/query-cc14b25ef4e2fad4ff555d00a188ef3180d44a471724aedc6dcf9c0274d331ab.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO itg_api_clients (id, client_key, display_name, base_url, auth, credentials, rate_limit, ops, enabled, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "cc14b25ef4e2fad4ff555d00a188ef3180d44a471724aedc6dcf9c0274d331ab" -} diff --git a/.sqlx-mysql/query-cc7c5ec6442985bd444e8452da2e690472890d20b8fd1ec2ea67ccdfa5618480.json b/.sqlx-mysql/query-cc7c5ec6442985bd444e8452da2e690472890d20b8fd1ec2ea67ccdfa5618480.json new file mode 100644 index 00000000..dbe6eeaa --- /dev/null +++ b/.sqlx-mysql/query-cc7c5ec6442985bd444e8452da2e690472890d20b8fd1ec2ea67ccdfa5618480.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `cron_execution_log` (`id`, `schedule_id`, `job_type`, `label`, `status`, `started_at`) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "cc7c5ec6442985bd444e8452da2e690472890d20b8fd1ec2ea67ccdfa5618480" +} diff --git a/.sqlx-mysql/query-ccadc6e443f157f6e3ec6b5d23830fef83551a4caaeee9d853608027d33fe37d.json b/.sqlx-mysql/query-ccadc6e443f157f6e3ec6b5d23830fef83551a4caaeee9d853608027d33fe37d.json new file mode 100644 index 00000000..52d8699d --- /dev/null +++ b/.sqlx-mysql/query-ccadc6e443f157f6e3ec6b5d23830fef83551a4caaeee9d853608027d33fe37d.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_wiki_pages` (`id`, `kb_id`, `title`, `slug`, `status`, `content`, `summary`, `linked_page_ids`, `created_by`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "ccadc6e443f157f6e3ec6b5d23830fef83551a4caaeee9d853608027d33fe37d" +} diff --git a/.sqlx-mysql/query-cda9b8fbf1bf94fbdb158df5dddf00446b16011633516523a25b72d6192cf0f0.json b/.sqlx-mysql/query-cda9b8fbf1bf94fbdb158df5dddf00446b16011633516523a25b72d6192cf0f0.json new file mode 100644 index 00000000..312de02d --- /dev/null +++ b/.sqlx-mysql/query-cda9b8fbf1bf94fbdb158df5dddf00446b16011633516523a25b72d6192cf0f0.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_knowledge_bases` (`id`, `name`, `description`, `slug`, `kind`, `indexing_strategy`, `embedding_model`, `embedding_dim`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "cda9b8fbf1bf94fbdb158df5dddf00446b16011633516523a25b72d6192cf0f0" +} diff --git a/.sqlx-mysql/query-cdaf7a8cb2657d0c7cd05d9219f6be599279b1adc3978387c42eca5eb7fd23ad.json b/.sqlx-mysql/query-cdaf7a8cb2657d0c7cd05d9219f6be599279b1adc3978387c42eca5eb7fd23ad.json deleted file mode 100644 index ed2d6a3e..00000000 --- a/.sqlx-mysql/query-cdaf7a8cb2657d0c7cd05d9219f6be599279b1adc3978387c42eca5eb7fd23ad.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_refunds (id, payment_order_id, order_id, user_id, amount, currency, reason, provider_refund_id, status, payment_tx_id, metadata, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "cdaf7a8cb2657d0c7cd05d9219f6be599279b1adc3978387c42eca5eb7fd23ad" -} diff --git a/.sqlx-mysql/query-cf53378c1bab374a671ea72b66cf0d5e80c566e29c5a6b0a1c75467b686b9c88.json b/.sqlx-mysql/query-cf53378c1bab374a671ea72b66cf0d5e80c566e29c5a6b0a1c75467b686b9c88.json new file mode 100644 index 00000000..6d4d44e7 --- /dev/null +++ b/.sqlx-mysql/query-cf53378c1bab374a671ea72b66cf0d5e80c566e29c5a6b0a1c75467b686b9c88.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `apps` (`id`, `app_id`, `version`, `status`, `source`, `source_ref`, `signature_ok`, `install_log`, `last_error`, `tenant_scope`, `options`, `installed_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "cf53378c1bab374a671ea72b66cf0d5e80c566e29c5a6b0a1c75467b686b9c88" +} diff --git a/.sqlx-mysql/query-cfc996252110accde41a09b2ceff175d8736051e85689c86442c0e77dca1f8ac.json b/.sqlx-mysql/query-cfc996252110accde41a09b2ceff175d8736051e85689c86442c0e77dca1f8ac.json deleted file mode 100644 index c608a965..00000000 --- a/.sqlx-mysql/query-cfc996252110accde41a09b2ceff175d8736051e85689c86442c0e77dca1f8ac.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO cron_schedules (id, label, job_type, payload, cron_expr, enabled, next_run_at, plugin_id, exec_kind, handler_id, params, script_lang, script_source, script_entry, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", - "describe": { - "columns": [], - "parameters": { - "Right": 16 - }, - "nullable": [] - }, - "hash": "cfc996252110accde41a09b2ceff175d8736051e85689c86442c0e77dca1f8ac" -} diff --git a/.sqlx-mysql/query-d18b1cf1500e819d96fdfcbd8bedd3aa11047cf03216ca57420ea05ea8c6f080.json b/.sqlx-mysql/query-d18b1cf1500e819d96fdfcbd8bedd3aa11047cf03216ca57420ea05ea8c6f080.json new file mode 100644 index 00000000..10bc147d --- /dev/null +++ b/.sqlx-mysql/query-d18b1cf1500e819d96fdfcbd8bedd3aa11047cf03216ca57420ea05ea8c6f080.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `product_variants` (`id`, `product_id`, `sku`, `title`, `price`, `original_price`, `stock`, `attributes`, `image_url`, `weight`, `sort_order`, `is_active`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "d18b1cf1500e819d96fdfcbd8bedd3aa11047cf03216ca57420ea05ea8c6f080" +} diff --git a/.sqlx-mysql/query-d2b4884649a5e2b3d4b878dbd3d2216ca9e12ad42ce0dd80f2e300faa940c51a.json b/.sqlx-mysql/query-d2b4884649a5e2b3d4b878dbd3d2216ca9e12ad42ce0dd80f2e300faa940c51a.json new file mode 100644 index 00000000..8d27d8d6 --- /dev/null +++ b/.sqlx-mysql/query-d2b4884649a5e2b3d4b878dbd3d2216ca9e12ad42ce0dd80f2e300faa940c51a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `oauth_states` (`id`, `provider`, `code_verifier`, `user_id`, `expires_at`, `created_at`) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "d2b4884649a5e2b3d4b878dbd3d2216ca9e12ad42ce0dd80f2e300faa940c51a" +} diff --git a/.sqlx-mysql/query-d2c0664af1f1d6a3f1ee9e471042791787d939db1493f3be3bdbd1b991e51ec7.json b/.sqlx-mysql/query-d2c0664af1f1d6a3f1ee9e471042791787d939db1493f3be3bdbd1b991e51ec7.json new file mode 100644 index 00000000..e960ba54 --- /dev/null +++ b/.sqlx-mysql/query-d2c0664af1f1d6a3f1ee9e471042791787d939db1493f3be3bdbd1b991e51ec7.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `user_addresses` (`id`, `user_id`, `label`, `recipient_name`, `phone`, `country`, `province`, `city`, `district`, `address_line1`, `address_line2`, `postal_code`, `is_default`, `address_type`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 17 + }, + "nullable": [] + }, + "hash": "d2c0664af1f1d6a3f1ee9e471042791787d939db1493f3be3bdbd1b991e51ec7" +} diff --git a/.sqlx-mysql/query-d36ffc5bd0ebcab09f7544ed73f6d9d1c021e587d0220fcad2821036b6f978c5.json b/.sqlx-mysql/query-d36ffc5bd0ebcab09f7544ed73f6d9d1c021e587d0220fcad2821036b6f978c5.json deleted file mode 100644 index 9c48d4da..00000000 --- a/.sqlx-mysql/query-d36ffc5bd0ebcab09f7544ed73f6d9d1c021e587d0220fcad2821036b6f978c5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_agents (id, user_id, name, system_prompt, provider, model, temperature, max_iterations, tools, memory_enabled, params, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "d36ffc5bd0ebcab09f7544ed73f6d9d1c021e587d0220fcad2821036b6f978c5" -} diff --git a/.sqlx-mysql/query-d4be79248c1b060067e1a5bd4252212a24bcedbb15b4b011dbc382ff2251234c.json b/.sqlx-mysql/query-d4be79248c1b060067e1a5bd4252212a24bcedbb15b4b011dbc382ff2251234c.json deleted file mode 100644 index 7ba2eb1f..00000000 --- a/.sqlx-mysql/query-d4be79248c1b060067e1a5bd4252212a24bcedbb15b4b011dbc382ff2251234c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO workflow_instances (id, definition_id, status, context, triggered_by, started_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "d4be79248c1b060067e1a5bd4252212a24bcedbb15b4b011dbc382ff2251234c" -} diff --git a/.sqlx-mysql/query-d5d20d03fa613b046017dce882e43abc30f76e71c76a930b27c3f9537ab1fb9b.json b/.sqlx-mysql/query-d5d20d03fa613b046017dce882e43abc30f76e71c76a930b27c3f9537ab1fb9b.json new file mode 100644 index 00000000..9d5d6fd8 --- /dev/null +++ b/.sqlx-mysql/query-d5d20d03fa613b046017dce882e43abc30f76e71c76a930b27c3f9537ab1fb9b.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_agents` (`id`, `user_id`, `name`, `system_prompt`, `provider`, `model`, `temperature`, `max_iterations`, `tools`, `memory_enabled`, `params`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "d5d20d03fa613b046017dce882e43abc30f76e71c76a930b27c3f9537ab1fb9b" +} diff --git a/.sqlx-mysql/query-d822d41544f6532882c080e7f53aaa7137dde5b5dfd9f921dec7ad0efe9b7e0a.json b/.sqlx-mysql/query-d822d41544f6532882c080e7f53aaa7137dde5b5dfd9f921dec7ad0efe9b7e0a.json deleted file mode 100644 index 624f8fa9..00000000 --- a/.sqlx-mysql/query-d822d41544f6532882c080e7f53aaa7137dde5b5dfd9f921dec7ad0efe9b7e0a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO taggings (id, tag_id, taggable_type, taggable_id) VALUES (?1, ?2, ?3, ?4)", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "d822d41544f6532882c080e7f53aaa7137dde5b5dfd9f921dec7ad0efe9b7e0a" -} diff --git a/.sqlx-mysql/query-d855e7b6043f446da20272d8bf1e6682c2e39ef36e523ddfc61514368acfe527.json b/.sqlx-mysql/query-d855e7b6043f446da20272d8bf1e6682c2e39ef36e523ddfc61514368acfe527.json new file mode 100644 index 00000000..15661249 --- /dev/null +++ b/.sqlx-mysql/query-d855e7b6043f446da20272d8bf1e6682c2e39ef36e523ddfc61514368acfe527.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `reusable_blocks` (`id`, `name`, `block_type`, `content`, `description`, `created_by`, `updated_by`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "d855e7b6043f446da20272d8bf1e6682c2e39ef36e523ddfc61514368acfe527" +} diff --git a/.sqlx-mysql/query-d95782de0df9eac1714fb696b4ee7d56cb12ce72bb5ca227df3ee8e742f80925.json b/.sqlx-mysql/query-d95782de0df9eac1714fb696b4ee7d56cb12ce72bb5ca227df3ee8e742f80925.json new file mode 100644 index 00000000..7668ed8a --- /dev/null +++ b/.sqlx-mysql/query-d95782de0df9eac1714fb696b4ee7d56cb12ce72bb5ca227df3ee8e742f80925.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `kb_faqs` (`id`, `kb_id`, `standard_question`, `similar_questions`, `answers`, `enabled`, `created_by`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "d95782de0df9eac1714fb696b4ee7d56cb12ce72bb5ca227df3ee8e742f80925" +} diff --git a/.sqlx-mysql/query-dd7225908a5194c0acc8920ffe17259c0eff4236af28441bf62a8b83e267e8cc.json b/.sqlx-mysql/query-dd7225908a5194c0acc8920ffe17259c0eff4236af28441bf62a8b83e267e8cc.json new file mode 100644 index 00000000..ce97c9bd --- /dev/null +++ b/.sqlx-mysql/query-dd7225908a5194c0acc8920ffe17259c0eff4236af28441bf62a8b83e267e8cc.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `taggings` (`id`, `tag_id`, `taggable_type`, `taggable_id`) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "dd7225908a5194c0acc8920ffe17259c0eff4236af28441bf62a8b83e267e8cc" +} diff --git a/.sqlx-mysql/query-de6922034b280628418dbdf22c324522dc662285947e3d0295e758ec3b493a43.json b/.sqlx-mysql/query-de6922034b280628418dbdf22c324522dc662285947e3d0295e758ec3b493a43.json new file mode 100644 index 00000000..6dbfb036 --- /dev/null +++ b/.sqlx-mysql/query-de6922034b280628418dbdf22c324522dc662285947e3d0295e758ec3b493a43.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_channels` (`id`, `name`, `provider`, `base_url`, `api_keys`, `key_mode`, `status`, `models`, `model_mapping`, `priority`, `weight`, `channel_groups`, `auto_ban`, `param_override`, `header_override`, `config`, `test_model`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 19 + }, + "nullable": [] + }, + "hash": "de6922034b280628418dbdf22c324522dc662285947e3d0295e758ec3b493a43" +} diff --git a/.sqlx-mysql/query-df2e469529b9cee629a0344a82c213f970d104db75c7109b9930ac11316b98d3.json b/.sqlx-mysql/query-df2e469529b9cee629a0344a82c213f970d104db75c7109b9930ac11316b98d3.json new file mode 100644 index 00000000..f64772f8 --- /dev/null +++ b/.sqlx-mysql/query-df2e469529b9cee629a0344a82c213f970d104db75c7109b9930ac11316b98d3.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `cron_schedules` (`id`, `label`, `job_type`, `payload`, `cron_expr`, `enabled`, `next_run_at`, `plugin_id`, `exec_kind`, `handler_id`, `params`, `script_lang`, `script_source`, `script_entry`, `use_shell`, `timeout_secs`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 18 + }, + "nullable": [] + }, + "hash": "df2e469529b9cee629a0344a82c213f970d104db75c7109b9930ac11316b98d3" +} diff --git a/.sqlx-mysql/query-e1f4ba6c8e47070ce25b384cb18a2d41ec9221e5cf5cabc375cb3b77314dd660.json b/.sqlx-mysql/query-e1f4ba6c8e47070ce25b384cb18a2d41ec9221e5cf5cabc375cb3b77314dd660.json new file mode 100644 index 00000000..b3a30426 --- /dev/null +++ b/.sqlx-mysql/query-e1f4ba6c8e47070ce25b384cb18a2d41ec9221e5cf5cabc375cb3b77314dd660.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `audit_log` (`id`, `actor_id`, `actor_role`, `action`, `subject`, `subject_id`, `detail`, `ip_address`, `user_agent`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "e1f4ba6c8e47070ce25b384cb18a2d41ec9221e5cf5cabc375cb3b77314dd660" +} diff --git a/.sqlx-mysql/query-e2c53a6b5fb5e78f3e00de2fb55277db831c88dcadd1bfecbede6867c3eb7979.json b/.sqlx-mysql/query-e2c53a6b5fb5e78f3e00de2fb55277db831c88dcadd1bfecbede6867c3eb7979.json deleted file mode 100644 index 02f17c05..00000000 --- a/.sqlx-mysql/query-e2c53a6b5fb5e78f3e00de2fb55277db831c88dcadd1bfecbede6867c3eb7979.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO webhook_deliveries (id, webhook_id, event, status, status_code, error, duration_ms, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - "describe": { - "columns": [], - "parameters": { - "Right": 8 - }, - "nullable": [] - }, - "hash": "e2c53a6b5fb5e78f3e00de2fb55277db831c88dcadd1bfecbede6867c3eb7979" -} diff --git a/.sqlx-mysql/query-e38cf91855fc4c311b74f695bf6f06900f9650c01673379f6eb26e0b42df1487.json b/.sqlx-mysql/query-e38cf91855fc4c311b74f695bf6f06900f9650c01673379f6eb26e0b42df1487.json deleted file mode 100644 index 6f5e2f9c..00000000 --- a/.sqlx-mysql/query-e38cf91855fc4c311b74f695bf6f06900f9650c01673379f6eb26e0b42df1487.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO payment_refunds (id, payment_order_id, order_id, user_id, amount, currency, reason, provider_refund_id, status, metadata, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "e38cf91855fc4c311b74f695bf6f06900f9650c01673379f6eb26e0b42df1487" -} diff --git a/.sqlx-mysql/query-e3e37a00e9f5aa573c3cf0e58dc549e8be4f1e8d067592c7197dbbfade4ad3c3.json b/.sqlx-mysql/query-e3e37a00e9f5aa573c3cf0e58dc549e8be4f1e8d067592c7197dbbfade4ad3c3.json new file mode 100644 index 00000000..192784d3 --- /dev/null +++ b/.sqlx-mysql/query-e3e37a00e9f5aa573c3cf0e58dc549e8be4f1e8d067592c7197dbbfade4ad3c3.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `sms_codes` (`id`, `phone`, `code`, `purpose`, `expires_at`, `ip_address`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "e3e37a00e9f5aa573c3cf0e58dc549e8be4f1e8d067592c7197dbbfade4ad3c3" +} diff --git a/.sqlx-mysql/query-e42e6f66a9e9eaf49e3d2b554ed0069bcfbfe61ea8befde895bae56980e4f778.json b/.sqlx-mysql/query-e42e6f66a9e9eaf49e3d2b554ed0069bcfbfe61ea8befde895bae56980e4f778.json deleted file mode 100644 index 8cee7e59..00000000 --- a/.sqlx-mysql/query-e42e6f66a9e9eaf49e3d2b554ed0069bcfbfe61ea8befde895bae56980e4f778.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO sms_codes (id, phone, code, purpose, expires_at, ip_address, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "e42e6f66a9e9eaf49e3d2b554ed0069bcfbfe61ea8befde895bae56980e4f778" -} diff --git a/.sqlx-mysql/query-e4edc339cde1b12bdbe5e8cc761d49e37541daf536c2ce5b13d37bf7594dd208.json b/.sqlx-mysql/query-e4edc339cde1b12bdbe5e8cc761d49e37541daf536c2ce5b13d37bf7594dd208.json deleted file mode 100644 index 799a262f..00000000 --- a/.sqlx-mysql/query-e4edc339cde1b12bdbe5e8cc761d49e37541daf536c2ce5b13d37bf7594dd208.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO password_reset_tokens (id, user_id, token, expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "e4edc339cde1b12bdbe5e8cc761d49e37541daf536c2ce5b13d37bf7594dd208" -} diff --git a/.sqlx-mysql/query-e6c1ad1d662dde5933c77022c16c898c97158d8c693560a8812b372e2f88c17a.json b/.sqlx-mysql/query-e6c1ad1d662dde5933c77022c16c898c97158d8c693560a8812b372e2f88c17a.json new file mode 100644 index 00000000..9716cba8 --- /dev/null +++ b/.sqlx-mysql/query-e6c1ad1d662dde5933c77022c16c898c97158d8c693560a8812b372e2f88c17a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `wallet_transactions` (`id`, `tenant_id`, `wallet_id`, `user_id`, `entry_type`, `amount`, `balance_after`, `tx_type`, `currency`, `transaction_no`, `related_tx_id`, `reference_type`, `reference_id`, `counterparty_wallet_id`, `metadata`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 16 + }, + "nullable": [] + }, + "hash": "e6c1ad1d662dde5933c77022c16c898c97158d8c693560a8812b372e2f88c17a" +} diff --git a/.sqlx-mysql/query-e70ee66c7c9c4b23fe6493ac8ffc29c70db8a55973912b740dbbda6d732e3ccb.json b/.sqlx-mysql/query-e70ee66c7c9c4b23fe6493ac8ffc29c70db8a55973912b740dbbda6d732e3ccb.json new file mode 100644 index 00000000..60bbb0e0 --- /dev/null +++ b/.sqlx-mysql/query-e70ee66c7c9c4b23fe6493ac8ffc29c70db8a55973912b740dbbda6d732e3ccb.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `comments` (`id`, `post_id`, `created_by`, `updated_by`, `nickname`, `email`, `content`, `parent_id`, `created_at`, `updated_at`, `status`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 11 + }, + "nullable": [] + }, + "hash": "e70ee66c7c9c4b23fe6493ac8ffc29c70db8a55973912b740dbbda6d732e3ccb" +} diff --git a/.sqlx-mysql/query-e729c266734237d11e1c75aabb5a5734a015f88dd88e5aa6c700f0d653342ab4.json b/.sqlx-mysql/query-e729c266734237d11e1c75aabb5a5734a015f88dd88e5aa6c700f0d653342ab4.json new file mode 100644 index 00000000..fc8a10a9 --- /dev/null +++ b/.sqlx-mysql/query-e729c266734237d11e1c75aabb5a5734a015f88dd88e5aa6c700f0d653342ab4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `wallets` (`id`, `user_id`, `currency`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "e729c266734237d11e1c75aabb5a5734a015f88dd88e5aa6c700f0d653342ab4" +} diff --git a/.sqlx-mysql/query-e89db1a60b3d6c97e3aed72b60dd19baeb00d99b320802f97d8c1571849d9b60.json b/.sqlx-mysql/query-e89db1a60b3d6c97e3aed72b60dd19baeb00d99b320802f97d8c1571849d9b60.json deleted file mode 100644 index 6db16df5..00000000 --- a/.sqlx-mysql/query-e89db1a60b3d6c97e3aed72b60dd19baeb00d99b320802f97d8c1571849d9b60.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_memories (id, agent_id, user_id, session_id, mem_key, content, category, importance, pinned, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "e89db1a60b3d6c97e3aed72b60dd19baeb00d99b320802f97d8c1571849d9b60" -} diff --git a/.sqlx-mysql/query-ecbc26ef819413f43919d03094e879d93406d56b2bfd546be60ffa966e24bc28.json b/.sqlx-mysql/query-ecbc26ef819413f43919d03094e879d93406d56b2bfd546be60ffa966e24bc28.json new file mode 100644 index 00000000..9aa7f726 --- /dev/null +++ b/.sqlx-mysql/query-ecbc26ef819413f43919d03094e879d93406d56b2bfd546be60ffa966e24bc28.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `payment_orders` (`id`, `user_id`, `order_id`, `title`, `amount`, `currency`, `channel_id`, `provider`, `reference_type`, `reference_id`, `return_url`, `idempotency_key`, `client_ip`, `client_language`, `client_country`, `client_user_agent`, `channel_selected_by`, `metadata`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 21 + }, + "nullable": [] + }, + "hash": "ecbc26ef819413f43919d03094e879d93406d56b2bfd546be60ffa966e24bc28" +} diff --git a/.sqlx-mysql/query-ee0e8532c8d801135393023af68392114ae5099faa0a1558821c309f6e409b29.json b/.sqlx-mysql/query-ee0e8532c8d801135393023af68392114ae5099faa0a1558821c309f6e409b29.json new file mode 100644 index 00000000..a40116cb --- /dev/null +++ b/.sqlx-mysql/query-ee0e8532c8d801135393023af68392114ae5099faa0a1558821c309f6e409b29.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_messages` (`id`, `session_id`, `seq`, `role`, `kind`, `content`, `tool_calls`, `tool_call_id`, `tool_name`, `tool_success`, `tool_error`, `tool_elapsed_ms`, `tool_truncated`, `reasoning_content`, `call_usage`, `created_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 16 + }, + "nullable": [] + }, + "hash": "ee0e8532c8d801135393023af68392114ae5099faa0a1558821c309f6e409b29" +} diff --git a/.sqlx-mysql/query-eea5def8af7d059a354e9b1ff5f6661f274cf6e387b3903e5bd75cd1e48074a4.json b/.sqlx-mysql/query-eea5def8af7d059a354e9b1ff5f6661f274cf6e387b3903e5bd75cd1e48074a4.json new file mode 100644 index 00000000..18fc9dde --- /dev/null +++ b/.sqlx-mysql/query-eea5def8af7d059a354e9b1ff5f6661f274cf6e387b3903e5bd75cd1e48074a4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `pages` (`id`, `title`, `slug`, `content`, `blocks`, `meta_title`, `meta_description`, `og_image`, `template`, `parent_id`, `sort_order`, `status`, `created_by`, `updated_by`, `cover_image`, `published_at`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 18 + }, + "nullable": [] + }, + "hash": "eea5def8af7d059a354e9b1ff5f6661f274cf6e387b3903e5bd75cd1e48074a4" +} diff --git a/.sqlx-mysql/query-ef7ee3bac331693a9775615817d5fca33d782034d1d67733596dcf6f3707c874.json b/.sqlx-mysql/query-ef7ee3bac331693a9775615817d5fca33d782034d1d67733596dcf6f3707c874.json new file mode 100644 index 00000000..092abf05 --- /dev/null +++ b/.sqlx-mysql/query-ef7ee3bac331693a9775615817d5fca33d782034d1d67733596dcf6f3707c874.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `llm_models` (`id`, `name`, `model_type`, `price_mode`, `model_ratio`, `completion_ratio`, `cache_ratio`, `cache_write_ratio`, `call_price`, `params`, `status`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "ef7ee3bac331693a9775615817d5fca33d782034d1d67733596dcf6f3707c874" +} diff --git a/.sqlx-mysql/query-f2aee52b49b4f457b919560fc37a61daed0de1f5acbe328a967fea7083598ccf.json b/.sqlx-mysql/query-f2aee52b49b4f457b919560fc37a61daed0de1f5acbe328a967fea7083598ccf.json new file mode 100644 index 00000000..2f7a0fa3 --- /dev/null +++ b/.sqlx-mysql/query-f2aee52b49b4f457b919560fc37a61daed0de1f5acbe328a967fea7083598ccf.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `tags` (`id`, `name`, `slug`, `created_by`, `updated_by`, `created_at`, `updated_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "f2aee52b49b4f457b919560fc37a61daed0de1f5acbe328a967fea7083598ccf" +} diff --git a/.sqlx-mysql/query-f4592afc8e969409255467f1486089eb0477f54eb12dd3c9a15aa344dbde1507.json b/.sqlx-mysql/query-f4592afc8e969409255467f1486089eb0477f54eb12dd3c9a15aa344dbde1507.json new file mode 100644 index 00000000..c0726227 --- /dev/null +++ b/.sqlx-mysql/query-f4592afc8e969409255467f1486089eb0477f54eb12dd3c9a15aa344dbde1507.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `ai_messages` (`id`, `session_id`, `seq`, `role`, `kind`, `content`, `tool_calls`, `tool_call_id`, `tool_name`, `tool_success`, `tool_error`, `tool_elapsed_ms`, `tool_truncated`, `reasoning_content`, `call_usage`, `created_at`, `tenant_id`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 17 + }, + "nullable": [] + }, + "hash": "f4592afc8e969409255467f1486089eb0477f54eb12dd3c9a15aa344dbde1507" +} diff --git a/.sqlx-mysql/query-f467fa102b6de4f9ab23a9820e82ee8a6efd972c54447ad4aedd244c78dab623.json b/.sqlx-mysql/query-f467fa102b6de4f9ab23a9820e82ee8a6efd972c54447ad4aedd244c78dab623.json deleted file mode 100644 index 1a6edc00..00000000 --- a/.sqlx-mysql/query-f467fa102b6de4f9ab23a9820e82ee8a6efd972c54447ad4aedd244c78dab623.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO product_comments (id, product_id, order_id, user_id, rating, title, content, images, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "f467fa102b6de4f9ab23a9820e82ee8a6efd972c54447ad4aedd244c78dab623" -} diff --git a/.sqlx-mysql/query-f51781d9bd5853dbac4e091b73f03a03dfcb1fcfeea0ae4db75f0eafaf97ec0a.json b/.sqlx-mysql/query-f51781d9bd5853dbac4e091b73f03a03dfcb1fcfeea0ae4db75f0eafaf97ec0a.json deleted file mode 100644 index 3a88889d..00000000 --- a/.sqlx-mysql/query-f51781d9bd5853dbac4e091b73f03a03dfcb1fcfeea0ae4db75f0eafaf97ec0a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO kb_knowledge_bases (id, tenant_id, name, slug, kind, indexing_strategy, embedding_model, embedding_dim, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "f51781d9bd5853dbac4e091b73f03a03dfcb1fcfeea0ae4db75f0eafaf97ec0a" -} diff --git a/.sqlx-mysql/query-f8207c8b05da03ca3f2c792ba7019ba207e7da6c97254a23c2f5e2773ff32a13.json b/.sqlx-mysql/query-f8207c8b05da03ca3f2c792ba7019ba207e7da6c97254a23c2f5e2773ff32a13.json deleted file mode 100644 index cd77dda5..00000000 --- a/.sqlx-mysql/query-f8207c8b05da03ca3f2c792ba7019ba207e7da6c97254a23c2f5e2773ff32a13.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO tenants (id, name, domain, config, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "f8207c8b05da03ca3f2c792ba7019ba207e7da6c97254a23c2f5e2773ff32a13" -} diff --git a/.sqlx-mysql/query-f9f69ddab25ad56b94f9fa4dcd9dd1d9c132fb9709d632c99af3cf38bd4f1ff2.json b/.sqlx-mysql/query-f9f69ddab25ad56b94f9fa4dcd9dd1d9c132fb9709d632c99af3cf38bd4f1ff2.json deleted file mode 100644 index 076b77ca..00000000 --- a/.sqlx-mysql/query-f9f69ddab25ad56b94f9fa4dcd9dd1d9c132fb9709d632c99af3cf38bd4f1ff2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO kb_knowledge_bases (id, tenant_id, name, slug, kind, indexing_strategy, embedding_model, embedding_dim, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "f9f69ddab25ad56b94f9fa4dcd9dd1d9c132fb9709d632c99af3cf38bd4f1ff2" -} diff --git a/.sqlx-mysql/query-fa9a097fc07d66663a39713a0bd019014092e486166af2ef907b0c7e14b6efa9.json b/.sqlx-mysql/query-fa9a097fc07d66663a39713a0bd019014092e486166af2ef907b0c7e14b6efa9.json deleted file mode 100644 index e11ae6fd..00000000 --- a/.sqlx-mysql/query-fa9a097fc07d66663a39713a0bd019014092e486166af2ef907b0c7e14b6efa9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO kb_documents (id, kb_id, tenant_id, title, source, storage_key, mime_type, size, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - "describe": { - "columns": [], - "parameters": { - "Right": 10 - }, - "nullable": [] - }, - "hash": "fa9a097fc07d66663a39713a0bd019014092e486166af2ef907b0c7e14b6efa9" -} diff --git a/.sqlx-mysql/query-fb01268ec816e86f67008ba6c503d6c718be4903b6fd194d7b04a9c64a4a8128.json b/.sqlx-mysql/query-fb01268ec816e86f67008ba6c503d6c718be4903b6fd194d7b04a9c64a4a8128.json new file mode 100644 index 00000000..4c35bea1 --- /dev/null +++ b/.sqlx-mysql/query-fb01268ec816e86f67008ba6c503d6c718be4903b6fd194d7b04a9c64a4a8128.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `oauth_accounts` (`id`, `user_id`, `provider`, `provider_user_id`, `email`, `display_name`, `avatar_url`, `access_token`, `refresh_token`, `token_expires_at`, `profile`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "fb01268ec816e86f67008ba6c503d6c718be4903b6fd194d7b04a9c64a4a8128" +} diff --git a/.sqlx-mysql/query-fb1b7a9e183976ff69d1ad9b30bf8f67fc6a75b08939a2ca750a917bcd6b4298.json b/.sqlx-mysql/query-fb1b7a9e183976ff69d1ad9b30bf8f67fc6a75b08939a2ca750a917bcd6b4298.json deleted file mode 100644 index b7d771b4..00000000 --- a/.sqlx-mysql/query-fb1b7a9e183976ff69d1ad9b30bf8f67fc6a75b08939a2ca750a917bcd6b4298.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO shipping_templates (id, name, type, first_unit, first_price, additional_unit, additional_price, free_shipping_amount, regions, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "fb1b7a9e183976ff69d1ad9b30bf8f67fc6a75b08939a2ca750a917bcd6b4298" -} diff --git a/.sqlx-mysql/query-fb74bff60db4204bb7b88d4825254d76551d25a16eebec367d92f5ee987d2ade.json b/.sqlx-mysql/query-fb74bff60db4204bb7b88d4825254d76551d25a16eebec367d92f5ee987d2ade.json deleted file mode 100644 index f8c51f16..00000000 --- a/.sqlx-mysql/query-fb74bff60db4204bb7b88d4825254d76551d25a16eebec367d92f5ee987d2ade.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO audit_log (id, actor_id, actor_role, action, subject, subject_id, detail, ip_address, user_agent, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - "describe": { - "columns": [], - "parameters": { - "Right": 11 - }, - "nullable": [] - }, - "hash": "fb74bff60db4204bb7b88d4825254d76551d25a16eebec367d92f5ee987d2ade" -} diff --git a/.sqlx-mysql/query-fbeb58fe9142da315b1101249e6d0c83f4e7f42e239c67fb2780a2791beb849d.json b/.sqlx-mysql/query-fbeb58fe9142da315b1101249e6d0c83f4e7f42e239c67fb2780a2791beb849d.json deleted file mode 100644 index 7d0b7e5c..00000000 --- a/.sqlx-mysql/query-fbeb58fe9142da315b1101249e6d0c83f4e7f42e239c67fb2780a2791beb849d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO email_verification_tokens (id, user_id, token, email, expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - "describe": { - "columns": [], - "parameters": { - "Right": 6 - }, - "nullable": [] - }, - "hash": "fbeb58fe9142da315b1101249e6d0c83f4e7f42e239c67fb2780a2791beb849d" -} diff --git a/.sqlx-mysql/query-ff3572478372bf0d26ca9312a8949348ef924bd7bf6361a40bf90b9e9b42c579.json b/.sqlx-mysql/query-ff3572478372bf0d26ca9312a8949348ef924bd7bf6361a40bf90b9e9b42c579.json deleted file mode 100644 index 5653a1ea..00000000 --- a/.sqlx-mysql/query-ff3572478372bf0d26ca9312a8949348ef924bd7bf6361a40bf90b9e9b42c579.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO ai_sessions (id, agent_id, user_id, title, status, last_seq, created_at, updated_at, last_active_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "ff3572478372bf0d26ca9312a8949348ef924bd7bf6361a40bf90b9e9b42c579" -} diff --git a/.sqlx-postgres/query-1219b1b6a8155c70d0f5a974f7f671f550446838c6fee096fb121a651528391d.json b/.sqlx-postgres/query-1219b1b6a8155c70d0f5a974f7f671f550446838c6fee096fb121a651528391d.json new file mode 100644 index 00000000..dab75cb1 --- /dev/null +++ b/.sqlx-postgres/query-1219b1b6a8155c70d0f5a974f7f671f550446838c6fee096fb121a651528391d.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_tokens\" (\"id\", \"user_id\", \"name\", \"key_hash\", \"status\", \"remain_quota\", \"unlimited_quota\", \"expired_at\", \"allowed_models\", \"allowed_ips\", \"created_at\", \"updated_at\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Int8", + "Bool", + "Timestamptz", + "Text", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "1219b1b6a8155c70d0f5a974f7f671f550446838c6fee096fb121a651528391d" +} diff --git a/.sqlx-postgres/query-19d87bad92d0b6c68c6dcab02cd8b5c128f31c7db074267fe32cdefa49da7b0a.json b/.sqlx-postgres/query-19d87bad92d0b6c68c6dcab02cd8b5c128f31c7db074267fe32cdefa49da7b0a.json new file mode 100644 index 00000000..1273346d --- /dev/null +++ b/.sqlx-postgres/query-19d87bad92d0b6c68c6dcab02cd8b5c128f31c7db074267fe32cdefa49da7b0a.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_models\" (\"id\", \"name\", \"model_type\", \"price_mode\", \"model_ratio\", \"completion_ratio\", \"cache_ratio\", \"cache_write_ratio\", \"call_price\", \"params\", \"status\", \"created_at\", \"updated_at\", \"tenant_id\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Float8", + "Float8", + "Float8", + "Float8", + "Float8", + "Jsonb", + "Varchar", + "Timestamptz", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "19d87bad92d0b6c68c6dcab02cd8b5c128f31c7db074267fe32cdefa49da7b0a" +} diff --git a/.sqlx-postgres/query-367d66743c700b35a3541f9c83210354eaf4ecbb563c506dfef5ed4f5d290bb4.json b/.sqlx-postgres/query-367d66743c700b35a3541f9c83210354eaf4ecbb563c506dfef5ed4f5d290bb4.json new file mode 100644 index 00000000..314db99b --- /dev/null +++ b/.sqlx-postgres/query-367d66743c700b35a3541f9c83210354eaf4ecbb563c506dfef5ed4f5d290bb4.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_logs\" (\"id\", \"request_id\", \"user_id\", \"token_id\", \"source\", \"channel_id\", \"key_index\", \"model_name\", \"is_stream\", \"prompt_tokens\", \"completion_tokens\", \"cache_read_tokens\", \"cache_write_tokens\", \"quota\", \"detail\", \"elapsed_ms\", \"status_code\", \"error_message\", \"created_at\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Int8", + "Varchar", + "Int8", + "Int4", + "Varchar", + "Bool", + "Int4", + "Int4", + "Int4", + "Int4", + "Int8", + "Jsonb", + "Int4", + "Int4", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "367d66743c700b35a3541f9c83210354eaf4ecbb563c506dfef5ed4f5d290bb4" +} diff --git a/.sqlx-postgres/query-3821271fa1d410b6f1aacde8a086035a5f3f9eb98e413f55468e1ecfc4de57a3.json b/.sqlx-postgres/query-3821271fa1d410b6f1aacde8a086035a5f3f9eb98e413f55468e1ecfc4de57a3.json new file mode 100644 index 00000000..50d6ecbd --- /dev/null +++ b/.sqlx-postgres/query-3821271fa1d410b6f1aacde8a086035a5f3f9eb98e413f55468e1ecfc4de57a3.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_models\" (\"id\", \"name\", \"model_type\", \"price_mode\", \"model_ratio\", \"completion_ratio\", \"cache_ratio\", \"cache_write_ratio\", \"call_price\", \"params\", \"status\", \"created_at\", \"updated_at\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Float8", + "Float8", + "Float8", + "Float8", + "Float8", + "Jsonb", + "Varchar", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "3821271fa1d410b6f1aacde8a086035a5f3f9eb98e413f55468e1ecfc4de57a3" +} diff --git a/.sqlx-postgres/query-62c4a2d43a44e5f41a157a8f72a2a3fad99d902045e42aca7c9df0d3c767ed5d.json b/.sqlx-postgres/query-62c4a2d43a44e5f41a157a8f72a2a3fad99d902045e42aca7c9df0d3c767ed5d.json new file mode 100644 index 00000000..ddee6a41 --- /dev/null +++ b/.sqlx-postgres/query-62c4a2d43a44e5f41a157a8f72a2a3fad99d902045e42aca7c9df0d3c767ed5d.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_channels\" (\"id\", \"name\", \"provider\", \"base_url\", \"api_keys\", \"key_mode\", \"status\", \"models\", \"model_mapping\", \"priority\", \"weight\", \"channel_groups\", \"auto_ban\", \"param_override\", \"header_override\", \"config\", \"test_model\", \"created_at\", \"updated_at\", \"tenant_id\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Varchar", + "Varchar", + "Text", + "Jsonb", + "Int8", + "Int4", + "Text", + "Bool", + "Jsonb", + "Jsonb", + "Jsonb", + "Varchar", + "Timestamptz", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "62c4a2d43a44e5f41a157a8f72a2a3fad99d902045e42aca7c9df0d3c767ed5d" +} diff --git a/.sqlx-postgres/query-671349c88e71fe3bd7c1f9ffd0a796fec192aeb1ac31b7e25c1e292f1404655c.json b/.sqlx-postgres/query-671349c88e71fe3bd7c1f9ffd0a796fec192aeb1ac31b7e25c1e292f1404655c.json new file mode 100644 index 00000000..8020141f --- /dev/null +++ b/.sqlx-postgres/query-671349c88e71fe3bd7c1f9ffd0a796fec192aeb1ac31b7e25c1e292f1404655c.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_channels\" (\"id\", \"name\", \"provider\", \"base_url\", \"api_keys\", \"key_mode\", \"status\", \"models\", \"model_mapping\", \"priority\", \"weight\", \"channel_groups\", \"auto_ban\", \"param_override\", \"header_override\", \"config\", \"test_model\", \"created_at\", \"updated_at\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Varchar", + "Varchar", + "Text", + "Jsonb", + "Int8", + "Int4", + "Text", + "Bool", + "Jsonb", + "Jsonb", + "Jsonb", + "Varchar", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "671349c88e71fe3bd7c1f9ffd0a796fec192aeb1ac31b7e25c1e292f1404655c" +} diff --git a/.sqlx-postgres/query-c576da9bf17c1a2898a157c278e4ba5c220ae59e546c13f0b5830853d815f251.json b/.sqlx-postgres/query-c576da9bf17c1a2898a157c278e4ba5c220ae59e546c13f0b5830853d815f251.json new file mode 100644 index 00000000..a77fdacc --- /dev/null +++ b/.sqlx-postgres/query-c576da9bf17c1a2898a157c278e4ba5c220ae59e546c13f0b5830853d815f251.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_tokens\" (\"id\", \"user_id\", \"name\", \"key_hash\", \"status\", \"remain_quota\", \"unlimited_quota\", \"expired_at\", \"allowed_models\", \"allowed_ips\", \"created_at\", \"updated_at\", \"tenant_id\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Int8", + "Bool", + "Timestamptz", + "Text", + "Text", + "Timestamptz", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c576da9bf17c1a2898a157c278e4ba5c220ae59e546c13f0b5830853d815f251" +} diff --git a/.sqlx-postgres/query-dd76d549d06e0c6de282b649ea33f1eb7b8f8f52c4cf9a295792c7f457bc6838.json b/.sqlx-postgres/query-dd76d549d06e0c6de282b649ea33f1eb7b8f8f52c4cf9a295792c7f457bc6838.json new file mode 100644 index 00000000..2307e6a0 --- /dev/null +++ b/.sqlx-postgres/query-dd76d549d06e0c6de282b649ea33f1eb7b8f8f52c4cf9a295792c7f457bc6838.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"llm_logs\" (\"id\", \"request_id\", \"user_id\", \"token_id\", \"source\", \"channel_id\", \"key_index\", \"model_name\", \"is_stream\", \"prompt_tokens\", \"completion_tokens\", \"cache_read_tokens\", \"cache_write_tokens\", \"quota\", \"detail\", \"elapsed_ms\", \"status_code\", \"error_message\", \"created_at\", \"tenant_id\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Int8", + "Varchar", + "Int8", + "Int4", + "Varchar", + "Bool", + "Int4", + "Int4", + "Int4", + "Int4", + "Int8", + "Jsonb", + "Int4", + "Int4", + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dd76d549d06e0c6de282b649ea33f1eb7b8f8f52c4cf9a295792c7f457bc6838" +} diff --git a/.sqlx-sqlite/query-0d693fccfbea98f73a3f855f645d6327d228ff563eb472201a22f109efa4661c.json b/.sqlx-sqlite/query-0d693fccfbea98f73a3f855f645d6327d228ff563eb472201a22f109efa4661c.json new file mode 100644 index 00000000..a1e02a96 --- /dev/null +++ b/.sqlx-sqlite/query-0d693fccfbea98f73a3f855f645d6327d228ff563eb472201a22f109efa4661c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_tokens (id, user_id, name, key_hash, status, remain_quota, unlimited_quota, expired_at, allowed_models, allowed_ips, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "0d693fccfbea98f73a3f855f645d6327d228ff563eb472201a22f109efa4661c" +} diff --git a/.sqlx-sqlite/query-1146ef0c16f285e7a7a5c1e257c2eaf0f016a546f2585393188a112967bb3148.json b/.sqlx-sqlite/query-1146ef0c16f285e7a7a5c1e257c2eaf0f016a546f2585393188a112967bb3148.json new file mode 100644 index 00000000..9b3d8e6e --- /dev/null +++ b/.sqlx-sqlite/query-1146ef0c16f285e7a7a5c1e257c2eaf0f016a546f2585393188a112967bb3148.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_logs (id, request_id, user_id, token_id, source, channel_id, key_index, model_name, is_stream, prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, quota, detail, elapsed_ms, status_code, error_message, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", + "describe": { + "columns": [], + "parameters": { + "Right": 19 + }, + "nullable": [] + }, + "hash": "1146ef0c16f285e7a7a5c1e257c2eaf0f016a546f2585393188a112967bb3148" +} diff --git a/.sqlx-sqlite/query-13fe09d26d7bc57e4cc058f1273621e620e7942af73d37271a13d435c6ebba3a.json b/.sqlx-sqlite/query-13fe09d26d7bc57e4cc058f1273621e620e7942af73d37271a13d435c6ebba3a.json new file mode 100644 index 00000000..eee1727c --- /dev/null +++ b/.sqlx-sqlite/query-13fe09d26d7bc57e4cc058f1273621e620e7942af73d37271a13d435c6ebba3a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_channels (id, name, provider, base_url, api_keys, key_mode, status, models, model_mapping, priority, weight, channel_groups, auto_ban, param_override, header_override, config, test_model, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "13fe09d26d7bc57e4cc058f1273621e620e7942af73d37271a13d435c6ebba3a" +} diff --git a/.sqlx-sqlite/query-b6320601bcc8d0557889f74f6de9c4f21c83c0fe80b7732fbe5e8a079b8a6c13.json b/.sqlx-sqlite/query-b6320601bcc8d0557889f74f6de9c4f21c83c0fe80b7732fbe5e8a079b8a6c13.json new file mode 100644 index 00000000..a272b560 --- /dev/null +++ b/.sqlx-sqlite/query-b6320601bcc8d0557889f74f6de9c4f21c83c0fe80b7732fbe5e8a079b8a6c13.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_models (id, name, model_type, price_mode, model_ratio, completion_ratio, cache_ratio, cache_write_ratio, call_price, params, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + "describe": { + "columns": [], + "parameters": { + "Right": 13 + }, + "nullable": [] + }, + "hash": "b6320601bcc8d0557889f74f6de9c4f21c83c0fe80b7732fbe5e8a079b8a6c13" +} diff --git a/.sqlx-sqlite/query-bc37fe082948f610f75c248200ea4e078b14be0440f19b02155ee3b6fbe7c1c6.json b/.sqlx-sqlite/query-bc37fe082948f610f75c248200ea4e078b14be0440f19b02155ee3b6fbe7c1c6.json new file mode 100644 index 00000000..0b3d7a3b --- /dev/null +++ b/.sqlx-sqlite/query-bc37fe082948f610f75c248200ea4e078b14be0440f19b02155ee3b6fbe7c1c6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_logs (id, request_id, user_id, token_id, source, channel_id, key_index, model_name, is_stream, prompt_tokens, completion_tokens, cache_read_tokens, cache_write_tokens, quota, detail, elapsed_ms, status_code, error_message, created_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + "describe": { + "columns": [], + "parameters": { + "Right": 20 + }, + "nullable": [] + }, + "hash": "bc37fe082948f610f75c248200ea4e078b14be0440f19b02155ee3b6fbe7c1c6" +} diff --git a/.sqlx-sqlite/query-d519d62d254695044f8c6b9368fe425e6f8f009af345838aad749d4e3b792c9a.json b/.sqlx-sqlite/query-d519d62d254695044f8c6b9368fe425e6f8f009af345838aad749d4e3b792c9a.json new file mode 100644 index 00000000..3309bc58 --- /dev/null +++ b/.sqlx-sqlite/query-d519d62d254695044f8c6b9368fe425e6f8f009af345838aad749d4e3b792c9a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_tokens (id, user_id, name, key_hash, status, remain_quota, unlimited_quota, expired_at, allowed_models, allowed_ips, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "describe": { + "columns": [], + "parameters": { + "Right": 12 + }, + "nullable": [] + }, + "hash": "d519d62d254695044f8c6b9368fe425e6f8f009af345838aad749d4e3b792c9a" +} diff --git a/.sqlx-sqlite/query-dcaf6b1ab5a1eb487c7f61857bb70b09c8513b38eac8954cfa819f16c3afb583.json b/.sqlx-sqlite/query-dcaf6b1ab5a1eb487c7f61857bb70b09c8513b38eac8954cfa819f16c3afb583.json new file mode 100644 index 00000000..0289d063 --- /dev/null +++ b/.sqlx-sqlite/query-dcaf6b1ab5a1eb487c7f61857bb70b09c8513b38eac8954cfa819f16c3afb583.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_models (id, name, model_type, price_mode, model_ratio, completion_ratio, cache_ratio, cache_write_ratio, call_price, params, status, created_at, updated_at, tenant_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "dcaf6b1ab5a1eb487c7f61857bb70b09c8513b38eac8954cfa819f16c3afb583" +} diff --git a/.sqlx-sqlite/query-f53c5328a24460b8c7134529d98dbe1b23ae4250c7aaa387d78f77f26bb692f3.json b/.sqlx-sqlite/query-f53c5328a24460b8c7134529d98dbe1b23ae4250c7aaa387d78f77f26bb692f3.json new file mode 100644 index 00000000..88c425f9 --- /dev/null +++ b/.sqlx-sqlite/query-f53c5328a24460b8c7134529d98dbe1b23ae4250c7aaa387d78f77f26bb692f3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO llm_channels (id, name, provider, base_url, api_keys, key_mode, status, models, model_mapping, priority, weight, channel_groups, auto_ban, param_override, header_override, config, test_model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", + "describe": { + "columns": [], + "parameters": { + "Right": 19 + }, + "nullable": [] + }, + "hash": "f53c5328a24460b8c7134529d98dbe1b23ae4250c7aaa387d78f77f26bb692f3" +} diff --git a/crates/core/src/config/app.rs b/crates/core/src/config/app.rs index 1288faf7..7998a071 100644 --- a/crates/core/src/config/app.rs +++ b/crates/core/src/config/app.rs @@ -275,6 +275,10 @@ pub struct BuiltinsConfig { /// and the `raisfast mcp serve` stdio subcommand (default true). #[serde(default = "default_true")] pub mcp: bool, + /// Whether to enable the LLM foundation (channels/key pools/model + /// directory admin API; the `/v1` relay lands in P3) — default true. + #[serde(default = "default_true")] + pub llm_gateway: bool, } impl Default for BuiltinsConfig { @@ -289,6 +293,7 @@ impl Default for BuiltinsConfig { payment: true, wallet: true, mcp: true, + llm_gateway: true, } } } @@ -332,6 +337,10 @@ impl BuiltinsConfig { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(true), + llm_gateway: env::var("BUILTIN_LLM_GATEWAY") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(true), } } @@ -2038,6 +2047,7 @@ mod tests { payment: false, wallet: false, mcp: false, + llm_gateway: false, }; assert!(b.is_all_disabled()); } diff --git a/crates/core/src/handlers/jobs.rs b/crates/core/src/handlers/jobs.rs index e3ba2cb9..6f8f3c46 100644 --- a/crates/core/src/handlers/jobs.rs +++ b/crates/core/src/handlers/jobs.rs @@ -154,10 +154,7 @@ pub async fn list( let filter = JobFilter { status: match params.status.as_deref() { None | Some("") => None, - Some(s) => Some( - s.parse::() - .map_err(AppError::BadRequest)?, - ), + Some(s) => Some(s.parse::().map_err(AppError::BadRequest)?), }, job_type: params .job_type diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 15e7990d..5c18fc02 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -26,6 +26,7 @@ pub mod config; pub mod constants; pub mod content_type; pub mod db; +pub mod llm; pub use db::DbDriver; pub mod dto; pub mod errors; @@ -138,6 +139,8 @@ pub struct AppState { pub search: Arc, /// Knowledge-base runtime singletons; `None` when KB disabled (M2+). pub kb_runtime: Option>, + /// LLM foundation routing core (channels/key pools/model directory). + pub llm_router: Arc, pub content_type_registry: Arc, pub emitter: crate::event::EventEmitter, pub protocol_registry: Arc, @@ -436,6 +439,8 @@ pub async fn build_app_state( } }; + let llm_router = crate::llm::service::LlmRouter::new(pool.clone()).await; + let state = AppState { pool: pool.clone(), config: Arc::new(config.clone()), @@ -461,6 +466,7 @@ pub async fn build_app_state( payment_service, search, kb_runtime, + llm_router, content_type_registry: ct_registry, emitter, protocol_registry, diff --git a/crates/core/src/llm/cache.rs b/crates/core/src/llm/cache.rs new file mode 100644 index 00000000..6d69cc22 --- /dev/null +++ b/crates/core/src/llm/cache.rs @@ -0,0 +1,476 @@ +//! ChannelCache — the in-memory routing table (design §7.1). +//! +//! `routes` maps `RouteKey { tenant, group, model }` to candidate channels +//! (enabled only, pre-sorted by priority DESC — disabled channels are evicted +//! from the routing table immediately on write). +//! `models` caches the directory (status=active rows) including pricing. +//! Write-path invalidation lives in `service::LlmRouter`. + +use std::collections::HashMap; +use std::str::FromStr as _; +use std::sync::Arc; + +use crate::llm::models::channel::{ + LlmChannel, LlmChannelStatus, LlmKeyEntry, LlmKeyMode, LlmKeyStatus, parse_keys, +}; +use crate::llm::models::model::{LlmModel, LlmModelStatus, LlmModelType, LlmPriceMode}; +use crate::types::snowflake_id::SnowflakeId; + +/// Routing key (design §7.1; `group` stays `"default"` until groups ship). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RouteKey { + pub tenant: String, + pub group: String, + pub model: String, +} + +/// Pricing snapshot carried on `ModelInfo` (design §9.3: same cache, same +/// invalidation — the relay hot path never queries the DB for prices). +#[derive(Debug, Clone)] +pub struct Pricing { + pub price_mode: LlmPriceMode, + pub model_ratio: f64, + pub completion_ratio: f64, + pub cache_ratio: Option, + pub cache_write_ratio: Option, + pub call_price: Option, +} + +/// Model metadata + pricing resolved with a request (design §10.1). +#[derive(Debug, Clone)] +pub struct ModelInfo { + pub name: String, + pub model_type: LlmModelType, + pub pricing: Pricing, + pub params: Option, +} + +/// One decrypted key inside the cached channel. `plain` is `None` when +/// decryption failed — the key is marked disabled and skipped (lenient, §11.3). +#[derive(Debug, Clone)] +pub struct CachedKey { + pub plain: Option, + pub status: LlmKeyStatus, + pub max_concurrency: Option, +} + +/// Immutable cached channel shared via `Arc`; state changes clone-and-replace. +#[derive(Debug, Clone)] +pub struct CachedChannel { + pub id: SnowflakeId, + pub tenant: String, + pub provider: String, + pub base_url: String, + pub keys: Vec, + pub key_mode: LlmKeyMode, + pub status: LlmChannelStatus, + pub models: Vec, + pub model_mapping: Option, + pub priority: i64, + pub weight: i32, + pub groups: Vec, + pub auto_ban: bool, + /// Fallback concurrency cap for keys without their own + /// `max_concurrency` (`channel.config.max_concurrency`, design §7.6). + pub default_max_concurrency: Option, + /// Probe model for the channel test endpoint / health cron (§7.5). + pub test_model: Option, + pub param_override: Option, + pub header_override: Option, + pub config: Option, +} + +fn tenant_of(row: &LlmChannel) -> String { + row.tenant_id + .clone() + .unwrap_or_else(|| "default".to_owned()) +} + +fn split_csv(s: &str) -> Vec { + s.split(',') + .map(|p| p.trim().to_owned()) + .filter(|p| !p.is_empty()) + .collect() +} + +/// Built-in common-model seed (design §5.4: directory lookup = DB row → +/// built-in → 400; both count as "registered"). +const BUILTIN_MODELS: &[(&str, &str, f64, f64)] = &[ + ("gpt-4o", "chat", 2.5, 2.0), + ("gpt-4o-mini", "chat", 0.15, 4.0), + ("o3-mini", "chat", 1.1, 4.0), + ("claude-sonnet-4-5", "chat", 1.5, 5.0), + ("claude-haiku-4-5", "chat", 0.5, 4.0), + ("deepseek-chat", "chat", 0.27, 2.0), + ("deepseek-reasoner", "chat", 0.55, 2.0), + ("gemini-2.0-flash", "chat", 0.15, 4.0), + ("qwen-plus", "chat", 0.4, 3.0), + ("text-embedding-3-small", "embedding", 0.02, 1.0), + ("text-embedding-3-large", "embedding", 0.13, 1.0), +]; + +fn builtin_model(name: &str) -> Option> { + let (_, _, ratio, completion) = BUILTIN_MODELS.iter().find(|(n, ..)| *n == name)?; + let model_type = LlmModelType::from_str( + BUILTIN_MODELS + .iter() + .find(|(n, ..)| *n == name) + .map(|(_, t, ..)| *t) + .unwrap_or("chat"), + ) + .ok()?; + Some(Arc::new(ModelInfo { + name: name.to_owned(), + model_type, + pricing: Pricing { + price_mode: LlmPriceMode::Ratio, + model_ratio: *ratio, + completion_ratio: *completion, + cache_ratio: None, + cache_write_ratio: None, + call_price: None, + }, + params: None, + })) +} + +/// The in-memory routing table snapshot. +#[derive(Debug, Default, Clone)] +pub struct ChannelCache { + pub routes: HashMap>>, + pub channels: HashMap>, + pub models: HashMap<(String, String), Arc>, +} + +impl ChannelCache { + /// Build a cache snapshot from channel + directory rows. + pub fn build(channels: Vec, models: Vec) -> Self { + let mut cache = ChannelCache::default(); + for row in channels { + let cached = cached_from_row(&row); + cache.channels.insert(cached.id, Arc::new(cached)); + } + for row in models { + if row.status != LlmModelStatus::Active { + continue; + } + let tenant = row + .tenant_id + .clone() + .unwrap_or_else(|| "default".to_owned()); + cache.models.insert( + (tenant, row.name.clone()), + Arc::new(ModelInfo { + name: row.name.clone(), + model_type: row.model_type, + pricing: Pricing { + price_mode: row.price_mode, + model_ratio: row.model_ratio, + completion_ratio: row.completion_ratio, + cache_ratio: row.cache_ratio, + cache_write_ratio: row.cache_write_ratio, + call_price: row.call_price, + }, + params: row.params.clone(), + }), + ); + } + cache.rebuild_routes(); + cache + } + + /// Directory lookup: DB row → built-in seed → `None` (design §5.4). + pub fn model_info(&self, tenant: &str, name: &str) -> Option> { + self.models + .get(&(tenant.to_owned(), name.to_owned())) + .cloned() + .or_else(|| builtin_model(name)) + } + + /// Candidates for a route key (already priority-DESC sorted). + pub fn candidates(&self, key: &RouteKey) -> Option<&Vec>> { + self.routes.get(key) + } + + /// Rebuild every route entry from the channel map (O(channels × models)); + /// invoked on write-path invalidation — channel counts are small. + pub fn rebuild_routes(&mut self) { + let mut routes: HashMap>> = HashMap::new(); + for channel in self.channels.values() { + if channel.status != LlmChannelStatus::Enabled { + continue; + } + for group in &channel.groups { + for model in &channel.models { + routes + .entry(RouteKey { + tenant: channel.tenant.clone(), + group: group.clone(), + model: model.clone(), + }) + .or_default() + .push(channel.clone()); + } + } + } + for candidates in routes.values_mut() { + candidates.sort_by_key(|c| std::cmp::Reverse(c.priority)); + } + self.routes = routes; + } +} + +impl ChannelCache { + /// Convert a DB row into a cached channel (public for incremental + /// reloads). + pub fn from_row(row: &LlmChannel) -> CachedChannel { + cached_from_row(row) + } +} + +/// Convert a DB row into a cached channel, decrypting keys leniently (§11.3: +/// undecryptable key → disabled + warn, never fails the row). +fn cached_from_row(row: &LlmChannel) -> CachedChannel { + let entries: Vec = parse_keys(row); + let keys = entries + .iter() + .map(|e| { + let plain = crate::llm::crypto::decrypt(&e.key); + let undecryptable = plain.is_none(); + if undecryptable { + tracing::warn!( + channel = %row.id, + "llm key undecryptable (aes key rotated/lost?), marked disabled" + ); + } + CachedKey { + plain, + status: if undecryptable { + LlmKeyStatus::Disabled + } else { + e.status + }, + max_concurrency: e.max_concurrency, + } + }) + .collect(); + CachedChannel { + id: row.id, + tenant: tenant_of(row), + provider: row.provider.clone(), + base_url: row.base_url.clone(), + keys, + key_mode: row.key_mode, + status: row.status, + models: split_csv(&row.models), + model_mapping: row.model_mapping.clone(), + priority: row.priority, + weight: row.weight, + groups: { + let groups = split_csv(&row.channel_groups); + if groups.is_empty() { + vec!["default".to_owned()] + } else { + groups + } + }, + auto_ban: row.auto_ban, + default_max_concurrency: row + .config + .as_ref() + .and_then(|c| c.get("max_concurrency")) + .and_then(|v| v.as_i64()) + .map(|v| v as i32), + test_model: row.test_model.clone(), + param_override: row.param_override.clone(), + header_override: row.header_override.clone(), + config: row.config.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::models::channel::{ + LlmChannel, LlmChannelStatus, LlmKeyEntry, LlmKeyMode, LlmKeyStatus, + }; + use crate::llm::models::model::{LlmModel, LlmModelStatus, LlmModelType, LlmPriceMode}; + + fn row(id: i64, models: &str, groups: &str, priority: i64) -> LlmChannel { + LlmChannel { + id: SnowflakeId(id), + tenant_id: Some("default".to_owned()), + name: format!("ch{id}"), + provider: "openai".to_owned(), + base_url: "https://x.test/v1".to_owned(), + api_keys: serde_json::to_value(vec![LlmKeyEntry { + key: "plain-key".to_owned(), + status: LlmKeyStatus::Active, + disabled_reason: None, + disabled_at: None, + max_concurrency: None, + }]) + .unwrap(), + key_mode: LlmKeyMode::Polling, + status: LlmChannelStatus::Enabled, + models: models.to_owned(), + model_mapping: None, + priority, + weight: 0, + channel_groups: groups.to_owned(), + auto_ban: true, + param_override: None, + header_override: None, + config: None, + used_quota: 0, + test_model: None, + test_time: None, + response_time: None, + created_at: crate::utils::tz::now_utc(), + updated_at: crate::utils::tz::now_utc(), + } + } + + fn model_row(name: &str, model_type: LlmModelType, status: LlmModelStatus) -> LlmModel { + LlmModel { + id: SnowflakeId(new_id_for_test()), + tenant_id: Some("default".to_owned()), + name: name.to_owned(), + model_type, + price_mode: LlmPriceMode::Ratio, + model_ratio: 2.0, + completion_ratio: 2.0, + cache_ratio: None, + cache_write_ratio: None, + call_price: None, + params: None, + status, + created_at: crate::utils::tz::now_utc(), + updated_at: crate::utils::tz::now_utc(), + } + } + + fn new_id_for_test() -> i64 { + static C: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(9000); + C.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } + + fn route(tenant: &str, group: &str, model: &str) -> RouteKey { + RouteKey { + tenant: tenant.to_owned(), + group: group.to_owned(), + model: model.to_owned(), + } + } + + #[test] + fn routes_fan_out_over_groups_and_models() { + let cache = ChannelCache::build(vec![row(1, "m1,m2", "default,vip", 0)], vec![]); + let ch = cache.channels.get(&SnowflakeId(1)).cloned().unwrap(); + assert_eq!(ch.groups, vec!["default", "vip"]); + assert_eq!(ch.models.len(), 2); + for (g, m) in [ + ("default", "m1"), + ("default", "m2"), + ("vip", "m1"), + ("vip", "m2"), + ] { + assert!( + cache.routes.contains_key(&route("default", g, m)), + "missing route {g}/{m}" + ); + } + } + + #[test] + fn disabled_channel_evicted_from_routes_kept_in_map() { + let mut r = row(2, "m1", "default", 0); + r.status = LlmChannelStatus::ManualDisabled; + let cache = ChannelCache::build(vec![r], vec![]); + assert!(cache.channels.contains_key(&SnowflakeId(2))); + assert!( + !cache + .routes + .contains_key(&route("default", "default", "m1")) + ); + } + + #[test] + fn candidates_sorted_by_priority_desc() { + let cache = ChannelCache::build( + vec![row(1, "m1", "default", 1), row(2, "m1", "default", 9)], + vec![], + ); + let cands = cache + .candidates(&route("default", "default", "m1")) + .expect("route exists"); + assert_eq!(cands[0].id, SnowflakeId(2)); + assert_eq!(cands[1].id, SnowflakeId(1)); + } + + #[test] + fn directory_lookup_order_db_then_builtin_then_none() { + let cache = ChannelCache::build( + vec![], + vec![ + model_row("db-registered", LlmModelType::Chat, LlmModelStatus::Active), + model_row("db-disabled", LlmModelType::Chat, LlmModelStatus::Disabled), + ], + ); + assert!(cache.model_info("default", "db-registered").is_some()); + // Disabled DB rows do not resolve (§5.4 global delisting)… + assert!(cache.model_info("default", "db-disabled").is_none()); + // …and never shadow the builtin seed for the same name. + let cache2 = ChannelCache::build( + vec![], + vec![model_row( + "gpt-4o", + LlmModelType::Chat, + LlmModelStatus::Disabled, + )], + ); + assert!( + cache2.model_info("default", "gpt-4o").is_some(), + "builtin fallback" + ); + assert!( + cache + .model_info("default", "no-such-model-anywhere") + .is_none() + ); + // Tenant isolation on directory rows. + assert!(cache.model_info("other-tenant", "db-registered").is_none()); + } + + #[test] + fn default_concurrency_from_channel_config() { + let mut r = row(3, "m1", "default", 0); + r.config = Some(serde_json::json!({ "max_concurrency": 7 })); + let ch = ChannelCache::from_row(&r); + assert_eq!(ch.default_max_concurrency, Some(7)); + let ch2 = ChannelCache::from_row(&row(4, "m1", "default", 0)); + assert_eq!(ch2.default_max_concurrency, None); + } + + #[test] + fn mapped_model_applies_after_selection() { + let mut r = row(5, "gpt-4", "default", 0); + r.model_mapping = Some(serde_json::json!({ "gpt-4": "gpt-4-0613" })); + let ch = ChannelCache::from_row(&r); + assert_eq!( + crate::llm::service::mapped_model(&ch, "gpt-4"), + "gpt-4-0613" + ); + assert_eq!(crate::llm::service::mapped_model(&ch, "other"), "other"); + let plain = ChannelCache::from_row(&row(6, "m", "default", 0)); + assert_eq!(crate::llm::service::mapped_model(&plain, "m"), "m"); + } + + #[test] + fn empty_groups_defaults_to_default_group() { + let mut r = row(7, "m1", "", 0); + r.channel_groups = "".to_owned(); + let ch = ChannelCache::from_row(&r); + assert_eq!(ch.groups, vec!["default".to_owned()]); + } +} diff --git a/crates/core/src/llm/crypto.rs b/crates/core/src/llm/crypto.rs new file mode 100644 index 00000000..0454b6a8 --- /dev/null +++ b/crates/core/src/llm/crypto.rs @@ -0,0 +1,189 @@ +//! Upstream key encryption at rest — AES-256-GCM with `enc:v1:` prefix +//! (design §11). +//! +//! - The prefix is a *format* version, not a key generation marker. +//! - Key ring: decryption tries `RAISFAST_AES_KEY` first, then +//! `RAISFAST_AES_KEY_OLD` (rotation window, §11.6). AES-GCM is +//! authenticated, so a wrong key always fails — try-both is unambiguous. +//! - Writing keys without `RAISFAST_AES_KEY` set is rejected (design §11.2 +//! tightening); reading stays lenient (`decrypt` returns `None`). + +use aes_gcm::aead::consts::U12; +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::engine::Engine; +use base64::engine::general_purpose::STANDARD as B64; + +use crate::errors::app_error::{AppError, AppResult}; + +const PREFIX: &str = "enc:v1:"; +const KEY_ENV: &str = "RAISFAST_AES_KEY"; +const KEY_OLD_ENV: &str = "RAISFAST_AES_KEY_OLD"; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; + +fn key_from_env(env: &str) -> Option<[u8; KEY_LEN]> { + let raw = std::env::var(env).ok()?; + let bytes = raw.as_bytes(); + if bytes.len() != KEY_LEN { + tracing::warn!( + env, + len = bytes.len(), + "{env} must be exactly 32 bytes, ignored" + ); + return None; + } + let mut key = [0u8; KEY_LEN]; + key.copy_from_slice(bytes); + Some(key) +} + +fn current_key() -> Option<[u8; KEY_LEN]> { + key_from_env(KEY_ENV) +} + +fn old_key() -> Option<[u8; KEY_LEN]> { + key_from_env(KEY_OLD_ENV) +} + +/// Whether at-rest encryption is configured (i.e. key writes are allowed). +pub fn is_enabled() -> bool { + current_key().is_some() +} + +/// Encrypt one upstream key with the current AES key. +pub fn encrypt(plain: &str) -> AppResult { + let key = current_key().ok_or_else(|| { + AppError::BadRequest( + "refusing to store plaintext upstream keys: set RAISFAST_AES_KEY (32 bytes)".to_owned(), + ) + })?; + seal(&key, plain) +} + +/// Environment-free seal (testable core). +fn seal(key: &[u8; KEY_LEN], plain: &str) -> AppResult { + let k = Key::::try_from(key.as_slice()) + .map_err(|_| AppError::Internal(anyhow::anyhow!("invalid aes key length")))?; + let cipher = Aes256Gcm::new(&k); + let mut nonce_bytes = [0u8; NONCE_LEN]; + getrandom::fill(&mut nonce_bytes) + .map_err(|e| AppError::Internal(anyhow::anyhow!("generate nonce: {e}")))?; + let nonce = Nonce::::try_from(nonce_bytes.as_slice()) + .map_err(|_| AppError::Internal(anyhow::anyhow!("invalid nonce length")))?; + let ct = cipher + .encrypt(&nonce, plain.as_bytes()) + .map_err(|_| AppError::Internal(anyhow::anyhow!("aes-gcm encrypt failed")))?; + let mut buf = Vec::with_capacity(NONCE_LEN + ct.len()); + buf.extend_from_slice(&nonce_bytes); + buf.extend_from_slice(&ct); + Ok(format!("{PREFIX}{}", B64.encode(buf))) +} + +/// Decrypt a stored value via the key ring (current key first, then OLD). +/// Returns `None` for undecryptable input — callers decide lenient handling. +pub fn decrypt(stored: &str) -> Option { + if !stored.starts_with(PREFIX) { + return Some(stored.to_owned()); + } + open_ring(stored, current_key().as_ref(), old_key().as_ref()) +} + +/// Environment-free open over a key ring (testable core): try each key in +/// order; authenticated encryption guarantees a wrong key always fails. +fn open_ring( + stored: &str, + current: Option<&[u8; KEY_LEN]>, + old: Option<&[u8; KEY_LEN]>, +) -> Option { + let payload = B64.decode(stored.trim_start_matches(PREFIX)).ok()?; + if payload.len() <= NONCE_LEN { + return None; + } + let (nonce, ct) = payload.split_at(NONCE_LEN); + let nonce = Nonce::::try_from(nonce).ok()?; + for key in [current, old].into_iter().flatten() { + let k = Key::::try_from(key.as_slice()).ok()?; + let cipher = Aes256Gcm::new(&k); + if let Ok(plain) = cipher.decrypt(&nonce, ct) { + return String::from_utf8(plain).ok(); + } + } + None +} + +/// Mask a stored (encrypted) key for admin DTOs: first 6 + last 4 of the +/// plaintext, or a placeholder when it cannot be decrypted (design §11.4). +pub fn mask_key(stored: &str) -> String { + match decrypt(stored) { + Some(plain) if plain.len() > 12 => { + format!("{}…{}", &plain[..6], &plain[plain.len() - 4..]) + } + Some(_) => "configured".to_owned(), + None => "not-configured".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(seed: u8) -> [u8; KEY_LEN] { + [seed; KEY_LEN] + } + + #[test] + fn seal_open_roundtrip() { + let k = key(1); + let sealed = seal(&k, "sk-upstream-secret").expect("seal"); + assert!(sealed.starts_with(PREFIX)); + assert_ne!(sealed, "sk-upstream-secret"); + let opened = open_ring(&sealed, Some(&k), None).expect("open"); + assert_eq!(opened, "sk-upstream-secret"); + } + + #[test] + fn wrong_key_fails_then_old_key_recovers() { + let old = key(2); + let sealed = seal(&old, "rotate-me").expect("seal"); + // Current key wrong → None; key ring falls back to OLD (§11.6). + assert_eq!(open_ring(&sealed, Some(&key(9)), None), None); + assert_eq!( + open_ring(&sealed, Some(&key(9)), Some(&old)).as_deref(), + Some("rotate-me") + ); + } + + #[test] + fn nonce_uniqueness_yields_distinct_ciphertexts() { + let k = key(3); + let a = seal(&k, "same").expect("seal"); + let b = seal(&k, "same").expect("seal"); + assert_ne!(a, b, "random nonce must vary ciphertexts"); + } + + #[test] + fn garbage_input_returns_none() { + assert_eq!(open_ring("enc:v1:!!!notbase64", Some(&key(1)), None), None); + assert_eq!( + open_ring(&format!("{PREFIX}AAAA"), Some(&key(1)), None), + None + ); + } + + #[test] + fn decrypt_passthrough_for_unprefixed() { + // Lenient: legacy plaintext values pass through unchanged. + assert_eq!(decrypt("legacy-plain"), Some("legacy-plain".to_owned())); + } + + #[test] + fn mask_hides_middle() { + let k = key(4); + let _sealed = seal(&k, "sk-abcdefghij").expect("seal"); + // mask_key reads env keys; without RAISFAST_AES_KEY set it reports + // not-configured for prefixed values — assert the non-prefixed shape. + let masked = super::mask_key("sk-abcdefghij"); + assert_eq!(masked, "sk-abc…ghij"); + } +} diff --git a/crates/core/src/llm/execute.rs b/crates/core/src/llm/execute.rs new file mode 100644 index 00000000..b60955f3 --- /dev/null +++ b/crates/core/src/llm/execute.rs @@ -0,0 +1,726 @@ +//! Internal-consumer execution kernel (design §10.1): selection → slot → +//! guarded provider → closure → failure classification → retry — consumers +//! carry zero retry code. +//! +//! - `SideEffectGuard` decides the retry boundary in the kernel: it proxies +//! `chat_stream` and flags the first `on_event` (callback-style streaming +//! fires the first side effect with the first token). Retrying after that +//! would duplicate partial output, so the kernel only retries while the +//! flag is clear. +//! - `ExecError` splits closure failures: `Upstream` (classified by status + +//! side-effect flag) vs `Local` (never retried). Defined in core so +//! `ProviderError` stays untouched (engine crate 不动). + +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use raisfast_agent::provider::ModelProvider; +use raisfast_agent::{ChatRequest, ChatResponse, ProviderError, StreamEvent}; + +use crate::errors::app_error::{AppError, AppResult}; +use crate::llm::models::log::{LogSource, NewLog}; +use crate::llm::service::{ + DEFAULT_RETRY_TIMES, INTERNAL_TIER, LlmRouter, ResolveCtx, ResolvedEndpoint, UpstreamFailure, +}; +use crate::types::snowflake_id::SnowflakeId; + +/// Closure error dichotomy (design §10.1). +pub enum ExecError { + /// Upstream failure — the kernel classifies (status ∧ side-effect flag). + Upstream(ProviderError), + /// Closure's own failure (DB write etc.) — never retried. + Local(AppError), +} + +impl From for ExecError { + fn from(e: ProviderError) -> Self { + ExecError::Upstream(e) + } +} + +impl From for ExecError { + fn from(e: AppError) -> Self { + ExecError::Local(e) + } +} + +fn provider_status(err: &ProviderError) -> Option { + match err { + ProviderError::Http { status, .. } => Some(*status), + _ => None, + } +} + +/// `ModelProvider` wrapper marking the side-effect boundary: the first +/// streamed event flips the flag; the kernel refuses to retry afterwards. +/// Created per `execute` call, never cached (flag is per-request). +pub struct SideEffectGuard { + inner: Arc, + effects: Arc, +} + +impl SideEffectGuard { + /// Wrap a bare provider. + pub fn new(inner: Arc) -> Self { + Self { + inner, + effects: Arc::new(AtomicBool::new(false)), + } + } + + /// Whether any side effect (first stream event) has been observed. + pub fn side_effects(&self) -> bool { + self.effects.load(Ordering::Acquire) + } +} + +#[async_trait::async_trait] +impl ModelProvider for SideEffectGuard { + fn name(&self) -> &str { + self.inner.name() + } + + async fn chat( + &self, + request: &ChatRequest<'_>, + model: &str, + ) -> Result { + self.inner.chat(request, model).await + } + + async fn embed(&self, texts: &[&str], model: &str) -> Result>, ProviderError> { + self.inner.embed(texts, model).await + } + + async fn chat_stream( + &self, + request: &ChatRequest<'_>, + model: &str, + on_event: &mut (dyn FnMut(StreamEvent) + Send), + ) -> Result { + let effects = self.effects.clone(); + self.inner + .chat_stream(request, model, &mut move |ev: StreamEvent| { + effects.store(true, Ordering::Release); + on_event(ev); + }) + .await + } +} + +/// Log entry passed to [`LlmRouter::log_call`] (keeps the arg list flat). +struct LogCall<'a> { + tenant: &'a str, + source: LogSource, + channel_id: Option, + key_index: Option, + model: &'a str, + elapsed_ms: Option, + error: Option<&'a AppError>, +} + +impl LlmRouter { + /// Resolve an endpoint to a cached bare provider (design §10.2): v1 + /// internal consumption supports OpenAI-compatible providers only; + /// native anthropic/gemini channels relay externally but reject here + /// until their provider impls ship. The guard wraps per call; the bare provider + /// is cached by `(channel_id, key_index)`. + pub fn provider_for(&self, ep: &ResolvedEndpoint) -> AppResult> { + match ep.provider.as_str() { + "anthropic" | "gemini" => Err(AppError::BadRequest(format!( + "provider '{}' is not available for internal calls yet", + ep.provider + ))), + _ => { + let entry = self + .providers + .entry((ep.channel_id, ep.key_index)) + .or_insert_with(|| { + Arc::new(raisfast_agent::provider::openai::OpenAiCompatProvider::new( + ep.base_url.clone(), + Some(ep.api_key.clone()), + )) as Arc + }) + .clone(); + Ok(entry) + } + } + } + + /// Unified execution kernel (design §10.1). The closure receives the + /// guarded provider plus the resolved endpoint (model metadata rides + /// along); everything else — slot, queue, retry, failover, logging — is + /// kernel-internal. + pub async fn execute( + self: &Arc, + ctx: &ResolveCtx<'_>, + model: &str, + source: LogSource, + run: F, + ) -> AppResult + where + F: FnMut(Arc, ResolvedEndpoint) -> Fut, + Fut: Future>, + { + let cache = self.cache.read().expect("llm cache lock").clone(); + let info = cache + .model_info(ctx.tenant, model) + .ok_or_else(|| AppError::BadRequest(format!("unknown model: {model}")))?; + + let mut retry = crate::llm::service::RetryState::default(); + let deadline = Instant::now() + std::time::Duration::from_secs(INTERNAL_TIER.max_wait_secs); + let mut run = run; + let mut last_err: Option = None; + + for attempt in 0..=DEFAULT_RETRY_TIMES { + let (channel, key_index, _permit) = { + match self + .acquire_slot( + &crate::llm::service::SlotRequest { + cache: &cache, + ctx, + model, + tier: INTERNAL_TIER, + caller: None, + body_bytes: 0, + deadline, + }, + &mut retry, + ) + .await + { + Ok(triple) => triple, + Err(crate::llm::service::SlotError::NoRoute(err)) => return Err(err), + Err(crate::llm::service::SlotError::Rejected { + status, + message, + retry_after_secs, + }) => { + return Err(AppError::BadRequest(format!( + "llm busy (HTTP {status}{hint}): {message}", + hint = retry_after_secs + .map(|s| format!(", retry after {s}s")) + .unwrap_or_default() + ))); + } + } + }; + + let endpoint = ResolvedEndpoint { + channel_id: channel.id, + key_index, + base_url: channel.base_url.clone(), + api_key: channel.keys[key_index].plain.clone().unwrap_or_default(), + provider: channel.provider.clone(), + upstream_model: crate::llm::service::mapped_model(&channel, model), + model: info.clone(), + param_override: channel.param_override.clone(), + header_override: channel.header_override.clone(), + }; + let provider = match self.provider_for(&endpoint) { + Ok(p) => p, + Err(err) => { + self.log_call(LogCall { + tenant: ctx.tenant, + source, + channel_id: Some(channel.id), + key_index: Some(key_index), + model, + elapsed_ms: None, + error: Some(&err), + }) + .await; + return Err(err); + } + }; + let guard = Arc::new(SideEffectGuard::new(provider)); + let started = Instant::now(); + + let outcome = run(guard.clone() as Arc, endpoint).await; + let elapsed = started.elapsed(); + let _ = attempt; + + match outcome { + Ok(value) => { + self.report_success(channel.id, key_index); + self.record_latency(ctx.tenant, model, elapsed.as_secs_f64()); + self.log_call(LogCall { + tenant: ctx.tenant, + source, + channel_id: Some(channel.id), + key_index: Some(key_index), + model, + elapsed_ms: Some(elapsed.as_millis() as i32), + error: None, + }) + .await; + return Ok(value); + } + Err(ExecError::Local(err)) => { + self.log_call(LogCall { + tenant: ctx.tenant, + source, + channel_id: Some(channel.id), + key_index: Some(key_index), + model, + elapsed_ms: Some(elapsed.as_millis() as i32), + error: Some(&err), + }) + .await; + return Err(err); + } + Err(ExecError::Upstream(pe)) => { + let failure = UpstreamFailure { + status: provider_status(&pe), + message: pe.to_string(), + retry_after: None, + }; + self.report_failure(channel.id, key_index, &failure); + let retryable = Self::upstream_retryable(&pe) && !guard.side_effects(); + self.log_call(LogCall { + tenant: ctx.tenant, + source, + channel_id: Some(channel.id), + key_index: Some(key_index), + model, + elapsed_ms: Some(elapsed.as_millis() as i32), + error: Some(&AppError::Internal(anyhow::anyhow!(pe.to_string()))), + }) + .await; + if retryable && attempt < DEFAULT_RETRY_TIMES { + last_err = Some(AppError::Internal(anyhow::anyhow!(pe.to_string()))); + continue; + } + return match Self::upstream_to_app_error(&pe) { + Err(err) => Err(err), + Ok(()) => unreachable!("upstream_to_app_error always errors"), + }; + } + } + } + Err(last_err.unwrap_or_else(|| { + AppError::Internal(anyhow::anyhow!("llm execute exhausted retries")) + })) + } + + fn upstream_retryable(pe: &ProviderError) -> bool { + Self::upstream_retryable_impl(pe) + } + + /// §7.3 retry conditions: 429/408/5xx/transport retry; 4xx never. + /// (split out for unit testing) + fn upstream_retryable_impl(pe: &ProviderError) -> bool { + match pe { + ProviderError::Http { status, .. } => { + matches!(status, 429 | 408 | 500..=599) + } + ProviderError::Transport(_) => true, + ProviderError::Parse(_) => false, + ProviderError::Config(_) => false, + } + } + + fn upstream_to_app_error(pe: &ProviderError) -> AppResult<()> { + Err(match pe { + ProviderError::Http { status, body } => { + AppError::BadRequest(format!("upstream {status}: {body}")) + } + other => AppError::Internal(anyhow::anyhow!(other.to_string())), + }) + } + + async fn log_call(&self, entry: LogCall<'_>) { + let Some(pool) = self.pool.as_ref() else { + return; + }; + let log = NewLog { + tenant_id: Some(entry.tenant.to_owned()), + source: entry.source, + channel_id: entry.channel_id, + key_index: entry.key_index.map(|i| i as i32), + model_name: entry.model.to_owned(), + elapsed_ms: entry.elapsed_ms, + error_message: entry.error.map(|e| e.to_string()), + ..NewLog::default() + }; + if let Err(err) = crate::llm::models::log::insert_log(pool, log).await { + tracing::warn!(%err, "llm log insert failed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::cache::ChannelCache; + use crate::llm::models::channel::{ + LlmChannel, LlmChannelStatus, LlmKeyEntry, LlmKeyMode, LlmKeyStatus, + }; + use crate::llm::service::ResolveCtx; + use raisfast_agent::messages::{ChatMessage, ChatRole}; + + /// Scripted provider: pops queued outcomes per call. + struct MockProvider { + script: std::sync::Mutex>, + } + + enum MockStep { + Ok, + StreamThenBreak { events_before_break: usize }, + Http(u16), + Transport, + } + + impl Clone for MockStep { + fn clone(&self) -> Self { + match self { + MockStep::Ok => MockStep::Ok, + MockStep::StreamThenBreak { + events_before_break, + } => MockStep::StreamThenBreak { + events_before_break: *events_before_break, + }, + MockStep::Http(s) => MockStep::Http(*s), + MockStep::Transport => MockStep::Transport, + } + } + } + + #[async_trait::async_trait] + impl ModelProvider for MockProvider { + fn name(&self) -> &str { + "mock" + } + + async fn chat( + &self, + _request: &ChatRequest<'_>, + _model: &str, + ) -> Result { + let mut script = self.script.lock().expect("script"); + match script.remove(0) { + MockStep::Ok => Ok(ChatResponse::text_only("done")), + MockStep::StreamThenBreak { .. } => Ok(ChatResponse::text_only("done")), + MockStep::Http(status) => Err(ProviderError::Http { + status, + body: "boom".to_owned(), + }), + MockStep::Transport => Err(ProviderError::Transport("net down".to_owned())), + } + } + + async fn chat_stream( + &self, + _request: &ChatRequest<'_>, + _model: &str, + on_event: &mut (dyn FnMut(StreamEvent) + Send), + ) -> Result { + let step = { + let mut script = self.script.lock().expect("script"); + script.remove(0) + }; + match step { + MockStep::StreamThenBreak { + events_before_break, + } => { + for _ in 0..events_before_break { + on_event(StreamEvent::TextDelta { + delta: "x".to_owned(), + }); + } + Err(ProviderError::Transport("stream broke mid-way".to_owned())) + } + MockStep::Ok => { + on_event(StreamEvent::TextDelta { + delta: "full".to_owned(), + }); + on_event(StreamEvent::Final); + Ok(ChatResponse::text_only("full")) + } + other => { + // Non-stream steps reuse chat semantics. + let _ = on_event; + match other { + MockStep::Http(status) => Err(ProviderError::Http { + status, + body: "boom".to_owned(), + }), + MockStep::Transport => Err(ProviderError::Transport("net down".to_owned())), + _ => unreachable!(), + } + } + } + } + } + + fn channel_row(id: i64) -> LlmChannel { + LlmChannel { + id: SnowflakeId(id), + tenant_id: Some("default".to_owned()), + name: format!("ch{id}"), + provider: "openai".to_owned(), + base_url: "https://mock.test/v1".to_owned(), + api_keys: serde_json::to_value(vec![LlmKeyEntry { + key: "plain".to_owned(), + status: LlmKeyStatus::Active, + disabled_reason: None, + disabled_at: None, + max_concurrency: None, + }]) + .unwrap(), + key_mode: LlmKeyMode::Polling, + status: LlmChannelStatus::Enabled, + models: "mock-model".to_owned(), + model_mapping: None, + priority: 0, + weight: 0, + channel_groups: "default".to_owned(), + auto_ban: true, + param_override: None, + header_override: None, + config: None, + used_quota: 0, + test_model: None, + test_time: None, + response_time: None, + created_at: crate::utils::tz::now_utc(), + updated_at: crate::utils::tz::now_utc(), + } + } + + fn router_with_mock(steps: Vec) -> std::sync::Arc { + let row = channel_row(1); + let mut cache = ChannelCache::default(); + let cached = ChannelCache::from_row(&row); + cache + .channels + .insert(cached.id, std::sync::Arc::new(cached)); + cache.models.insert( + ("default".to_owned(), "mock-model".to_owned()), + std::sync::Arc::new(crate::llm::cache::ModelInfo { + name: "mock-model".to_owned(), + model_type: crate::llm::models::model::LlmModelType::Chat, + pricing: crate::llm::cache::Pricing { + price_mode: crate::llm::models::model::LlmPriceMode::Ratio, + model_ratio: 1.0, + completion_ratio: 1.0, + cache_ratio: None, + cache_write_ratio: None, + call_price: None, + }, + params: None, + }), + ); + cache.rebuild_routes(); + let router = crate::llm::service::LlmRouter::from_cache_for_test(cache); + // Seed the provider cache with the mock (provider_for hits cache + // first), so execute() never constructs OpenAiCompatProvider. + router.providers.insert( + (SnowflakeId(1), 0), + std::sync::Arc::new(MockProvider { + script: std::sync::Mutex::new(steps), + }) as std::sync::Arc, + ); + router + } + + fn ctx() -> ResolveCtx<'static> { + ResolveCtx { + tenant: "default", + group: None, + pin_channel: None, + } + } + + fn leaked_messages() -> &'static [ChatMessage] { + Box::leak(Box::new(vec![ChatMessage { + role: ChatRole::User, + content: Some("hi".to_owned()), + tool_calls: None, + tool_call_id: None, + }])) + } + + fn chat_req() -> ChatRequest<'static> { + ChatRequest { + messages: leaked_messages(), + tools: None, + temperature: None, + max_tokens: None, + stop: None, + } + } + + async fn run_chat( + router: &std::sync::Arc, + ) -> AppResult { + let _req = chat_req(); + router + .execute( + &ctx(), + "mock-model", + LogSource::Agent, + move |p, _ep| async move { + p.chat(&chat_req(), "mock-model") + .await + .map_err(ExecError::Upstream) + }, + ) + .await + } + + #[tokio::test] + async fn success_on_first_attempt() { + let router = router_with_mock(vec![MockStep::Ok]); + let out = run_chat(&router).await.expect("ok"); + assert_eq!(out.text.as_deref(), Some("done")); + } + + #[tokio::test] + async fn transient_500_retries_then_succeeds() { + let router = router_with_mock(vec![MockStep::Http(500), MockStep::Ok]); + let out = run_chat(&router).await.expect("recovered"); + assert_eq!(out.text.as_deref(), Some("done")); + } + + #[tokio::test] + async fn transport_error_retries() { + let router = router_with_mock(vec![MockStep::Transport, MockStep::Ok]); + let out = run_chat(&router).await.expect("recovered"); + assert_eq!(out.text.as_deref(), Some("done")); + } + + #[tokio::test] + async fn client_4xx_never_retries() { + let router = router_with_mock(vec![MockStep::Http(400)]); + let err = run_chat(&router).await.expect_err("4xx is fatal"); + // 4xx maps to BadRequest (§7.3). + assert!(matches!(err, AppError::BadRequest(_))); + } + + #[tokio::test] + async fn exhaustion_after_retries_errors_out() { + let router = router_with_mock(vec![ + MockStep::Http(500), + MockStep::Http(500), + MockStep::Http(500), + ]); + assert!(run_chat(&router).await.is_err()); + } + + #[tokio::test] + async fn no_retry_after_first_stream_event() { + // Stream emits one event then breaks: side effects started → the + // kernel must NOT retry (duplicate output risk, design §10.1). + let router = router_with_mock(vec![ + MockStep::StreamThenBreak { + events_before_break: 1, + }, + MockStep::Ok, // would succeed if (wrongly) retried + ]); + let _req = chat_req(); + let err = router + .execute( + &ctx(), + "mock-model", + LogSource::Agent, + move |p, _ep| async move { + p.chat_stream(&chat_req(), "mock-model", &mut |_ev| {}) + .await + .map_err(ExecError::Upstream) + }, + ) + .await + .expect_err("must not retry after side effects"); + assert!(!matches!(err, AppError::BadRequest(_))); + } + + #[tokio::test] + async fn retry_allowed_when_stream_breaks_before_first_event() { + let router = router_with_mock(vec![ + MockStep::StreamThenBreak { + events_before_break: 0, + }, + MockStep::Ok, + ]); + let _req = chat_req(); + let out = router + .execute( + &ctx(), + "mock-model", + LogSource::Agent, + move |p, _ep| async move { + p.chat_stream(&chat_req(), "mock-model", &mut |_ev| {}) + .await + .map_err(ExecError::Upstream) + }, + ) + .await + .expect("pre-first-event failure is retryable"); + assert_eq!(out.text.as_deref(), Some("full")); + } + + #[tokio::test] + async fn local_error_never_retries() { + let router = router_with_mock(vec![MockStep::Ok, MockStep::Ok]); + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = attempts.clone(); + let err = router + .execute(&ctx(), "mock-model", LogSource::Agent, move |_p, _ep| { + let counter = counter.clone(); + async move { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Err::(ExecError::Local(AppError::BadRequest( + "db write failed".to_owned(), + ))) + } + }) + .await + .expect_err("local errors surface immediately"); + assert!(matches!(err, AppError::BadRequest(_))); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::Relaxed), + 1, + "Local must short-circuit all retries" + ); + } + + #[tokio::test] + async fn side_effect_guard_flag_semantics() { + let guard = SideEffectGuard::new(std::sync::Arc::new(MockProvider { + script: std::sync::Mutex::new(vec![MockStep::Ok]), + })); + assert!(!guard.side_effects()); + let _req = chat_req(); + let _ = guard.chat_stream(&chat_req(), "m", &mut |_| {}).await; + assert!( + guard.side_effects(), + "any stream event flips the side-effect flag" + ); + } + + #[test] + fn upstream_retryable_classification() { + let http = |s: u16| ProviderError::Http { + status: s, + body: String::new(), + }; + assert!(LlmRouter::upstream_retryable_impl(&http(429))); + assert!(LlmRouter::upstream_retryable_impl(&http(408))); + assert!(LlmRouter::upstream_retryable_impl(&http(500))); + assert!(LlmRouter::upstream_retryable_impl(&http(503))); + assert!(!LlmRouter::upstream_retryable_impl(&http(400))); + assert!(!LlmRouter::upstream_retryable_impl(&http(401))); + assert!(LlmRouter::upstream_retryable_impl( + &ProviderError::Transport("t".to_owned()) + )); + assert!(!LlmRouter::upstream_retryable_impl(&ProviderError::Parse( + "p".to_owned() + ))); + assert!(!LlmRouter::upstream_retryable_impl(&ProviderError::Config( + "c".to_owned() + ))); + } +} diff --git a/crates/core/src/llm/handler.rs b/crates/core/src/llm/handler.rs new file mode 100644 index 00000000..5b5c2f48 --- /dev/null +++ b/crates/core/src/llm/handler.rs @@ -0,0 +1,1339 @@ +//! Admin handlers for the llm foundation (design §12): channel CRUD, the +//! write-only key-pool sub-resource, model-directory CRUD and the provider +//! preset registry. All routes are admin-scoped via `reg_route!`. + +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::AppState; +use crate::errors::app_error::{AppError, AppResult}; +use crate::errors::response::ApiResponse; +use crate::llm::crypto; +use crate::llm::models::channel::{self, ChannelChanges, LlmKeyEntry, LlmKeyStatus, NewChannel}; +use crate::llm::models::model::{self, ModelChanges}; +use crate::llm::service::LlmRouter; +use crate::middleware::auth::AuthUser; +use crate::types::snowflake_id::{SnowflakeId, parse_id}; + +use std::str::FromStr as _; + +/// Register routes. Paths are prefixed `/api/v1` by `reg_route!`. +pub fn routes( + registry: &mut crate::server::RouteRegistry, + config: &crate::config::app::AppConfig, +) -> axum::Router { + let restful = config.api_restful; + let r = axum::Router::new(); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/providers", + get, + list_providers, + "system", + "admin/llm/providers", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels", + get, + list_channels, + "system", + "admin/llm/channels", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels", + post, + create_channel, + "system", + "admin/llm/channels", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}", + get, + get_channel, + "system", + "admin/llm/channels", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}", + put, + update_channel, + "system", + "admin/llm/channels", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}", + delete, + delete_channel, + "system", + "admin/llm/channels", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}/keys", + put, + replace_keys, + "system", + "admin/llm/channels/keys", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}/keys/{index}/enable", + post, + enable_key, + "system", + "admin/llm/channels/keys", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}/keys/{index}/disable", + post, + disable_key, + "system", + "admin/llm/channels/keys", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}/enable", + post, + enable_channel, + "system", + "admin/llm/channels/status", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}/disable", + post, + disable_channel, + "system", + "admin/llm/channels/status", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/models", + get, + list_models, + "system", + "admin/llm/models", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/models", + post, + create_model, + "system", + "admin/llm/models", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/models/{id}", + get, + get_model, + "system", + "admin/llm/models", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/models/{id}", + put, + update_model, + "system", + "admin/llm/models", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/logs", + get, + list_logs, + "system", + "admin/llm/logs", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/admin/llm/channels/{id}/test", + post, + test_channel, + "system", + "admin/llm/channels/test", + "admin" + ); + let r = reg_route!( + r, + registry, + restful, + "/llm/tokens", + get, + list_own_tokens, + "system", + "llm/tokens", + "authed" + ); + let r = reg_route!( + r, + registry, + restful, + "/llm/tokens", + post, + create_own_token, + "system", + "llm/tokens", + "authed" + ); + let r = reg_route!( + r, + registry, + restful, + "/llm/tokens/{id}", + put, + update_own_token, + "system", + "llm/tokens", + "authed" + ); + let r = reg_route!( + r, + registry, + restful, + "/llm/tokens/{id}", + delete, + delete_own_token, + "system", + "llm/tokens", + "authed" + ); + reg_route!( + r, + registry, + restful, + "/admin/llm/models/{id}", + delete, + delete_model, + "system", + "admin/llm/models", + "admin" + ) +} + +// ---------- DTOs ---------- + +/// Key input for create / pool replacement (plaintext in, encrypted at rest). +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct KeyInput { + pub key: String, + #[serde(default)] + pub max_concurrency: Option, +} + +/// Masked key view — plaintext never leaves the service (§11.4). +#[derive(Debug, Serialize)] +pub struct KeyView { + pub index: usize, + pub status: String, + pub disabled_reason: Option, + pub disabled_at: Option, + pub max_concurrency: Option, + pub masked_key: String, +} + +/// Channel response (no plaintext keys). +#[derive(Debug, Serialize)] +pub struct ChannelResponse { + pub id: SnowflakeId, + pub tenant_id: Option, + pub name: String, + pub provider: String, + pub base_url: String, + pub key_mode: String, + pub status: String, + pub models: String, + pub model_mapping: Option, + pub priority: i64, + pub weight: i32, + pub groups: String, + pub auto_ban: bool, + pub param_override: Option, + pub header_override: Option, + pub config: Option, + pub used_quota: i64, + pub test_model: Option, + pub response_time: Option, + pub keys: Vec, + pub created_at: String, + pub updated_at: String, +} + +impl ChannelResponse { + fn from_row(row: &channel::LlmChannel) -> Self { + let entries = channel::parse_keys(row); + Self { + id: row.id, + tenant_id: row.tenant_id.clone(), + name: row.name.clone(), + provider: row.provider.clone(), + base_url: row.base_url.clone(), + key_mode: row.key_mode.as_str().to_owned(), + status: row.status.as_str().to_owned(), + models: row.models.clone(), + model_mapping: row.model_mapping.clone(), + priority: row.priority, + weight: row.weight, + groups: row.channel_groups.clone(), + auto_ban: row.auto_ban, + param_override: row.param_override.clone(), + header_override: row.header_override.clone(), + config: row.config.clone(), + used_quota: row.used_quota, + test_model: row.test_model.clone(), + response_time: row.response_time, + keys: entries + .iter() + .enumerate() + .map(|(index, e)| KeyView { + index, + status: e.status.as_str().to_owned(), + disabled_reason: e.disabled_reason.clone(), + disabled_at: e.disabled_at.clone(), + max_concurrency: e.max_concurrency, + masked_key: crypto::mask_key(&e.key), + }) + .collect(), + created_at: row.created_at.to_rfc3339(), + updated_at: row.updated_at.to_rfc3339(), + } + } +} + +/// Create-channel payload (`initial_keys` required — at least one key). +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateChannelReq { + pub name: String, + #[serde(default = "default_provider")] + pub provider: String, + pub base_url: String, + #[serde(default)] + pub key_mode: Option, + pub models: String, + #[serde(default)] + pub model_mapping: Option, + #[serde(default)] + pub priority: i64, + #[serde(default)] + pub weight: i32, + #[serde(default = "default_groups")] + pub groups: String, + #[serde(default = "default_true_fn")] + pub auto_ban: bool, + #[serde(default)] + pub param_override: Option, + #[serde(default)] + pub header_override: Option, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub test_model: Option, + pub initial_keys: Vec, +} + +fn default_provider() -> String { + "generic".to_owned() +} + +fn default_groups() -> String { + "default".to_owned() +} + +fn default_true_fn() -> bool { + true +} + +/// Update-channel payload — keys are ignored here (write-only sub-resource, +/// §11.5) and logged if present. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct UpdateChannelReq { + pub name: String, + pub provider: String, + pub base_url: String, + #[serde(default)] + pub key_mode: Option, + pub models: String, + #[serde(default)] + pub model_mapping: Option, + #[serde(default)] + pub priority: i64, + #[serde(default)] + pub weight: i32, + #[serde(default = "default_groups")] + pub groups: String, + #[serde(default = "default_true_fn")] + pub auto_ban: bool, + #[serde(default)] + pub param_override: Option, + #[serde(default)] + pub header_override: Option, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub test_model: Option, + #[serde(default)] + pub keys: Option, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ReplaceKeysReq { + pub keys: Vec, +} + +fn parse_key_mode(raw: &Option) -> AppResult { + match raw.as_deref() { + None => Ok(channel::LlmKeyMode::Polling), + Some("polling") => Ok(channel::LlmKeyMode::Polling), + Some("random") => Ok(channel::LlmKeyMode::Random), + Some(other) => Err(AppError::BadRequest(format!( + "invalid key_mode: {other} (polling|random)" + ))), + } +} + +fn validate_base_url(url: &str) -> AppResult<()> { + if url.starts_with("http://") || url.starts_with("https://") { + Ok(()) + } else { + Err(AppError::BadRequest( + "base_url must start with http:// or https://".to_owned(), + )) + } +} + +fn normalize_models(models: &str) -> String { + models + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect::>() + .join(",") +} + +fn encrypt_inputs(inputs: &[KeyInput]) -> AppResult> { + inputs + .iter() + .map(|k| { + if k.key.trim().is_empty() { + return Err(AppError::BadRequest("empty api key".to_owned())); + } + Ok(LlmKeyEntry { + key: crypto::encrypt(k.key.trim())?, + status: LlmKeyStatus::Active, + disabled_reason: None, + disabled_at: None, + max_concurrency: k.max_concurrency, + }) + }) + .collect() +} + +fn channel_not_found() -> AppError { + AppError::NotFound("llm_channel".to_owned()) +} + +async fn reload(router: &LlmRouter, id: SnowflakeId) { + if let Err(err) = router.reload_channel(id).await { + tracing::error!(%err, channel = %id, "reload llm channel after write failed"); + } +} + +// ---------- providers ---------- + +/// Provider preset registry for admin UI dropdowns. +#[utoipa::path(get, path = "/api/v1/admin/llm/providers", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Provider presets")) +)] +pub async fn list_providers( + auth: AuthUser, + State(_state): State, +) -> AppResult> { + auth.ensure_admin()?; + Ok(ApiResponse::success( + json!({ "providers": crate::llm::registry::registry() }), + )) +} + +// ---------- channels ---------- + +/// List channels of the tenant. +#[utoipa::path(get, path = "/api/v1/admin/llm/channels", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Channels (masked keys)")) +)] +pub async fn list_channels( + auth: AuthUser, + State(state): State, +) -> AppResult>> { + auth.ensure_admin()?; + let rows = channel::list_channels(&state.pool, auth.tenant_id()).await?; + Ok(ApiResponse::success( + rows.iter().map(ChannelResponse::from_row).collect(), + )) +} + +/// Create a channel with its initial key pool. +#[utoipa::path(post, path = "/api/v1/admin/llm/channels", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Channel created")) +)] +pub async fn create_channel( + auth: AuthUser, + State(state): State, + Json(body): Json, +) -> AppResult> { + auth.ensure_admin()?; + if body.name.trim().is_empty() { + return Err(AppError::BadRequest("channel name is required".to_owned())); + } + validate_base_url(&body.base_url)?; + let models = normalize_models(&body.models); + if models.is_empty() { + return Err(AppError::BadRequest( + "at least one model is required".to_owned(), + )); + } + if body.initial_keys.is_empty() { + return Err(AppError::BadRequest( + "at least one initial key is required".to_owned(), + )); + } + let entries = encrypt_inputs(&body.initial_keys)?; + let row = channel::create_channel( + &state.pool, + auth.tenant_id(), + NewChannel { + name: body.name.trim().to_owned(), + provider: body.provider, + base_url: body.base_url.trim_end_matches('/').to_owned(), + api_keys: channel::keys_value(&entries), + key_mode: parse_key_mode(&body.key_mode)?, + models, + model_mapping: body.model_mapping, + priority: body.priority, + weight: body.weight, + channel_groups: body.groups, + auto_ban: body.auto_ban, + param_override: body.param_override, + header_override: body.header_override, + config: body.config, + test_model: body.test_model, + }, + ) + .await?; + reload(&state.llm_router, row.id).await; + Ok(ApiResponse::success(ChannelResponse::from_row(&row))) +} + +/// Get one channel. +#[utoipa::path(get, path = "/api/v1/admin/llm/channels/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path, description = "Channel ID")), + responses((status = 200, description = "Channel detail")) +)] +pub async fn get_channel( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + let row = channel::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(channel_not_found)?; + Ok(ApiResponse::success(ChannelResponse::from_row(&row))) +} + +/// Update editable channel columns (keys ignored — use the keys sub-resource). +#[utoipa::path(put, path = "/api/v1/admin/llm/channels/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path, description = "Channel ID")), + responses((status = 200, description = "Channel updated")) +)] +pub async fn update_channel( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + if body.keys.is_some() { + tracing::warn!(channel = %id, "ignoring keys in channel update; use PUT /keys"); + } + validate_base_url(&body.base_url)?; + let models = normalize_models(&body.models); + if models.is_empty() { + return Err(AppError::BadRequest( + "at least one model is required".to_owned(), + )); + } + channel::update_channel( + &state.pool, + auth.tenant_id(), + id, + ChannelChanges { + name: body.name.trim().to_owned(), + provider: body.provider, + base_url: body.base_url.trim_end_matches('/').to_owned(), + key_mode: parse_key_mode(&body.key_mode)?, + models, + model_mapping: body.model_mapping, + priority: body.priority, + weight: body.weight, + channel_groups: body.groups, + auto_ban: body.auto_ban, + param_override: body.param_override, + header_override: body.header_override, + config: body.config, + test_model: body.test_model, + }, + ) + .await?; + let row = channel::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(channel_not_found)?; + reload(&state.llm_router, id).await; + Ok(ApiResponse::success(ChannelResponse::from_row(&row))) +} + +/// Delete a channel. +#[utoipa::path(delete, path = "/api/v1/admin/llm/channels/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path, description = "Channel ID")), + responses((status = 200, description = "Channel deleted")) +)] +pub async fn delete_channel( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + channel::delete_channel(&state.pool, id, auth.tenant_id()).await?; + reload(&state.llm_router, id).await; + Ok(ApiResponse::success(())) +} + +/// Replace the whole key pool (write-only credential sub-resource, §11.5). +#[utoipa::path(put, path = "/api/v1/admin/llm/channels/{id}/keys", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path, description = "Channel ID")), + responses((status = 200, description = "Key pool replaced")) +)] +pub async fn replace_keys( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + if body.keys.is_empty() { + return Err(AppError::BadRequest( + "at least one key is required".to_owned(), + )); + } + let entries = encrypt_inputs(&body.keys)?; + let _lock = state.llm_router.persist_lock(id).await; + channel::replace_keys(&state.pool, auth.tenant_id(), id, &entries).await?; + let row = channel::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(channel_not_found)?; + reload(&state.llm_router, id).await; + Ok(ApiResponse::success(ChannelResponse::from_row(&row))) +} + +/// Manually re-enable one key (§6.3). +#[utoipa::path(post, path = "/api/v1/admin/llm/channels/{id}/keys/{index}/enable", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path), ("index" = u32, Path)), + responses((status = 200, description = "Key enabled")))] +pub async fn enable_key( + auth: AuthUser, + State(state): State, + Path((id, index)): Path<(String, String)>, +) -> AppResult> { + auth.ensure_admin()?; + let (id, index) = parse_key_path(&id, &index)?; + let _lock = state.llm_router.persist_lock(id).await; + channel::update_key_status( + &state.pool, + auth.tenant_id(), + id, + index, + LlmKeyStatus::Active, + Some("manual enable"), + ) + .await?; + if let Some(row) = channel::find_by_id(&state.pool, id, auth.tenant_id()).await? + && row.status == channel::LlmChannelStatus::AutoDisabled + && channel::parse_keys(&row) + .iter() + .any(|k| k.status == LlmKeyStatus::Active) + { + channel::update_status( + &state.pool, + auth.tenant_id(), + id, + channel::LlmChannelStatus::Enabled, + ) + .await?; + } + reload(&state.llm_router, id).await; + Ok(ApiResponse::success(())) +} + +/// Manually disable one key (symmetric counterpart of enable, §12). +#[utoipa::path(post, path = "/api/v1/admin/llm/channels/{id}/keys/{index}/disable", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path), ("index" = u32, Path)), + responses((status = 200, description = "Key disabled")))] +pub async fn disable_key( + auth: AuthUser, + State(state): State, + Path((id, index)): Path<(String, String)>, +) -> AppResult> { + auth.ensure_admin()?; + let (id, index) = parse_key_path(&id, &index)?; + let _lock = state.llm_router.persist_lock(id).await; + channel::update_key_status( + &state.pool, + auth.tenant_id(), + id, + index, + LlmKeyStatus::Disabled, + Some("manual disable"), + ) + .await?; + reload(&state.llm_router, id).await; + Ok(ApiResponse::success(())) +} + +/// Manually enable a channel (manual_disabled → enabled). +#[utoipa::path(post, path = "/api/v1/admin/llm/channels/{id}/enable", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Channel enabled")))] +pub async fn enable_channel( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + channel::update_status( + &state.pool, + auth.tenant_id(), + id, + channel::LlmChannelStatus::Enabled, + ) + .await?; + reload(&state.llm_router, id).await; + Ok(ApiResponse::success(())) +} + +/// Manually disable a channel (→ manual_disabled; never auto-recovered, §6.3). +#[utoipa::path(post, path = "/api/v1/admin/llm/channels/{id}/disable", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Channel disabled")))] +pub async fn disable_channel( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + channel::update_status( + &state.pool, + auth.tenant_id(), + id, + channel::LlmChannelStatus::ManualDisabled, + ) + .await?; + reload(&state.llm_router, id).await; + Ok(ApiResponse::success(())) +} + +fn parse_key_path(id: &str, index: &str) -> AppResult<(SnowflakeId, usize)> { + let id = parse_id(id)?; + let index: usize = index + .parse() + .map_err(|_| AppError::BadRequest("invalid key index".to_owned()))?; + Ok((id, index)) +} + +// ---------- model directory ---------- + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ModelReq { + pub name: String, + #[serde(default = "default_model_type")] + pub model_type: String, + #[serde(default = "default_price_mode")] + pub price_mode: String, + #[serde(default = "default_ratio")] + pub model_ratio: f64, + #[serde(default = "default_ratio")] + pub completion_ratio: f64, + #[serde(default)] + pub cache_ratio: Option, + #[serde(default)] + pub cache_write_ratio: Option, + #[serde(default)] + pub call_price: Option, + #[serde(default)] + pub params: Option, + #[serde(default = "default_model_status")] + pub status: String, +} + +fn default_model_type() -> String { + "chat".to_owned() +} + +fn default_price_mode() -> String { + "ratio".to_owned() +} + +fn default_ratio() -> f64 { + 1.0 +} + +fn default_model_status() -> String { + "active".to_owned() +} + +impl ModelReq { + fn into_changes(self) -> AppResult { + use crate::llm::models::model::{LlmModelStatus, LlmModelType, LlmPriceMode}; + use std::str::FromStr; + let model_type = LlmModelType::from_str(&self.model_type).map_err(|_| { + AppError::BadRequest(format!("invalid model_type: {}", self.model_type)) + })?; + let price_mode = LlmPriceMode::from_str(&self.price_mode).map_err(|_| { + AppError::BadRequest(format!("invalid price_mode: {}", self.price_mode)) + })?; + let status = LlmModelStatus::from_str(&self.status) + .map_err(|_| AppError::BadRequest(format!("invalid status: {}", self.status)))?; + Ok(ModelChanges { + name: self.name.trim().to_owned(), + model_type, + price_mode, + model_ratio: self.model_ratio, + completion_ratio: self.completion_ratio, + cache_ratio: self.cache_ratio, + cache_write_ratio: self.cache_write_ratio, + call_price: self.call_price, + params: self.params, + status, + }) + } +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ListModelsQuery { + pub model_type: Option, +} + +/// List model-directory rows (optional `?model_type=` filter). +#[utoipa::path(get, path = "/api/v1/admin/llm/models", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Model directory")))] +pub async fn list_models( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> AppResult>> { + auth.ensure_admin()?; + let model_type = match q.model_type.as_deref() { + Some(t) => Some( + crate::llm::models::model::LlmModelType::from_str(t) + .map_err(|_| AppError::BadRequest(format!("invalid model_type: {t}")))?, + ), + None => None, + }; + let rows = model::list_models(&state.pool, auth.tenant_id(), model_type.as_ref()).await?; + Ok(ApiResponse::success(rows)) +} + +/// Create a model-directory row. +#[utoipa::path(post, path = "/api/v1/admin/llm/models", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Model created")))] +pub async fn create_model( + auth: AuthUser, + State(state): State, + Json(body): Json, +) -> AppResult> { + auth.ensure_admin()?; + if body.name.trim().is_empty() { + return Err(AppError::BadRequest("model name is required".to_owned())); + } + let row = model::create_model(&state.pool, auth.tenant_id(), body.into_changes()?).await?; + state.llm_router.invalidate_model_cache().await; + Ok(ApiResponse::success(row)) +} + +/// Get one model-directory row. +#[utoipa::path(get, path = "/api/v1/admin/llm/models/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Model detail")))] +pub async fn get_model( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + let row = model::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::NotFound("llm_model".to_owned()))?; + Ok(ApiResponse::success(row)) +} + +/// Update a model-directory row (pricing changes invalidate the cache, §7.1). +#[utoipa::path(put, path = "/api/v1/admin/llm/models/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Model updated")))] +pub async fn update_model( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + model::update_model(&state.pool, auth.tenant_id(), id, body.into_changes()?).await?; + state.llm_router.invalidate_model_cache().await; + let row = model::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::NotFound("llm_model".to_owned()))?; + Ok(ApiResponse::success(row)) +} + +/// Delete a model-directory row. +#[utoipa::path(delete, path = "/api/v1/admin/llm/models/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Model deleted")))] +pub async fn delete_model( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + model::delete_model(&state.pool, id, auth.tenant_id()).await?; + state.llm_router.invalidate_model_cache().await; + Ok(ApiResponse::success(())) +} + +// ---------- user self-service tokens (§12) ---------- + +/// Token view (no hash). +#[derive(Debug, Serialize)] +pub struct TokenResponse { + pub id: SnowflakeId, + pub name: String, + pub status: String, + pub remain_quota: i64, + pub used_quota: i64, + pub unlimited_quota: bool, + pub expired_at: Option, + pub allowed_models: Option, + pub created_at: String, +} + +impl TokenResponse { + fn from_row(row: &crate::llm::models::token::LlmToken) -> Self { + Self { + id: row.id, + name: row.name.clone(), + status: row.status.as_str().to_owned(), + remain_quota: row.remain_quota, + used_quota: row.used_quota, + unlimited_quota: row.unlimited_quota, + expired_at: row.expired_at.map(|t| t.to_rfc3339()), + allowed_models: row.allowed_models.clone(), + created_at: row.created_at.to_rfc3339(), + } + } +} + +/// Create-token payload (unlimited is admin-only and rejected here, §9.2). +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateTokenReq { + pub name: String, + #[serde(default)] + pub remain_quota: i64, + #[serde(default)] + pub unlimited_quota: bool, + #[serde(default)] + pub expired_at: Option, + #[serde(default)] + pub allowed_models: Option, + #[serde(default)] + pub allowed_ips: Option, +} + +/// Update-token payload. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct UpdateTokenReq { + pub name: String, + #[serde(default)] + pub remain_quota: Option, + #[serde(default)] + pub allowed_models: Option, + #[serde(default)] + pub allowed_ips: Option, + #[serde(default)] + pub expired_at: Option, + #[serde(default)] + pub enabled: Option, +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct ListLogsQuery { + pub page: Option, + pub page_size: Option, + pub channel_id: Option, + pub token_id: Option, + pub model_name: Option, +} + +/// List the caller's sk- tokens. +#[utoipa::path(get, path = "/api/v1/llm/tokens", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Own tokens")))] +pub async fn list_own_tokens( + auth: AuthUser, + State(state): State, +) -> AppResult>> { + let user = auth.ensure_snowflake_user_id()?; + let rows = crate::llm::models::token::list_by_user(&state.pool, auth.tenant_id(), user).await?; + Ok(ApiResponse::success( + rows.iter().map(TokenResponse::from_row).collect(), + )) +} + +/// Create a sk- token; the plaintext key appears exactly once in the response. +#[utoipa::path(post, path = "/api/v1/llm/tokens", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Token created (plaintext shown once)")) +)] +pub async fn create_own_token( + auth: AuthUser, + State(state): State, + Json(body): Json, +) -> AppResult> { + let user = auth.ensure_snowflake_user_id()?; + if body.unlimited_quota { + auth.ensure_admin()?; + } + if body.name.trim().is_empty() { + return Err(AppError::BadRequest("token name is required".to_owned())); + } + let plain = crate::llm::relay::generate_sk(); + let expired_at = crate::utils::tz::parse_rfc3339_opt(body.expired_at.as_deref()); + let row = crate::llm::models::token::create_token( + &state.pool, + auth.tenant_id(), + user, + body.name.trim(), + &crate::llm::relay::auth_hash(&plain), + body.remain_quota.max(0), + body.unlimited_quota, + expired_at, + body.allowed_models.as_deref().filter(|s| !s.is_empty()), + body.allowed_ips.as_deref().filter(|s| !s.is_empty()), + ) + .await?; + Ok(ApiResponse::success(serde_json::json!({ + "token": TokenResponse::from_row(&row), + "key": plain, + }))) +} + +/// Update an own token (ownership enforced). +#[utoipa::path(put, path = "/api/v1/llm/tokens/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Token updated")) +)] +pub async fn update_own_token( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> AppResult> { + let user = auth.ensure_snowflake_user_id()?; + let id = parse_id(&id)?; + let existing = crate::llm::models::token::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::NotFound("llm_token".to_owned()))?; + if existing.user_id != user { + return Err(AppError::ForbiddenOwnership); + } + use crate::llm::models::token::LlmTokenStatus; + let status = match body.enabled { + Some(true) => LlmTokenStatus::Enabled, + Some(false) => LlmTokenStatus::Disabled, + None => existing.status, + }; + let ph = crate::db::Driver::ph; + use crate::db::driver::DbDriver; + let now = crate::utils::tz::now_utc(); + let sql = format!( + "UPDATE llm_tokens SET name = {p1}, remain_quota = {p2}, allowed_models = {p3}, \ + allowed_ips = {p4}, expired_at = {p5}, status = {p6}, updated_at = {p7} WHERE id = {p8}", + p1 = ph(1), + p2 = ph(2), + p3 = ph(3), + p4 = ph(4), + p5 = ph(5), + p6 = ph(6), + p7 = ph(7), + p8 = ph(8) + ); + let expired_at = + crate::utils::tz::parse_rfc3339_opt(body.expired_at.as_deref()).or(existing.expired_at); + let result = sqlx::query(crate::db::safe_sql(&sql)) + .bind(body.name.trim()) + .bind(body.remain_quota.unwrap_or(existing.remain_quota).max(0)) + .bind(body.allowed_models.clone().filter(|s| !s.is_empty())) + .bind(body.allowed_ips.clone().filter(|s| !s.is_empty())) + .bind(expired_at) + .bind(status.as_str()) + .bind(now) + .bind(id) + .execute(&state.pool) + .await?; + AppError::expect_affected(&result, "llm_token")?; + Ok(ApiResponse::success(())) +} + +/// Delete an own token. +#[utoipa::path(delete, path = "/api/v1/llm/tokens/{id}", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Token deleted")) +)] +pub async fn delete_own_token( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + let user = auth.ensure_snowflake_user_id()?; + let id = parse_id(&id)?; + let existing = crate::llm::models::token::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::NotFound("llm_token".to_owned()))?; + if existing.user_id != user { + return Err(AppError::ForbiddenOwnership); + } + crate::llm::models::token::delete_token(&state.pool, id, auth.tenant_id()).await?; + Ok(ApiResponse::success(())) +} + +// ---------- admin logs (§12) ---------- + +/// Paged usage-log query with filters. +#[utoipa::path(get, path = "/api/v1/admin/llm/logs", tag = "llm", + security(("bearer_auth" = [])), + responses((status = 200, description = "Usage logs")) +)] +pub async fn list_logs( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> AppResult>> +{ + auth.ensure_admin()?; + let page = q.page.unwrap_or(1).max(1); + let page_size = q.page_size.unwrap_or(20).clamp(1, 100); + let filters = crate::llm::models::log::LogFilters { + channel_id: q.channel_id, + token_id: q.token_id, + model_name: q.model_name, + }; + let (items, total) = crate::llm::models::log::query_paged( + &state.pool, + auth.tenant_id(), + &filters, + page, + page_size, + ) + .await?; + Ok(ApiResponse::success( + crate::errors::response::PaginatedData { + items, + total, + page, + page_size, + }, + )) +} + +// ---------- channel connectivity test (§7.5 manual) ---------- + +/// Test one channel: pick its first active key, send a minimal chat request +/// against `test_model`, record response time. +#[utoipa::path(post, path = "/api/v1/admin/llm/channels/{id}/test", tag = "llm", + security(("bearer_auth" = [])), + params(("id" = String, Path)), + responses((status = 200, description = "Test result")) +)] +pub async fn test_channel( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + let id = parse_id(&id)?; + let row = channel::find_by_id(&state.pool, id, auth.tenant_id()) + .await? + .ok_or_else(channel_not_found)?; + let entries = channel::parse_keys(&row); + let key = entries + .iter() + .find(|e| e.status == LlmKeyStatus::Active) + .and_then(|e| crypto::decrypt(&e.key)) + .ok_or_else(|| AppError::BadRequest("channel has no active key".to_owned()))?; + let model = row + .test_model + .clone() + .or_else(|| { + row.models + .split(',') + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }) + .unwrap_or_default(); + if model.is_empty() { + return Err(AppError::BadRequest( + "no test_model and no models".to_owned(), + )); + } + let started = std::time::Instant::now(); + let client = crate::llm::relay::shared_client(); + let url = format!("{}/chat/completions", row.base_url.trim_end_matches('/')); + let body = serde_json::json!({ "model": model, "messages": [{"role":"user","content":"ping"}], "max_tokens": 1 }); + let resp = client + .post(&url) + .headers(crate::llm::relay::OpenaiHeaders::for_key( + &key, + row.header_override.as_ref(), + )) + .json(&body) + .send() + .await; + let elapsed_ms = started.elapsed().as_millis() as i32; + let (ok, detail) = match resp { + Ok(r) if r.status().is_success() => (true, format!("{} ok", r.status().as_u16())), + Ok(r) => ( + false, + format!( + "{} {}", + r.status().as_u16(), + r.text().await.unwrap_or_default() + ), + ), + Err(e) => (false, format!("transport: {e}")), + }; + let ph = crate::db::Driver::ph; + use crate::db::driver::DbDriver; + let now = crate::utils::tz::now_utc(); + let sql = format!( + "UPDATE llm_channels SET test_time = {p1}, response_time = {p2} WHERE id = {p3}", + p1 = ph(1), + p2 = ph(2), + p3 = ph(3) + ); + let _ = sqlx::query(crate::db::safe_sql(&sql)) + .bind(now) + .bind(elapsed_ms) + .bind(id) + .execute(&state.pool) + .await; + let log = crate::llm::models::log::NewLog { + tenant_id: auth.tenant_id().map(str::to_owned), + source: crate::llm::models::log::LogSource::Test, + channel_id: Some(id), + model_name: model.clone(), + elapsed_ms: Some(elapsed_ms), + error_message: if ok { None } else { Some(detail.clone()) }, + ..Default::default() + }; + if let Err(err) = crate::llm::models::log::insert_log(&state.pool, log).await { + tracing::warn!(%err, "llm test log failed"); + } + Ok(ApiResponse::success(serde_json::json!({ + "ok": ok, "elapsed_ms": elapsed_ms, "detail": detail, "model": model + }))) +} diff --git a/crates/core/src/llm/mod.rs b/crates/core/src/llm/mod.rs new file mode 100644 index 00000000..0e3c2e15 --- /dev/null +++ b/crates/core/src/llm/mod.rs @@ -0,0 +1,22 @@ +//! LLM foundation: upstream channel/key-pool routing core, model directory and +//! admin API. Design doc: `dev-docs/llm/design.md`; flow diagrams: +//! `dev-docs/llm/flow.md`. +//! +//! Layout follows the vertical-module convention of `agent/` and `kb/` +//! (design §13): this directory owns its handler/service/models, exposing only +//! three contact points to the rest of the crate — `lib.rs` (`pub mod` + +//! `AppState::llm_router`), `server.rs` (route merge behind +//! `builtins.llm_gateway`) and `migrations/` (four `llm_*` tables). + +pub mod cache; +pub mod crypto; +pub mod execute; +pub mod handler; +pub mod models; +pub mod queue; +pub mod registry; +pub mod relay; +pub mod service; + +#[cfg(test)] +mod tests_db; diff --git a/crates/core/src/llm/models/channel.rs b/crates/core/src/llm/models/channel.rs new file mode 100644 index 00000000..5470a98c --- /dev/null +++ b/crates/core/src/llm/models/channel.rs @@ -0,0 +1,315 @@ +//! `llm_channels` model — upstream channel with embedded key pool (design §5.1). + +use serde::{Deserialize, Serialize}; + +use crate::errors::app_error::{AppError, AppResult}; +use crate::types::snowflake_id::SnowflakeId; +use crate::utils::tz::{Timestamp, now_utc}; + +define_enum!( + LlmChannelStatus { + Enabled = "enabled", + ManualDisabled = "manual_disabled", + AutoDisabled = "auto_disabled", + } +); + +define_enum!( + LlmKeyMode { + Polling = "polling", + Random = "random", + } +); + +define_enum!( + LlmKeyStatus { + Active = "active", + Disabled = "disabled", + } +); + +/// One key inside the channel pool (`keys` JSONB array element, design §5.1). +/// `key` holds the AES ciphertext (`enc:v1:…`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LlmKeyEntry { + pub key: String, + pub status: LlmKeyStatus, + #[serde(default)] + pub disabled_reason: Option, + #[serde(default)] + pub disabled_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, +} + +/// Parse the `keys` JSONB column into entries (empty on malformed input). +pub fn parse_keys(channel: &LlmChannel) -> Vec { + serde_json::from_value(channel.api_keys.clone()).unwrap_or_default() +} + +/// Serialize entries back into the `keys` JSONB value. +pub fn keys_value(entries: &[LlmKeyEntry]) -> serde_json::Value { + serde_json::to_value(entries).unwrap_or(serde_json::Value::Array(vec![])) +} + +/// One upstream channel row. +#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] +pub struct LlmChannel { + pub id: SnowflakeId, + pub tenant_id: Option, + pub name: String, + pub provider: String, + pub base_url: String, + pub api_keys: serde_json::Value, + pub key_mode: LlmKeyMode, + pub status: LlmChannelStatus, + pub models: String, + pub model_mapping: Option, + pub priority: i64, + pub weight: i32, + pub channel_groups: String, + pub auto_ban: bool, + pub param_override: Option, + pub header_override: Option, + pub config: Option, + pub used_quota: i64, + pub test_model: Option, + pub test_time: Option, + pub response_time: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +/// Payload for creating a channel (keys arrive pre-encrypted). +#[derive(Debug)] +pub struct NewChannel { + pub name: String, + pub provider: String, + pub base_url: String, + pub api_keys: serde_json::Value, + pub key_mode: LlmKeyMode, + pub models: String, + pub model_mapping: Option, + pub priority: i64, + pub weight: i32, + pub channel_groups: String, + pub auto_ban: bool, + pub param_override: Option, + pub header_override: Option, + pub config: Option, + pub test_model: Option, +} + +/// Editable (non-key) columns for `update_channel`. +#[derive(Debug)] +pub struct ChannelChanges { + pub name: String, + pub provider: String, + pub base_url: String, + pub key_mode: LlmKeyMode, + pub models: String, + pub model_mapping: Option, + pub priority: i64, + pub weight: i32, + pub channel_groups: String, + pub auto_ban: bool, + pub param_override: Option, + pub header_override: Option, + pub config: Option, + pub test_model: Option, +} + +/// Create a channel and return it. +pub async fn create_channel( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + n: NewChannel, +) -> AppResult { + let id = crate::utils::id::new_snowflake_id(); + let now = now_utc(); + raisfast_derive::crud_insert!( + pool, + "llm_channels", + [ + "id" => id, + "name" => n.name, + "provider" => n.provider, + "base_url" => n.base_url, + "api_keys" => n.api_keys, + "key_mode" => n.key_mode.as_str(), + "status" => LlmChannelStatus::Enabled.as_str(), + "models" => n.models, + "model_mapping" => n.model_mapping, + "priority" => n.priority, + "weight" => n.weight, + "channel_groups" => n.channel_groups, + "auto_ban" => n.auto_ban, + "param_override" => n.param_override, + "header_override" => n.header_override, + "config" => n.config, + "test_model" => n.test_model, + "created_at" => &now, + "updated_at" => &now + ], + tenant: tenant_id + )?; + find_by_id(pool, id, tenant_id) + .await? + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("llm_channel insert vanished"))) +} + +/// Find a channel by id (tenant-scoped). +pub async fn find_by_id( + pool: &crate::db::Pool, + id: SnowflakeId, + tenant_id: Option<&str>, +) -> AppResult> { + let result: Option = raisfast_derive::crud_find!( + pool, + "llm_channels", + LlmChannel, + where: ("id", id), + tenant: tenant_id + )?; + Ok(result) +} + +/// List channels of a tenant, newest first. +pub async fn list_channels( + pool: &crate::db::Pool, + tenant_id: Option<&str>, +) -> AppResult> { + let result: Vec = raisfast_derive::crud_list!( + pool, + "llm_channels", + LlmChannel, + order_by: "id DESC", + tenant: tenant_id + )?; + Ok(result) +} + +/// Update editable (non-key) columns. +pub async fn update_channel( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + id: SnowflakeId, + c: ChannelChanges, +) -> AppResult<()> { + let now = now_utc(); + let result = raisfast_derive::crud_update!( + pool, + "llm_channels", + bind: [ + "name" => c.name, + "provider" => c.provider, + "base_url" => c.base_url, + "key_mode" => c.key_mode.as_str(), + "models" => c.models, + "model_mapping" => c.model_mapping, + "priority" => c.priority, + "weight" => c.weight, + "channel_groups" => c.channel_groups, + "auto_ban" => c.auto_ban, + "param_override" => c.param_override, + "header_override" => c.header_override, + "config" => c.config, + "test_model" => c.test_model, + "updated_at" => &now + ], + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_channel") +} + +/// Delete a channel by id (tenant-scoped). +pub async fn delete_channel( + pool: &crate::db::Pool, + id: SnowflakeId, + tenant_id: Option<&str>, +) -> AppResult<()> { + let result = raisfast_derive::crud_delete!( + pool, + "llm_channels", + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_channel") +} + +/// Replace the whole key pool (write-only credential sub-resource, §11.5). +pub async fn replace_keys( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + id: SnowflakeId, + entries: &[LlmKeyEntry], +) -> AppResult<()> { + let now = now_utc(); + let result = raisfast_derive::crud_update!( + pool, + "llm_channels", + bind: ["api_keys" => keys_value(entries), "updated_at" => &now], + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_channel") +} + +/// Set one key's status inside the pool (read-modify-write in a transaction; +/// per-channel write serialization is enforced by the service layer, §6.2). +pub async fn update_key_status( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + id: SnowflakeId, + key_index: usize, + status: LlmKeyStatus, + reason: Option<&str>, +) -> AppResult> { + crate::in_transaction!(pool, tx, { + let row: LlmChannel = raisfast_derive::crud_find_one!( + &mut *tx, + "llm_channels", + LlmChannel, + where: ("id", id), + tenant: tenant_id + )?; + let mut entries = parse_keys(&row); + if key_index >= entries.len() { + return Err(AppError::NotFound("llm_channel_key".to_owned())); + } + entries[key_index].status = status; + entries[key_index].disabled_reason = reason + .map(|r| r.to_owned()) + .or(entries[key_index].disabled_reason.clone()); + entries[key_index].disabled_at = Some(crate::utils::tz::now_str()); + let now = now_utc(); + let result = raisfast_derive::crud_update!( + &mut *tx, + "llm_channels", + bind: ["api_keys" => keys_value(&entries), "updated_at" => &now], + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_channel")?; + Ok::<_, AppError>(entries) + }) +} + +/// Set the channel-level status (manual enable/disable, auto ban). +pub async fn update_status( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + id: SnowflakeId, + status: LlmChannelStatus, +) -> AppResult<()> { + let now = now_utc(); + let result = raisfast_derive::crud_update!( + pool, + "llm_channels", + bind: ["status" => status.as_str(), "updated_at" => &now], + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_channel") +} diff --git a/crates/core/src/llm/models/log.rs b/crates/core/src/llm/models/log.rs new file mode 100644 index 00000000..a97fc297 --- /dev/null +++ b/crates/core/src/llm/models/log.rs @@ -0,0 +1,161 @@ +//! `llm_logs` model — usage/billing log rows (design §5.3). Write path is +//! shared by relay (P3) and internal `execute` (P2); P1 ships row + insert. + +use serde::{Deserialize, Serialize}; + +use crate::errors::app_error::AppResult; +use crate::types::snowflake_id::SnowflakeId; +use crate::utils::tz::{Timestamp, now_utc}; + +define_enum!( + LogSource { + Relay = "relay", + Agent = "agent", + Kb = "kb", + Flow = "flow", + Test = "test", + } +); + +/// One usage-log row (`quota` stays 0 for internal calls, design §9.3). +#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] +pub struct LlmLog { + pub id: SnowflakeId, + pub tenant_id: Option, + pub request_id: Option, + pub user_id: Option, + pub token_id: Option, + pub source: LogSource, + pub channel_id: Option, + pub key_index: Option, + pub model_name: String, + pub is_stream: bool, + pub prompt_tokens: i32, + pub completion_tokens: i32, + pub cache_read_tokens: i32, + pub cache_write_tokens: i32, + pub quota: i64, + pub detail: Option, + pub elapsed_ms: Option, + pub status_code: Option, + pub error_message: Option, + pub created_at: Timestamp, +} + +/// Payload for inserting a log row (best-effort: caller logs failures). +#[derive(Debug)] +pub struct NewLog { + pub tenant_id: Option, + pub request_id: Option, + pub user_id: Option, + pub token_id: Option, + pub source: LogSource, + pub channel_id: Option, + pub key_index: Option, + pub model_name: String, + pub is_stream: bool, + pub prompt_tokens: i32, + pub completion_tokens: i32, + pub cache_read_tokens: i32, + pub cache_write_tokens: i32, + pub quota: i64, + pub detail: Option, + pub elapsed_ms: Option, + pub status_code: Option, + pub error_message: Option, +} + +impl Default for NewLog { + fn default() -> Self { + Self { + tenant_id: None, + request_id: None, + user_id: None, + token_id: None, + source: LogSource::Relay, + channel_id: None, + key_index: None, + model_name: String::new(), + is_stream: false, + prompt_tokens: 0, + completion_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + quota: 0, + detail: None, + elapsed_ms: None, + status_code: None, + error_message: None, + } + } +} + +/// Insert a usage-log row. +pub async fn insert_log(pool: &crate::db::Pool, l: NewLog) -> AppResult<()> { + let id = crate::utils::id::new_snowflake_id(); + let now = now_utc(); + raisfast_derive::crud_insert!( + pool, + "llm_logs", + [ + "id" => id, + "request_id" => l.request_id, + "user_id" => l.user_id, + "token_id" => l.token_id, + "source" => l.source.as_str(), + "channel_id" => l.channel_id, + "key_index" => l.key_index, + "model_name" => l.model_name, + "is_stream" => l.is_stream, + "prompt_tokens" => l.prompt_tokens, + "completion_tokens" => l.completion_tokens, + "cache_read_tokens" => l.cache_read_tokens, + "cache_write_tokens" => l.cache_write_tokens, + "quota" => l.quota, + "detail" => l.detail, + "elapsed_ms" => l.elapsed_ms, + "status_code" => l.status_code, + "error_message" => l.error_message, + "created_at" => &now + ], + tenant: l.tenant_id.as_deref() + )?; + Ok(()) +} + +/// Admin log query filters (§12). +#[derive(Debug, Default, serde::Deserialize)] +pub struct LogFilters { + pub channel_id: Option, + pub token_id: Option, + pub model_name: Option, +} + +/// Paged admin log query. +pub async fn query_paged( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + filters: &LogFilters, + page: i64, + page_size: i64, +) -> AppResult<(Vec, i64)> { + use crate::types::snowflake_id::{SnowflakeId, parse_id}; + let channel: Option = filters.channel_id.as_deref().and_then(|s| parse_id(s).ok()); + let token: Option = filters.token_id.as_deref().and_then(|s| parse_id(s).ok()); + let model = filters.model_name.clone().filter(|m| !m.is_empty()); + + Ok(raisfast_derive::crud_query_paged!( + pool, + LlmLog, + table: "llm_logs", + where: [ + "channel_id" => channel, + "token_id" => token, + "model_name" => model + ], + order_by: "id DESC", + page: page, + page_size: page_size, + tenant: tenant_id + )) +} diff --git a/crates/core/src/llm/models/mod.rs b/crates/core/src/llm/models/mod.rs new file mode 100644 index 00000000..39256884 --- /dev/null +++ b/crates/core/src/llm/models/mod.rs @@ -0,0 +1,7 @@ +//! Data layer for the llm domain (design §5): channel (with embedded key +//! pool), sk- token, model directory and usage log rows. + +pub mod channel; +pub mod log; +pub mod model; +pub mod token; diff --git a/crates/core/src/llm/models/model.rs b/crates/core/src/llm/models/model.rs new file mode 100644 index 00000000..289195c1 --- /dev/null +++ b/crates/core/src/llm/models/model.rs @@ -0,0 +1,197 @@ +//! `llm_models` model — model directory + pricing (design §5.4). + +use serde::{Deserialize, Serialize}; + +use crate::errors::app_error::{AppError, AppResult}; +use crate::types::snowflake_id::SnowflakeId; +use crate::utils::tz::{Timestamp, now_utc}; + +define_enum!( + LlmModelType { + Chat = "chat", + Embedding = "embedding", + Rerank = "rerank", + Asr = "asr", + Vlm = "vlm", + Image = "image", + } +); + +define_enum!( + LlmPriceMode { + Ratio = "ratio", + PerCall = "per_call", + } +); + +define_enum!( + LlmModelStatus { + Active = "active", + Disabled = "disabled", + } +); + +/// One model-directory row: type, pricing and params metadata. +#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] +pub struct LlmModel { + pub id: SnowflakeId, + pub tenant_id: Option, + pub name: String, + pub model_type: LlmModelType, + pub price_mode: LlmPriceMode, + pub model_ratio: f64, + pub completion_ratio: f64, + pub cache_ratio: Option, + pub cache_write_ratio: Option, + pub call_price: Option, + pub params: Option, + pub status: LlmModelStatus, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +/// Payload for creating / updating a directory row. +#[derive(Debug)] +pub struct ModelChanges { + pub name: String, + pub model_type: LlmModelType, + pub price_mode: LlmPriceMode, + pub model_ratio: f64, + pub completion_ratio: f64, + pub cache_ratio: Option, + pub cache_write_ratio: Option, + pub call_price: Option, + pub params: Option, + pub status: LlmModelStatus, +} + +/// Create a directory row and return it. +pub async fn create_model( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + m: ModelChanges, +) -> AppResult { + let id = crate::utils::id::new_snowflake_id(); + let now = now_utc(); + raisfast_derive::crud_insert!( + pool, + "llm_models", + [ + "id" => id, + "name" => m.name, + "model_type" => m.model_type.as_str(), + "price_mode" => m.price_mode.as_str(), + "model_ratio" => m.model_ratio, + "completion_ratio" => m.completion_ratio, + "cache_ratio" => m.cache_ratio, + "cache_write_ratio" => m.cache_write_ratio, + "call_price" => m.call_price, + "params" => m.params, + "status" => m.status.as_str(), + "created_at" => &now, + "updated_at" => &now + ], + tenant: tenant_id + )?; + find_by_id(pool, id, tenant_id) + .await? + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("llm_model insert vanished"))) +} + +/// Find a directory row by id (tenant-scoped). +pub async fn find_by_id( + pool: &crate::db::Pool, + id: SnowflakeId, + tenant_id: Option<&str>, +) -> AppResult> { + let result: Option = raisfast_derive::crud_find!( + pool, + "llm_models", + LlmModel, + where: ("id", id), + tenant: tenant_id + )?; + Ok(result) +} + +/// Find a directory row by (tenant, name). +pub async fn find_by_name( + pool: &crate::db::Pool, + tenant_id: &str, + name: &str, +) -> AppResult> { + let result: Option = raisfast_derive::crud_find!( + pool, + "llm_models", + LlmModel, + where: ("name", name), + tenant: Some(tenant_id) + )?; + Ok(result) +} + +/// List directory rows of a tenant, optionally filtered by type (filtering +/// happens in memory — directory scale is small and `crud_list!` has no +/// WHERE section). +pub async fn list_models( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + model_type: Option<&LlmModelType>, +) -> AppResult> { + let rows: Vec = raisfast_derive::crud_list!( + pool, + "llm_models", + LlmModel, + order_by: "name", + tenant: tenant_id + )?; + Ok(match model_type { + Some(t) => rows.into_iter().filter(|r| r.model_type == *t).collect(), + None => rows, + }) +} + +/// Update a directory row. +pub async fn update_model( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + id: SnowflakeId, + m: ModelChanges, +) -> AppResult<()> { + let now = now_utc(); + let result = raisfast_derive::crud_update!( + pool, + "llm_models", + bind: [ + "name" => m.name, + "model_type" => m.model_type.as_str(), + "price_mode" => m.price_mode.as_str(), + "model_ratio" => m.model_ratio, + "completion_ratio" => m.completion_ratio, + "cache_ratio" => m.cache_ratio, + "cache_write_ratio" => m.cache_write_ratio, + "call_price" => m.call_price, + "params" => m.params, + "status" => m.status.as_str(), + "updated_at" => &now + ], + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_model") +} + +/// Delete a directory row by id (tenant-scoped). +pub async fn delete_model( + pool: &crate::db::Pool, + id: SnowflakeId, + tenant_id: Option<&str>, +) -> AppResult<()> { + let result = raisfast_derive::crud_delete!( + pool, + "llm_models", + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_model") +} diff --git a/crates/core/src/llm/models/token.rs b/crates/core/src/llm/models/token.rs new file mode 100644 index 00000000..ad916c95 --- /dev/null +++ b/crates/core/src/llm/models/token.rs @@ -0,0 +1,154 @@ +//! `llm_tokens` model — downstream sk- keys (design §5.2). Relay-facing +//! auth/billing lands in P3; P1 ships the row + CRUD essentials. + +use serde::{Deserialize, Serialize}; + +use crate::errors::app_error::{AppError, AppResult}; +use crate::types::snowflake_id::SnowflakeId; +use crate::utils::tz::{Timestamp, now_utc}; + +define_enum!( + LlmTokenStatus { + Enabled = "enabled", + Disabled = "disabled", + Expired = "expired", + Exhausted = "exhausted", + } +); + +/// One downstream sk- token row (key stored as sha256 hash only). +#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] +pub struct LlmToken { + pub id: SnowflakeId, + pub tenant_id: Option, + pub user_id: SnowflakeId, + pub name: String, + pub key_hash: String, + pub status: LlmTokenStatus, + pub remain_quota: i64, + pub used_quota: i64, + pub unlimited_quota: bool, + pub expired_at: Option, + pub allowed_models: Option, + pub allowed_ips: Option, + pub token_group: Option, + pub created_at: Timestamp, + pub accessed_at: Option, + pub updated_at: Timestamp, +} + +/// Create a token row (key_hash pre-computed by the caller). +#[allow(clippy::too_many_arguments)] +pub async fn create_token( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + user_id: SnowflakeId, + name: &str, + key_hash: &str, + remain_quota: i64, + unlimited_quota: bool, + expired_at: Option, + allowed_models: Option<&str>, + allowed_ips: Option<&str>, +) -> AppResult { + let id = crate::utils::id::new_snowflake_id(); + let now = now_utc(); + raisfast_derive::crud_insert!( + pool, + "llm_tokens", + [ + "id" => id, + "user_id" => user_id, + "name" => name, + "key_hash" => key_hash, + "status" => LlmTokenStatus::Enabled.as_str(), + "remain_quota" => remain_quota, + "unlimited_quota" => unlimited_quota, + "expired_at" => expired_at, + "allowed_models" => allowed_models, + "allowed_ips" => allowed_ips, + "created_at" => &now, + "updated_at" => &now + ], + tenant: tenant_id + )?; + find_by_id(pool, id, tenant_id) + .await? + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("llm_token insert vanished"))) +} + +/// Find a token by id (tenant-scoped). +pub async fn find_by_id( + pool: &crate::db::Pool, + id: SnowflakeId, + tenant_id: Option<&str>, +) -> AppResult> { + let result: Option = raisfast_derive::crud_find!( + pool, + "llm_tokens", + LlmToken, + where: ("id", id), + tenant: tenant_id + )?; + Ok(result) +} + +/// Find a token by key hash (auth lookup, global unique). +pub async fn find_by_hash(pool: &crate::db::Pool, key_hash: &str) -> AppResult> { + let result: Option = raisfast_derive::crud_find!( + pool, + "llm_tokens", + LlmToken, + where: ("key_hash", key_hash) + )?; + Ok(result) +} + +/// List a user's tokens. +pub async fn list_by_user( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + user_id: SnowflakeId, +) -> AppResult> { + let result: Vec = raisfast_derive::crud_find_all!( + pool, + "llm_tokens", + LlmToken, + where: ("user_id", user_id), + tenant: tenant_id + )?; + Ok(result) +} + +/// Update token status. +pub async fn update_status( + pool: &crate::db::Pool, + tenant_id: Option<&str>, + id: SnowflakeId, + status: LlmTokenStatus, +) -> AppResult<()> { + let now = now_utc(); + let result = raisfast_derive::crud_update!( + pool, + "llm_tokens", + bind: ["status" => status.as_str(), "updated_at" => &now], + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_token") +} + +/// Delete a token by id (tenant-scoped). +pub async fn delete_token( + pool: &crate::db::Pool, + id: SnowflakeId, + tenant_id: Option<&str>, +) -> AppResult<()> { + let result = raisfast_derive::crud_delete!( + pool, + "llm_tokens", + where: ("id", id), + tenant: tenant_id + )?; + AppError::expect_affected(&result, "llm_token") +} diff --git a/crates/core/src/llm/queue.rs b/crates/core/src/llm/queue.rs new file mode 100644 index 00000000..d7d26f9c --- /dev/null +++ b/crates/core/src/llm/queue.rs @@ -0,0 +1,325 @@ +//! FIFO wait queue between callers and upstream slots (design §7.6). +//! +//! Strict first-come-first-served across keys via ticket numbering: +//! `tokio::sync::Semaphore` fairness only holds within a single key, so the +//! queue adds a `serving` counter — only the waiter whose ticket equals +//! `serving` may grab a freed slot, then `serving` advances. +//! +//! Correctness rules (design §7.6 queue pins): +//! - CV discipline: **every** state change notifies waiters — slot release +//! (`notify_all` fan-out), `serving` advance, waiter removal. tokio's +//! `notify_waiters` stores no permits, so a missed notify means a stall +//! until the next event. +//! - Head cancel: a dropped `OwnedWaitTicket` whose ticket equals `serving` +//! advances `serving` and notifies — without this, one disconnect while +//! queued deadlocks the whole queue. +//! - Dual bounds: depth (`max_waiting`) and bytes (`max_waiting_bytes`); +//! either exceeded rejects the newcomer immediately (caller maps to 503). + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use tokio::sync::Notify; + +use crate::llm::cache::RouteKey; + +struct QueueState { + waiters: VecDeque>, + bytes: usize, +} + +struct WaiterSlot { + ticket: u64, + bytes: usize, + /// Set when the waiter took its turn (left the queue) so a stale drop + /// does not double-advance `serving`. + done: AtomicBool, +} + +/// One FIFO queue (per `RouteKey`), always shared as `Arc`. +pub struct WaitQueue { + next_ticket: AtomicU64, + serving: AtomicU64, + state: Mutex, + notify: Notify, +} + +/// Queue admission rejected: depth or byte bound exceeded → caller returns +/// 503 overloaded ("处理不过来,排队也白等"). +#[derive(Debug)] +pub struct QueueFull { + pub waiting: usize, +} + +/// Timed out waiting for a slot → caller returns 429 + Retry-After. +#[derive(Debug)] +pub struct WaitTimeout { + /// Queue position at timeout, for the Retry-After estimate. + pub position: usize, +} + +impl WaitQueue { + /// New empty queue. + pub fn new() -> Self { + Self { + next_ticket: AtomicU64::new(0), + serving: AtomicU64::new(0), + state: Mutex::new(QueueState { + waiters: VecDeque::new(), + bytes: 0, + }), + notify: Notify::new(), + } + } + + /// Current queue depth. + pub fn depth(&self) -> usize { + self.state.lock().expect("waitqueue lock").waiters.len() + } + + /// Enqueue a waiter under the dual bounds. On success the caller owns an + /// `OwnedWaitTicket`; dropping it before the turn arrives cancels the + /// waiter and (head only) advances `serving`. + pub fn enqueue( + self: &Arc, + bytes: usize, + max_waiting: usize, + max_waiting_bytes: usize, + ) -> Result { + let ticket = self.next_ticket.fetch_add(1, Ordering::Relaxed); + let slot = Arc::new(WaiterSlot { + ticket, + bytes, + done: AtomicBool::new(false), + }); + { + let mut state = self.state.lock().expect("waitqueue lock"); + if state.waiters.len() >= max_waiting || state.bytes + bytes > max_waiting_bytes { + return Err(QueueFull { + waiting: state.waiters.len(), + }); + } + state.waiters.push_back(slot.clone()); + state.bytes += bytes; + } + Ok(OwnedWaitTicket { + queue: self.clone(), + slot, + }) + } + + async fn wait_turn(&self, slot: &WaiterSlot, deadline: Instant) -> Result<(), Duration> { + loop { + if slot.ticket == self.serving.load(Ordering::Acquire) { + return Ok(()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(remaining); + } + let notified = tokio::time::timeout(remaining, self.notify.notified()); + if notified.await.is_err() { + return Err(Duration::ZERO); + } + } + } + + fn take_turn(&self, slot: &WaiterSlot) { + if slot.done.swap(true, Ordering::AcqRel) { + return; + } + self.remove_slot(slot); + self.serving.fetch_add(1, Ordering::AcqRel); + self.notify.notify_waiters(); + } + + /// Notify all current waiters (slot-release fan-out / state changes). + pub fn notify_all(&self) { + self.notify.notify_waiters(); + } + + fn remove_slot(&self, slot: &WaiterSlot) { + let mut state = self.state.lock().expect("waitqueue lock"); + if let Some(pos) = state.waiters.iter().position(|w| w.ticket == slot.ticket) { + state.waiters.remove(pos); + state.bytes = state.bytes.saturating_sub(slot.bytes); + } + } + + fn position_of(&self, ticket: u64) -> usize { + let state = self.state.lock().expect("waitqueue lock"); + state + .waiters + .iter() + .position(|w| w.ticket == ticket) + .unwrap_or(0) + } +} + +impl Default for WaitQueue { + fn default() -> Self { + Self::new() + } +} + +/// Owned queue admission handle. Drop before `wait` completes = cancel. +pub struct OwnedWaitTicket { + queue: Arc, + slot: Arc, +} + +impl OwnedWaitTicket { + /// Wait for this ticket's turn within the deadline. On success the turn + /// is consumed (waiter leaves the queue, `serving` advances, next waiter + /// notified) — the caller then retries slot acquisition. + pub async fn wait(self, deadline: Instant) -> Result<(), WaitTimeout> { + match self.queue.wait_turn(&self.slot, deadline).await { + Ok(()) => { + self.queue.take_turn(&self.slot); + Ok(()) + } + Err(_) => { + let position = self.queue.position_of(self.slot.ticket); + Err(WaitTimeout { position }) + } + } + } +} + +impl Drop for OwnedWaitTicket { + fn drop(&mut self) { + // Cancellation path (timeout handled by caller, disconnect = drop): + // remove the waiter; if it was the head, advance `serving` and wake + // the next — the head-cancel deadlock guard. + if !self.slot.done.swap(true, Ordering::AcqRel) { + self.queue.remove_slot(&self.slot); + if self.slot.ticket == self.queue.serving.load(Ordering::Acquire) { + self.queue.serving.fetch_add(1, Ordering::AcqRel); + self.queue.notify.notify_waiters(); + } + } + } +} + +/// Registry of per-route queues. +pub struct QueueRegistry { + queues: DashMap>, +} + +impl QueueRegistry { + /// New empty registry. + pub fn new() -> Self { + Self { + queues: DashMap::new(), + } + } + + /// Get or create the queue for a route key. + pub fn get_or_create(&self, key: &RouteKey) -> Arc { + self.queues + .entry(key.clone()) + .or_insert_with(|| Arc::new(WaitQueue::new())) + .clone() + } + + /// Notify every waiter of one route (slot-release fan-out). + pub fn notify_route(&self, key: &RouteKey) { + if let Some(q) = self.queues.get(key) { + q.notify_all(); + } + } + + /// Notify all routes a channel serves (a freed key slot is shared across + /// every model queue the channel serves, design §7.6). + pub fn notify_channel(&self, tenant: &str, groups: &[String], models: &[String]) { + for group in groups { + for model in models { + let key = RouteKey { + tenant: tenant.to_owned(), + group: group.clone(), + model: model.clone(), + }; + self.notify_route(&key); + } + } + } + + /// Total waiting count across routes (metrics). + pub fn total_waiting(&self) -> usize { + self.queues.iter().map(|q| q.value().depth()).sum() + } +} + +impl Default for QueueRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn deadline(secs: u64) -> Instant { + Instant::now() + Duration::from_secs(secs) + } + + #[tokio::test] + async fn strict_fifo_order() { + let q = Arc::new(WaitQueue::new()); + let t0 = q.enqueue(0, 10, 1_000_000).expect("enqueue"); + let t1 = q.enqueue(0, 10, 1_000_000).expect("enqueue"); + let t2 = q.enqueue(0, 10, 1_000_000).expect("enqueue"); + + // All three are pending; only the head may take a turn. + let h0 = tokio::spawn(async move { t0.wait(deadline(3)).await.is_ok() }); + let h1 = tokio::spawn(async move { t1.wait(deadline(3)).await.is_ok() }); + let h2 = tokio::spawn(async move { t2.wait(deadline(3)).await.is_ok() }); + + q.notify_all(); + assert!(h0.await.expect("join"), "head takes the first turn"); + q.notify_all(); + assert!(h1.await.expect("join"), "second waits for the first"); + q.notify_all(); + assert!(h2.await.expect("join"), "third waits for the second"); + } + + #[tokio::test] + async fn head_cancel_advances_serving() { + let q = Arc::new(WaitQueue::new()); + let t0 = q.enqueue(0, 10, 1_000).expect("enqueue"); + let t1 = q.enqueue(0, 10, 1_000).expect("enqueue"); + // Head cancels before its turn (drop without waiting). + drop(t0); + // t1 must now be eligible without any further notify. + assert!( + t1.wait(deadline(1)).await.is_ok(), + "head cancel must advance serving" + ); + } + + #[tokio::test] + async fn depth_and_byte_bounds_reject() { + let q = Arc::new(WaitQueue::new()); + let t1 = q.enqueue(10, 1, 1_000).expect("first fits"); + assert!(q.enqueue(10, 1, 1_000).is_err(), "depth bound"); + drop(t1); + let q2 = Arc::new(WaitQueue::new()); + let t2 = q2.enqueue(700, 10, 1_000).expect("first fits"); + assert!(q2.enqueue(700, 10, 1_000).is_err(), "byte bound"); + drop(t2); + } + + #[tokio::test] + async fn timeout_reports_position() { + let q = Arc::new(WaitQueue::new()); + let _t0 = q.enqueue(0, 10, 1_000).expect("enqueue"); + let t1 = q.enqueue(0, 10, 1_000).expect("enqueue"); + // t1 is not the head; nothing notifies; must time out. + let err = t1.wait(deadline(0)).await.expect_err("must time out"); + assert!(err.position >= 1); + } +} diff --git a/crates/core/src/llm/registry.rs b/crates/core/src/llm/registry.rs new file mode 100644 index 00000000..0be88e45 --- /dev/null +++ b/crates/core/src/llm/registry.rs @@ -0,0 +1,85 @@ +//! Provider code registry — UI preset sugar only (design §13). +//! +//! Creating a channel does NOT validate `provider` against this list +//! (`generic` is the catch-all for custom deployments). + +use serde::Serialize; + +/// One provider preset for admin UI dropdowns. +#[derive(Debug, Clone, Serialize)] +pub struct ProviderPreset { + /// Registry key stored on `llm_channels.provider`. + pub key: &'static str, + pub display_name: &'static str, + /// Pre-filled upstream base URL (empty = user must supply). + pub default_base_url: &'static str, + /// Whether the upstream requires an API key. + pub requires_auth: bool, +} + +const PRESETS: &[ProviderPreset] = &[ + ProviderPreset { + key: "openai", + display_name: "OpenAI", + default_base_url: "https://api.openai.com/v1", + requires_auth: true, + }, + ProviderPreset { + key: "anthropic", + display_name: "Anthropic", + default_base_url: "https://api.anthropic.com", + requires_auth: true, + }, + ProviderPreset { + key: "gemini", + display_name: "Google Gemini", + default_base_url: "https://generativelanguage.googleapis.com/v1beta/openai", + requires_auth: true, + }, + ProviderPreset { + key: "deepseek", + display_name: "DeepSeek", + default_base_url: "https://api.deepseek.com/v1", + requires_auth: true, + }, + ProviderPreset { + key: "zhipu", + display_name: "Zhipu (GLM)", + default_base_url: "https://open.bigmodel.cn/api/paas/v4", + requires_auth: true, + }, + ProviderPreset { + key: "moonshot", + display_name: "Moonshot (Kimi)", + default_base_url: "https://api.moonshot.cn/v1", + requires_auth: true, + }, + ProviderPreset { + key: "ollama", + display_name: "Ollama (local)", + default_base_url: "http://localhost:11434/v1", + requires_auth: false, + }, + ProviderPreset { + key: "siliconflow", + display_name: "SiliconFlow", + default_base_url: "https://api.siliconflow.cn/v1", + requires_auth: true, + }, + ProviderPreset { + key: "generic", + display_name: "Generic (OpenAI-compatible)", + default_base_url: "", + requires_auth: false, + }, +]; + +/// Full preset list (admin dropdown). +pub fn registry() -> &'static [ProviderPreset] { + PRESETS +} + +/// Find one preset by key. +pub fn find(key: &str) -> Option<&'static ProviderPreset> { + PRESETS.iter().find(|p| p.key == key) +} diff --git a/crates/core/src/llm/relay/adaptor.rs b/crates/core/src/llm/relay/adaptor.rs new file mode 100644 index 00000000..e285d0ba --- /dev/null +++ b/crates/core/src/llm/relay/adaptor.rs @@ -0,0 +1,383 @@ +//! Relay adaptors (design §8.2): OpenAI canonical format in, per-provider +//! conversion out. v1 ships the openai-compatible adaptor (deepseek/moonshot/ +//! ollama/siliconflow/generic all ride it); anthropic lands in P4. + +use std::pin::Pin; + +use futures::Stream; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; + +use crate::errors::app_error::{AppError, AppResult}; + +/// Normalized usage (design §9.3 normalization contract: +/// `prompt_tokens` includes cache; OpenAI/DeepSeek are native, the anthropic +/// adaptor must sum its split fields). +#[derive(Debug, Clone, Default)] +pub struct RelayUsage { + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, +} + +impl RelayUsage { + /// Parse an OpenAI-shaped usage object. + pub fn from_openai(v: &serde_json::Value) -> Self { + let get = |name: &str| v.get(name).and_then(|x| x.as_i64()).unwrap_or(0); + let cache_read = v + .get("prompt_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|x| x.as_i64()) + .or_else(|| v.get("prompt_cache_hit_tokens").and_then(|x| x.as_i64())) + .unwrap_or(0); + Self { + prompt_tokens: get("prompt_tokens"), + completion_tokens: get("completion_tokens"), + cache_read_tokens: cache_read, + cache_write_tokens: 0, + } + } + + /// Fallback estimation when the upstream reports no usage (design §8.4). + #[allow(dead_code)] + pub fn estimate(prompt_chars: usize, completion_chars: usize) -> Self { + Self { + prompt_tokens: (prompt_chars / 4) as i64, + completion_tokens: (completion_chars / 4) as i64, + cache_read_tokens: 0, + cache_write_tokens: 0, + } + } +} + +/// Upstream endpoint selector. +#[derive(Debug, Clone, Copy)] +pub enum RelayEndpoint { + ChatCompletions, + /// Reserved for the proxied models listing (P4). + #[allow(dead_code)] + Models, +} + +/// One shared HTTP client for the whole module (design §7.6: per-host +/// connection reuse + h2 multiplexing; one client per provider would cause +/// handshake storms under load). +pub fn shared_client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(600)) + .build() + .unwrap_or_default() + }) +} + +/// Apply channel header overrides onto the request headers. +pub fn apply_header_override(headers: &mut HeaderMap, override_hdr: Option<&serde_json::Value>) { + if let Some(serde_json::Value::Object(map)) = override_hdr { + for (k, v) in map { + if let Some(vs) = v.as_str() + && let (Ok(name), Ok(value)) = + (HeaderName::try_from(k.as_str()), HeaderValue::try_from(vs)) + { + headers.insert(name, value); + } + } + } +} + +/// The OpenAI-compatible adaptor (near-passthrough). +pub struct OpenaiAdaptor; + +impl OpenaiAdaptor { + /// Upstream URL for an endpoint. + pub fn request_url(base_url: &str, endpoint: RelayEndpoint) -> String { + match endpoint { + RelayEndpoint::ChatCompletions => format!("{base_url}/chat/completions"), + RelayEndpoint::Models => format!("{base_url}/models"), + } + } + + /// Auth headers + channel overrides. + pub fn setup_headers(key: &str, header_override: Option<&serde_json::Value>) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Ok(value) = HeaderValue::try_from(format!("Bearer {key}")) { + headers.insert(AUTHORIZATION, value); + } + apply_header_override(&mut headers, header_override); + headers + } + + /// Convert a request body (design §8.3): upstream model rewrite → + /// param_override shallow merge → stream_options.include_usage key-level + /// merge (admin override wins). + pub fn convert_chat( + mut body: serde_json::Value, + upstream_model: &str, + param_override: Option<&serde_json::Value>, + stream: bool, + ) -> AppResult { + let Some(obj) = body.as_object_mut() else { + return Err(AppError::BadRequest( + "request body must be an object".to_owned(), + )); + }; + obj.insert( + "model".to_owned(), + serde_json::Value::String(upstream_model.to_owned()), + ); + if let Some(serde_json::Value::Object(over)) = param_override { + for (k, v) in over { + obj.insert(k.clone(), v.clone()); + } + } + if stream { + // §8.3 merge semantics: `include_usage` injection is KEY-LEVEL + // (client's other stream_options keys survive); an explicit + // `stream_options` in param_override replaces wholesale (admin + // intent wins) — the shallow merge above already did that. + let admin_forces = param_override + .and_then(|p| p.get("stream_options")) + .is_some(); + if !admin_forces { + let mut so = obj + .get("stream_options") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + if !so.is_object() { + so = serde_json::json!({}); + } + if let Some(so_obj) = so.as_object_mut() { + so_obj.insert("include_usage".to_owned(), serde_json::Value::Bool(true)); + } + obj.insert("stream_options".to_owned(), so); + } + } + Ok(body) + } + + /// Non-stream response: full body + usage extraction. + pub async fn handle_response( + resp: reqwest::Response, + ) -> AppResult<(serde_json::Value, RelayUsage)> { + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("read upstream: {e}")))?; + let body: serde_json::Value = serde_json::from_str(&text).map_err(|_| { + AppError::Internal(anyhow::anyhow!( + "upstream non-JSON body: {}", + &text[..text.len().min(200)] + )) + })?; + if !status.is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "upstream {}: {}", + status.as_u16(), + text.chars().take(500).collect::() + ))); + } + let usage = body + .get("usage") + .map(RelayUsage::from_openai) + .unwrap_or_default(); + Ok((body, usage)) + } + + /// Stream response: an SSE byte-frame stream that forwards frames + /// verbatim while accumulating usage. Callers attach settlement to the + /// stream's termination (settle-on-complete, design §8.2/8.4). + pub fn handle_stream( + resp: reqwest::Response, + usage_out: std::sync::Arc>, + ) -> Pin, std::io::Error>> + Send>> { + Box::pin(SseForwardStream::new(resp, usage_out)) + } +} + +/// Line-buffered SSE forwarding stream (design §8.4): forwards every raw +/// frame, parses `data:` payloads only to accumulate usage. +struct SseForwardStream { + upstream: Pin, reqwest::Error>> + Send>>, + buffer: String, + usage: std::sync::Arc>, + done: bool, +} + +impl SseForwardStream { + fn new(resp: reqwest::Response, usage: std::sync::Arc>) -> Self { + use futures::StreamExt; + Self { + upstream: Box::pin(resp.bytes_stream().map(|r| r.map(|b| b.to_vec()))), + buffer: String::new(), + usage, + done: false, + } + } + + fn feed_line(&mut self, line: &str) { + let data = line.strip_prefix("data:").map(str::trim); + if let Some(data) = data { + if data == "[DONE]" { + return; + } + if let Ok(v) = serde_json::from_str::(data) + && v.get("usage").is_some_and(|u| !u.is_null()) + { + let parsed = RelayUsage::from_openai(v.get("usage").unwrap_or(&v)); + if let Ok(mut u) = self.usage.lock() { + *u = parsed; + } + } + } + } +} + +impl Stream for SseForwardStream { + type Item = Result, std::io::Error>; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + use futures::StreamExt; + if self.done { + return std::task::Poll::Ready(None); + } + match self.upstream.poll_next_unpin(cx) { + std::task::Poll::Ready(Some(Ok(chunk))) => { + let text = String::from_utf8_lossy(&chunk).to_string(); + self.buffer.push_str(&text); + let mut out = Vec::new(); + while let Some(pos) = self.buffer.find('\n') { + let line: String = self.buffer.drain(..=pos).collect(); + let trimmed_end = line.trim_end_matches(['\n', '\r']); + self.feed_line(trimmed_end); + out.extend_from_slice(line.as_bytes()); + } + if out.is_empty() { + cx.waker().wake_by_ref(); + return std::task::Poll::Pending; + } + std::task::Poll::Ready(Some(Ok(out))) + } + std::task::Poll::Ready(Some(Err(e))) => { + self.done = true; + std::task::Poll::Ready(Some(Err(std::io::Error::other(e)))) + } + std::task::Poll::Ready(None) => { + self.done = true; + std::task::Poll::Ready(None) + } + std::task::Poll::Pending => std::task::Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(model: &str) -> serde_json::Value { + serde_json::json!({ "model": model, "messages": [] }) + } + + #[test] + fn model_rewritten_to_upstream_name() { + let out = OpenaiAdaptor::convert_chat(body("public"), "upstream-x", None, false) + .expect("convert"); + assert_eq!(out["model"], "upstream-x"); + } + + #[test] + fn non_object_body_rejected() { + assert!(OpenaiAdaptor::convert_chat(serde_json::json!([]), "m", None, false).is_err()); + } + + #[test] + fn param_override_shallow_merges() { + let over = serde_json::json!({ "temperature": 0.1 }); + let mut b = body("m"); + b["temperature"] = serde_json::json!(0.9); + let out = OpenaiAdaptor::convert_chat(b, "m", Some(&over), false).expect("convert"); + assert_eq!(out["temperature"], 0.1); + } + + #[test] + fn non_stream_never_injects_stream_options() { + let out = OpenaiAdaptor::convert_chat(body("m"), "m", None, false).expect("convert"); + assert!(out.get("stream_options").is_none()); + } + + #[test] + fn include_usage_injection_is_key_level() { + let mut b = body("m"); + b["stream"] = serde_json::json!(true); + b["stream_options"] = serde_json::json!({ "x_custom": 42 }); + let out = OpenaiAdaptor::convert_chat(b, "m", None, true).expect("convert"); + assert_eq!(out["stream_options"]["include_usage"], true); + assert_eq!( + out["stream_options"]["x_custom"], 42, + "client keys must survive (§8.3)" + ); + } + + #[test] + fn admin_stream_options_override_wins() { + let over = serde_json::json!({ "stream_options": { "include_usage": false } }); + let mut b = body("m"); + b["stream"] = serde_json::json!(true); + let out = OpenaiAdaptor::convert_chat(b, "m", Some(&over), true).expect("convert"); + assert_eq!(out["stream_options"]["include_usage"], false); + } + + #[test] + fn request_urls() { + assert_eq!( + OpenaiAdaptor::request_url("https://x/v1", RelayEndpoint::ChatCompletions), + "https://x/v1/chat/completions" + ); + assert_eq!( + OpenaiAdaptor::request_url("https://x/v1", RelayEndpoint::Models), + "https://x/v1/models" + ); + } + + #[test] + fn usage_from_openai_variants() { + let openai = serde_json::json!({ + "prompt_tokens": 100, + "completion_tokens": 40, + "prompt_tokens_details": { "cached_tokens": 60 } + }); + let u = RelayUsage::from_openai(&openai); + assert_eq!((u.prompt_tokens, u.cache_read_tokens), (100, 60)); + + let deepseek = serde_json::json!({ + "prompt_tokens": 100, + "completion_tokens": 40, + "prompt_cache_hit_tokens": 70 + }); + let u2 = RelayUsage::from_openai(&deepseek); + assert_eq!(u2.cache_read_tokens, 70); + + let bare = serde_json::json!({ "prompt_tokens": 10, "completion_tokens": 5 }); + let u3 = RelayUsage::from_openai(&bare); + assert_eq!((u3.prompt_tokens, u3.cache_read_tokens), (10, 0)); + } + + #[test] + fn usage_estimate_fallback() { + let u = RelayUsage::estimate(400, 80); + assert_eq!((u.prompt_tokens, u.completion_tokens), (100, 20)); + } + + #[test] + fn header_override_applies() { + let over = serde_json::json!({ "x-custom": "v", "not-a-string": 9 }); + let h = OpenaiAdaptor::setup_headers("k", Some(&over)); + assert_eq!(h.get("x-custom").unwrap(), "v"); + assert_eq!(h.get(reqwest::header::AUTHORIZATION).unwrap(), "Bearer k"); + } +} diff --git a/crates/core/src/llm/relay/auth.rs b/crates/core/src/llm/relay/auth.rs new file mode 100644 index 00000000..bb6c98e6 --- /dev/null +++ b/crates/core/src/llm/relay/auth.rs @@ -0,0 +1,189 @@ +//! sk- token authentication for `/v1` relay (design §9.1, §5.2). + +use std::net::Ipv4Addr; +use std::str::FromStr; + +use crate::db::Pool; +use crate::errors::app_error::{AppError, AppResult}; +use crate::llm::models::token::{self, LlmToken, LlmTokenStatus}; +use crate::types::snowflake_id::SnowflakeId; + +/// Authenticated relay identity. +pub struct RelayIdentity { + pub token: LlmToken, +} + +/// Generate a fresh downstream key: `sk-` + 48 url-safe chars (shown once). +pub fn generate_sk() -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut raw = [0u8; 48]; + let _ = getrandom::fill(&mut raw); + let body: String = raw + .iter() + .map(|b| ALPHABET[*b as usize % ALPHABET.len()] as char) + .collect(); + format!("sk-{body}") +} + +/// Hash a plaintext sk- key for storage/lookup. +pub fn hash_sk(plain: &str) -> String { + crate::services::api_token::hash_token(plain) +} + +fn ip_allowed(allowed: Option<&str>, client_ip: &str) -> bool { + let Some(list) = allowed else { return true }; + if list.trim().is_empty() { + return true; + } + let client = client_ip.trim(); + for entry in list.lines().map(str::trim).filter(|l| !l.is_empty()) { + if let Some((net, bits)) = entry.split_once('/') { + if ipv4_in_cidr(client, net, bits) { + return true; + } + } else if entry == client { + return true; + } + } + false +} + +fn ipv4_in_cidr(ip: &str, net: &str, bits: &str) -> bool { + let Ok(bits) = bits.parse::() else { + return false; + }; + if bits > 32 { + return false; + } + let Ok(a) = Ipv4Addr::from_str(ip) else { + return false; + }; + let Ok(b) = Ipv4Addr::from_str(net) else { + return false; + }; + let mask: u32 = if bits == 0 { + 0 + } else { + u32::MAX << (32 - bits) + }; + u32::from(a) & mask == u32::from(b) & mask +} + +/// Full token authentication chain (design §9.1): hash lookup → owner user +/// status → token status (lazy expired/exhausted flips) → IP allowlist. +/// `client_ip` may be empty when no address is known (allowlist skipped). +pub async fn authenticate(pool: &Pool, bearer: &str, client_ip: &str) -> AppResult { + let plain = bearer.strip_prefix("sk-").unwrap_or(bearer); + let full = if bearer.starts_with("sk-") { + bearer.to_owned() + } else { + format!("sk-{plain}") + }; + let token = token::find_by_hash(pool, &hash_sk(&full)) + .await? + .ok_or(AppError::Unauthorized)?; + + // Owner user status: a banned user's tokens die with the account. + if let Some(user) = + crate::models::user::find_by_id(pool, token.user_id, token.tenant_id.as_deref()).await? + { + if user.status != crate::models::user::UserStatus::Active { + return Err(AppError::Unauthorized); + } + } else { + return Err(AppError::Unauthorized); + } + + // Lazy status flips (design §9.2). + if token.status == LlmTokenStatus::Enabled { + let now = crate::utils::tz::now_utc(); + if let Some(exp) = token.expired_at + && exp <= now + { + token::update_status(pool, None, token.id, LlmTokenStatus::Expired).await?; + return Err(AppError::Unauthorized); + } + if !token.unlimited_quota && token.remain_quota <= 0 { + token::update_status(pool, None, token.id, LlmTokenStatus::Exhausted).await?; + return Err(AppError::Unauthorized); + } + } else if token.status != LlmTokenStatus::Enabled { + return Err(AppError::Unauthorized); + } + + if !client_ip.is_empty() && !ip_allowed(token.allowed_ips.as_deref(), client_ip) { + return Err(AppError::Forbidden); + } + + Ok(RelayIdentity { token }) +} + +/// Model whitelist check (§5.2 `allowed_models`). +pub fn model_allowed(token: &LlmToken, model: &str) -> bool { + match token.allowed_models.as_deref() { + None | Some("") => true, + Some(list) => list.split(',').map(str::trim).any(|m| m == model), + } +} + +/// Placeholder owner helper for log rows. +pub fn token_owner(token: &LlmToken) -> (SnowflakeId, SnowflakeId) { + (token.id, token.user_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sk_format() { + let sk = generate_sk(); + assert!(sk.starts_with("sk-"), "prefix"); + assert_eq!(sk.len(), 3 + 48, "48 random chars"); + assert!(sk[3..].chars().all(|c| c.is_ascii_alphanumeric())); + assert_ne!(generate_sk(), sk, "randomness"); + } + + #[test] + fn ip_allowlist_matching() { + assert!(ip_allowed(None, "1.2.3.4")); + assert!(ip_allowed(Some(""), "1.2.3.4")); + assert!(ip_allowed(Some("1.2.3.4\n5.6.7.8"), "5.6.7.8")); + assert!(ip_allowed(Some("10.0.0.0/8"), "10.1.2.3")); + assert!(ip_allowed(Some("192.168.1.0/24"), "192.168.1.99")); + assert!(!ip_allowed(Some("192.168.1.0/24"), "192.168.2.1")); + assert!(!ip_allowed(Some("1.2.3.4"), "5.6.7.8")); + assert!(!ip_allowed(Some("10.0.0.0/99"), "10.0.0.1"), "invalid bits"); + } + + #[test] + fn model_whitelist() { + let mut token = token_fixture(); + assert!(model_allowed(&token, "anything")); + token.allowed_models = Some("gpt-4o, gpt-4o-mini".to_owned()); + assert!(model_allowed(&token, "gpt-4o")); + assert!(!model_allowed(&token, "claude")); + } + + fn token_fixture() -> crate::llm::models::token::LlmToken { + use crate::llm::models::token::{LlmToken, LlmTokenStatus}; + LlmToken { + id: SnowflakeId(1), + tenant_id: Some("default".to_owned()), + user_id: SnowflakeId(2), + name: "t".to_owned(), + key_hash: "h".to_owned(), + status: LlmTokenStatus::Enabled, + remain_quota: 100, + used_quota: 0, + unlimited_quota: false, + expired_at: None, + allowed_models: None, + allowed_ips: None, + token_group: None, + created_at: crate::utils::tz::now_utc(), + accessed_at: None, + updated_at: crate::utils::tz::now_utc(), + } + } +} diff --git a/crates/core/src/llm/relay/billing.rs b/crates/core/src/llm/relay/billing.rs new file mode 100644 index 00000000..4e467abe --- /dev/null +++ b/crates/core/src/llm/relay/billing.rs @@ -0,0 +1,331 @@ +//! Quota billing for the relay (design §9.3): pre-consume → settle/refund +//! with the atomic token-quota source. Wallet-backed sources land with the +//! relay-station evolution (§16) behind the same call sites. + +use crate::db::Driver; +use crate::db::Pool; +use crate::db::driver::DbDriver; +use crate::errors::app_error::{AppError, AppResult}; +use crate::llm::cache::Pricing; +use crate::llm::models::model::LlmModelType; +use crate::llm::models::model::LlmPriceMode; +use crate::llm::relay::adaptor::RelayUsage; +use crate::types::snowflake_id::SnowflakeId; + +/// $1 = 500,000 quota (design §5.4). +pub const QUOTA_PER_USD: f64 = 500_000.0; + +/// Pre-consumption hold for one request. +#[derive(Debug, Clone)] +pub struct PreCharge { + pub token_id: SnowflakeId, + pub pre_consumed: i64, + pub unlimited: bool, +} + +/// Per-token quota source (design §9.1). Placeholder order matches the SQL +/// text order exactly (bind-position discipline: `Driver::ph` on SQLite is a +/// bare `?`, so text order IS bind order). +async fn debit_hold(pool: &Pool, token_id: SnowflakeId, amount: i64) -> AppResult<()> { + let ph = |i: usize| Driver::ph(i); + let sql = format!( + "UPDATE llm_tokens SET remain_quota = remain_quota - {a} \ + WHERE id = {b} AND (unlimited_quota = TRUE OR remain_quota >= {c})", + a = ph(1), + b = ph(2), + c = ph(3) + ); + let result = sqlx::query(crate::db::safe_sql(&sql)) + .bind(amount) + .bind(token_id) + .bind(amount) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(AppError::TooManyRequests("insufficient quota".to_owned())); + } + Ok(()) +} + +/// Settle: `remain += (pre - actual)` and `used += actual` in one statement. +/// The non-negative guard makes a top-up (actual > pre) best-effort: when the +/// token drained mid-flight the row doesn't go negative and the caller logs +/// (design §9.3 补扣失败不追). +async fn apply_settle(pool: &Pool, token_id: SnowflakeId, diff: i64, actual: i64) -> AppResult<()> { + let ph = |i: usize| Driver::ph(i); + let sql = format!( + "UPDATE llm_tokens SET remain_quota = remain_quota + {d}, used_quota = used_quota + {u} \ + WHERE id = {id} AND remain_quota + {g} >= 0", + d = ph(1), + u = ph(2), + id = ph(3), + g = ph(4) + ); + sqlx::query(crate::db::safe_sql(&sql)) + .bind(diff) + .bind(actual) + .bind(token_id) + .bind(diff) + .execute(pool) + .await?; + Ok(()) +} + +/// Refund the full hold (all attempts failed / admission rejected). +async fn apply_refund(pool: &Pool, token_id: SnowflakeId, amount: i64) -> AppResult<()> { + let ph = |i: usize| Driver::ph(i); + let sql = format!( + "UPDATE llm_tokens SET remain_quota = remain_quota + {a} WHERE id = {b}", + a = ph(1), + b = ph(2) + ); + sqlx::query(crate::db::safe_sql(&sql)) + .bind(amount) + .bind(token_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Pre-consume an estimate before forwarding (design §9.3 formulas; ratio / +/// per-call / embedding variants). +pub async fn pre_consume( + pool: &Pool, + token_id: SnowflakeId, + unlimited: bool, + estimate: i64, +) -> AppResult { + if unlimited || estimate <= 0 { + return Ok(PreCharge { + token_id, + pre_consumed: estimate.max(0), + unlimited, + }); + } + debit_hold(pool, token_id, estimate).await?; + Ok(PreCharge { + token_id, + pre_consumed: estimate, + unlimited, + }) +} + +/// Settle with the actual usage: refund the over-hold, or try to collect the +/// under-hold (collection failure is logged, not chased — design §9.3). +pub async fn settle(pool: &Pool, charge: &PreCharge, actual: i64) { + if charge.unlimited { + return; + } + let diff = charge.pre_consumed - actual; + if let Err(err) = apply_settle(pool, charge.token_id, diff, actual).await { + tracing::warn!(%err, "llm settle failed (logged, not chased)"); + } +} + +/// Refund the full hold (all attempts failed / admission rejected). +pub async fn refund_all(pool: &Pool, charge: &PreCharge) { + if charge.unlimited || charge.pre_consumed <= 0 { + return; + } + if let Err(err) = apply_refund(pool, charge.token_id, charge.pre_consumed).await { + tracing::error!(%err, "llm refund_all failed"); + } +} + +/// Pre-consume estimate (design §9.3): ratio / per-call / embedding variants. +pub fn estimate_precharge( + pricing: &Pricing, + model_type: LlmModelType, + body: &serde_json::Value, +) -> i64 { + match pricing.price_mode { + LlmPriceMode::PerCall => (pricing.call_price.unwrap_or(0.0) * QUOTA_PER_USD).ceil() as i64, + LlmPriceMode::Ratio => { + let prompt_chars = count_prompt_chars(body); + let mut prompt_est = (prompt_chars / 4).max(500) as f64; + if has_cjk(body) { + prompt_est = (prompt_chars as f64 / 1.5).max(500.0); + } + if model_type == LlmModelType::Embedding { + return (prompt_est * pricing.model_ratio).ceil() as i64; + } + let max_tokens_eff = body + .get("max_tokens") + .and_then(|v| v.as_i64()) + .filter(|v| *v > 0) + .unwrap_or(4096); + ((prompt_est + max_tokens_eff as f64) * pricing.model_ratio).ceil() as i64 + } + } +} + +/// Settle quota from normalized usage (design §9.3, cache read/write split). +pub fn settle_quota(pricing: &Pricing, usage: &RelayUsage) -> i64 { + match pricing.price_mode { + LlmPriceMode::PerCall => (pricing.call_price.unwrap_or(0.0) * QUOTA_PER_USD).ceil() as i64, + LlmPriceMode::Ratio => { + let per = |tokens: i64, ratio: f64| tokens as f64 * ratio / 1_000_000.0 * QUOTA_PER_USD; + let base = + (usage.prompt_tokens - usage.cache_read_tokens - usage.cache_write_tokens).max(0); + let cache_read_ratio = pricing.cache_ratio.unwrap_or(pricing.model_ratio); + let cache_write_ratio = pricing.cache_write_ratio.unwrap_or(pricing.model_ratio); + let total = per(base, pricing.model_ratio) + + per(usage.cache_read_tokens, cache_read_ratio) + + per(usage.cache_write_tokens, cache_write_ratio) + + per(usage.completion_tokens, pricing.completion_ratio); + total.ceil() as i64 + } + } +} + +fn count_prompt_chars(body: &serde_json::Value) -> usize { + body.get("messages") + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter() + .map(|m| match m.get("content") { + Some(serde_json::Value::String(s)) => s.chars().count(), + Some(serde_json::Value::Array(parts)) => parts + .iter() + .map(|p| { + p.get("text") + .and_then(|t| t.as_str()) + .map(|t| t.chars().count()) + .unwrap_or(0) + }) + .sum(), + _ => 0, + }) + .sum() + }) + .unwrap_or(0) +} + +fn has_cjk(body: &serde_json::Value) -> bool { + body.get("messages") + .and_then(|m| m.as_array()) + .is_some_and(|arr| { + arr.iter().any(|m| { + m.get("content") + .and_then(|c| c.as_str()) + .is_some_and(|s| s.chars().any(|ch| ('\u{4E00}'..='\u{9FFF}').contains(&ch))) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pricing(ratio: f64, completion: f64) -> Pricing { + Pricing { + price_mode: LlmPriceMode::Ratio, + model_ratio: ratio, + completion_ratio: completion, + cache_ratio: None, + cache_write_ratio: None, + call_price: None, + } + } + + fn chat_body(prompt: &str, max_tokens: i64) -> serde_json::Value { + serde_json::json!({ + "model": "m", + "max_tokens": max_tokens, + "messages": [{ "role": "user", "content": prompt }] + }) + } + + #[test] + fn precharge_ratio_uses_default_max_tokens_chain() { + // 100 ascii chars → est 25 → floor 500; +4096 default; ×1 ratio. + let p = pricing(1.0, 1.0); + let body = chat_body(&"a".repeat(100), 0); + // max_tokens: 0 present → treated as explicit 0? chain: req → dir → 4096. + // A zero in the request is treated as absent → 4096. + let est = estimate_precharge(&p, LlmModelType::Chat, &body); + assert_eq!(est, 500 + 4096); + } + + #[test] + fn precharge_respects_explicit_max_tokens() { + let p = pricing(2.0, 2.0); + let body = chat_body(&"a".repeat(100), 1000); + let est = estimate_precharge(&p, LlmModelType::Chat, &body); + assert_eq!(est, (500 + 1000) * 2); + } + + #[test] + fn precharge_embedding_skips_output_reserve() { + let p = pricing(1.0, 1.0); + let body = chat_body(&"a".repeat(100), 4096); + let est = estimate_precharge(&p, LlmModelType::Embedding, &body); + assert_eq!(est, 500, "embedding holds prompt only (§9.3)"); + } + + #[test] + fn precharge_cjk_counts_denser() { + let p = pricing(1.0, 1.0); + // 100 CJK → /1.5 = 66 → floor 500; +1000. + let body = chat_body(&"中".repeat(100), 1000); + let est = estimate_precharge(&p, LlmModelType::Chat, &body); + assert_eq!(est, 1500); + // 2000 CJK → 1333.33 (>500, no floor) + 1000 → ceil 2334. + let body2 = chat_body(&"中".repeat(2000), 1000); + let est2 = estimate_precharge(&p, LlmModelType::Chat, &body2); + assert_eq!(est2, f64::ceil((2000.0 / 1.5) + 1000.0) as i64); + } + + #[test] + fn precharge_per_call_is_flat_full_amount() { + let mut p = pricing(0.0, 0.0); + p.price_mode = LlmPriceMode::PerCall; + p.call_price = Some(0.002); + let body = chat_body("x", 99999); + assert_eq!(estimate_precharge(&p, LlmModelType::Chat, &body), 1000); + } + + #[test] + fn settle_quota_splits_cache_read_write() { + let mut p = pricing(1.0, 2.0); + p.cache_ratio = Some(0.1); + p.cache_write_ratio = Some(1.25); + let usage = RelayUsage { + prompt_tokens: 1000, + completion_tokens: 500, + cache_read_tokens: 600, + cache_write_tokens: 200, + }; + let quota = settle_quota(&p, &usage); + // base = 1000-600-200 = 200 ×1.0; read 600×0.1; write 200×1.25; + // completion 500×2.0 → (200+60+250+1000)/1M ×500k = 1510/2 = 755. + let expected = 200.0 + 60.0 + 250.0 + 1000.0; + assert_eq!( + quota, + (expected / 1_000_000.0 * QUOTA_PER_USD).ceil() as i64 + ); + } + + #[test] + fn settle_quota_clamps_base_but_still_bills_cache() { + let p = pricing(1.0, 1.0); + let usage = RelayUsage { + prompt_tokens: 100, + completion_tokens: 0, + cache_read_tokens: 500, + cache_write_tokens: 500, + }; + // Inconsistent usage: base clamps to 0 (never negative, §9.3), but + // the reported cache tokens still bill at their ratios. + assert_eq!(settle_quota(&p, &usage), 500); + } + + #[test] + fn settle_quota_per_call() { + let mut p = pricing(0.0, 0.0); + p.price_mode = LlmPriceMode::PerCall; + p.call_price = Some(0.01); + let usage = RelayUsage::default(); + assert_eq!(settle_quota(&p, &usage), 5000); + } +} diff --git a/crates/core/src/llm/relay/mod.rs b/crates/core/src/llm/relay/mod.rs new file mode 100644 index 00000000..0bfec04f --- /dev/null +++ b/crates/core/src/llm/relay/mod.rs @@ -0,0 +1,551 @@ +//! `/v1` relay endpoints (design §8, §4): the OpenAI-compatible public face. +//! Mounted at the application root (not `/api/v1`) — sk- auth, not JWT. + +pub(crate) mod adaptor; +pub(crate) mod auth; +pub(crate) mod billing; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::Json; +use axum::body::Body; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use futures::StreamExt; + +use crate::AppState; +use crate::llm::models::log::{LogSource, NewLog}; +use crate::llm::models::model::LlmModelType; +pub use crate::llm::relay::adaptor::shared_client; +use crate::llm::relay::adaptor::{RelayEndpoint, RelayUsage}; +use crate::llm::service::{ + DEFAULT_RETRY_TIMES, LlmRouter, RELAY_TIER, ResolveCtx, RetryState, SlotError, +}; +use crate::types::snowflake_id::SnowflakeId; + +pub use auth::generate_sk; +pub use billing::QUOTA_PER_USD; + +/// Hash helper re-exported for the token self-service endpoints. +pub fn auth_hash(plain: &str) -> String { + auth::hash_sk(plain) +} + +/// Header builder re-exported for the channel test endpoint. +pub struct OpenaiHeaders; + +impl OpenaiHeaders { + /// Auth + channel header overrides. + pub fn for_key( + key: &str, + header_override: Option<&serde_json::Value>, + ) -> axum::http::HeaderMap { + adaptor::OpenaiAdaptor::setup_headers(key, header_override) + } +} + +/// Register `/v1` routes on the application root. +pub fn routes() -> axum::Router { + axum::Router::new() + .route( + "/v1/chat/completions", + axum::routing::post(chat_completions), + ) + .route("/v1/models", axum::routing::get(list_models)) +} + +/// Error body in the OpenAI-compatible wire shape (design §8.5): SDKs parse errors from this +/// envelope, bare JSON/text breaks them. +fn openai_error(status: StatusCode, typ: &str, message: String) -> Response { + let body = serde_json::json!({ + "error": { "message": message, "type": typ, "code": null } + }); + (status, Json(body)).into_response() +} + +fn bearer_of(headers: &HeaderMap) -> Option { + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::to_owned) +} + +fn client_ip_of(headers: &HeaderMap) -> String { + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .unwrap_or_default() + .to_owned() +} + +async fn write_log(state: &AppState, log: NewLog) { + if let Err(err) = crate::llm::models::log::insert_log(&state.pool, log).await { + tracing::warn!(%err, "llm relay log insert failed"); + } +} + +/// POST /v1/chat/completions — the full pipeline of design §4. +#[allow(clippy::too_many_lines)] +async fn chat_completions( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let Some(bearer) = bearer_of(&headers) else { + return openai_error( + StatusCode::UNAUTHORIZED, + "invalid_request_error", + "missing bearer token".into(), + ); + }; + let identity = match auth::authenticate(&state.pool, &bearer, &client_ip_of(&headers)).await { + Ok(id) => id, + Err(err) => { + let status = match &err { + crate::errors::app_error::AppError::Unauthorized => StatusCode::UNAUTHORIZED, + crate::errors::app_error::AppError::Forbidden => StatusCode::FORBIDDEN, + _ => StatusCode::UNAUTHORIZED, + }; + return openai_error(status, "invalid_request_error", err.to_string()); + } + }; + let token = identity.token; + let tenant = token + .tenant_id + .clone() + .unwrap_or_else(|| "default".to_owned()); + let (token_id, user_id) = auth::token_owner(&token); + + let Some(model) = body + .get("model") + .and_then(|m| m.as_str()) + .map(str::to_owned) + else { + return openai_error( + StatusCode::BAD_REQUEST, + "invalid_request_error", + "missing model".into(), + ); + }; + if !auth::model_allowed(&token, &model) { + return openai_error( + StatusCode::FORBIDDEN, + "invalid_request_error", + format!("model not allowed: {model}"), + ); + } + let Some(info) = state.llm_router.model_info(&tenant, &model) else { + return openai_error( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!("unknown model: {model}"), + ); + }; + if !matches!(info.model_type, LlmModelType::Chat | LlmModelType::Vlm) { + return openai_error( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!("model is not a chat model: {model}"), + ); + } + let stream = body + .get("stream") + .and_then(|s| s.as_bool()) + .unwrap_or(false); + + // Pre-consume (§9.3) — rejection refunds nothing (no hold taken). + let estimate = billing::estimate_precharge(&info.pricing, info.model_type, &body); + let charge = + match billing::pre_consume(&state.pool, token_id, token.unlimited_quota, estimate).await { + Ok(c) => c, + Err(_) => { + return openai_error( + StatusCode::TOO_MANY_REQUESTS, + "rate_limit_error", + "insufficient quota".into(), + ); + } + }; + + let router = state.llm_router.clone(); + let cache = router.cache_snapshot(); + let ctx = ResolveCtx { + tenant: &tenant, + group: None, + pin_channel: None, + }; + let mut retry = RetryState::default(); + let deadline = Instant::now() + Duration::from_secs(RELAY_TIER.max_wait_secs); + let body_bytes = body.to_string().len(); + + let mut attempts_log: Vec = Vec::new(); + let mut last_error: Option<(u16, String)> = None; + + for attempt in 0..=DEFAULT_RETRY_TIMES { + let acquired = router + .acquire_slot( + &crate::llm::service::SlotRequest { + cache: &cache, + ctx: &ctx, + model: &model, + tier: RELAY_TIER, + caller: Some((token_id, user_id)), + body_bytes, + deadline, + }, + &mut retry, + ) + .await; + let (channel, key_index, _permit) = match acquired { + Ok(triple) => triple, + Err(SlotError::NoRoute(err)) => { + billing::refund_all(&state.pool, &charge).await; + return openai_error( + StatusCode::BAD_REQUEST, + "invalid_request_error", + err.to_string(), + ); + } + Err(SlotError::Rejected { + status, + message, + retry_after_secs, + }) => { + billing::refund_all(&state.pool, &charge).await; + let mut hdrs = HeaderMap::new(); + if let Some(secs) = retry_after_secs + && let Ok(v) = secs.to_string().parse() + { + hdrs.insert("retry-after", v); + } + let typ = if status == 503 { + "server_error" + } else { + "rate_limit_error" + }; + let resp = openai_error( + StatusCode::from_u16(status).unwrap_or(StatusCode::TOO_MANY_REQUESTS), + typ, + message, + ); + return ( + StatusCode::from_u16(status).unwrap_or(StatusCode::TOO_MANY_REQUESTS), + hdrs, + resp, + ) + .into_response(); + } + }; + + let upstream_model = crate::llm::service::mapped_model(&channel, &model); + let converted = OpenaiBody::convert( + body.clone(), + &upstream_model, + channel.param_override.as_ref(), + stream, + ); + let url = adaptor_request_url(&channel.base_url); + let client = shared_client(); + let resp = client + .post(&url) + .headers(crate::llm::relay::adaptor::OpenaiAdaptor::setup_headers( + &channel.keys[key_index].plain.clone().unwrap_or_default(), + channel.header_override.as_ref(), + )) + .json(&converted) + .send() + .await; + + match resp { + Ok(upstream) if upstream.status().is_success() => { + let usage_cell = Arc::new(std::sync::Mutex::new(RelayUsage::default())); + if stream { + let inner = crate::llm::relay::adaptor::OpenaiAdaptor::handle_stream( + upstream, + usage_cell.clone(), + ); + let settle_stream = SettleStream { + inner, + state: SettleCtx { + pool: state.pool.clone(), + charge: charge.clone(), + pricing: info.pricing.clone(), + usage: usage_cell, + router: router.clone(), + tenant: tenant.clone(), + token_id: Some(token_id), + user_id: Some(user_id), + channel_id: channel.id, + key_index, + model: model.clone(), + }, + settled: false, + }; + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .body(Body::from_stream(settle_stream)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); + } + match crate::llm::relay::adaptor::OpenaiAdaptor::handle_response(upstream).await { + Ok((resp_body, usage)) => { + let actual = billing::settle_quota(&info.pricing, &usage); + billing::settle(&state.pool, &charge, actual).await; + router.report_success(channel.id, key_index); + router.record_latency(&tenant, &model, 1.0); + let log = NewLog { + tenant_id: Some(tenant.clone()), + user_id: Some(user_id), + token_id: Some(token_id), + source: LogSource::Relay, + channel_id: Some(channel.id), + key_index: Some(key_index as i32), + model_name: model.clone(), + is_stream: false, + prompt_tokens: usage.prompt_tokens as i32, + completion_tokens: usage.completion_tokens as i32, + cache_read_tokens: usage.cache_read_tokens as i32, + cache_write_tokens: usage.cache_write_tokens as i32, + quota: actual, + detail: Some(serde_json::json!({ + "pre_consumed": charge.pre_consumed, + "attempts": attempts_log.len() + 1, + })), + elapsed_ms: None, + status_code: Some(200), + error_message: None, + request_id: None, + }; + write_log(&state, log).await; + return (StatusCode::OK, Json(resp_body)).into_response(); + } + Err(err) => { + attempts_log.push(format!("ch{} parse: {err}", channel.id.0)); + last_error = Some((502, err.to_string())); + } + } + } + Ok(upstream) => { + let status = upstream.status().as_u16(); + let text = upstream.text().await.unwrap_or_default(); + attempts_log.push(format!("ch{} key{key_index}: {status}", channel.id.0)); + router.report_failure( + channel.id, + key_index, + &crate::llm::service::UpstreamFailure { + status: Some(status), + message: text.clone(), + retry_after: None, + }, + ); + last_error = Some((status, text)); + } + Err(err) => { + attempts_log.push(format!( + "ch{} key{key_index}: transport {err}", + channel.id.0 + )); + router.report_failure( + channel.id, + key_index, + &crate::llm::service::UpstreamFailure { + status: None, + message: err.to_string(), + retry_after: None, + }, + ); + last_error = Some((502, err.to_string())); + } + } + if attempt < DEFAULT_RETRY_TIMES { + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + billing::refund_all(&state.pool, &charge).await; + let (status, message) = last_error.unwrap_or((502, "upstream failed".to_owned())); + let status = StatusCode::from_u16(if (400..600).contains(&status) { + status + } else { + 502 + }) + .unwrap_or(StatusCode::BAD_GATEWAY); + openai_error(status, "api_error", message) +} + +/// GET /v1/models — token-visible models (design §8.1): +/// channel models ∩ active directory ∩ token allowlist. +async fn list_models(State(state): State, headers: HeaderMap) -> Response { + let Some(bearer) = bearer_of(&headers) else { + return openai_error( + StatusCode::UNAUTHORIZED, + "invalid_request_error", + "missing bearer token".into(), + ); + }; + let identity = match auth::authenticate(&state.pool, &bearer, &client_ip_of(&headers)).await { + Ok(id) => id, + Err(err) => { + return openai_error( + StatusCode::UNAUTHORIZED, + "invalid_request_error", + err.to_string(), + ); + } + }; + let token = identity.token; + let tenant = token + .tenant_id + .clone() + .unwrap_or_else(|| "default".to_owned()); + let cache = state.llm_router.cache_snapshot(); + let mut names: Vec = cache + .channels + .values() + .filter(|ch| ch.tenant == tenant) + .flat_map(|ch| ch.models.iter().cloned()) + .collect(); + names.sort(); + names.dedup(); + let data: Vec = names + .into_iter() + .filter(|name| auth::model_allowed(&token, name)) + .filter(|name| cache.model_info(&tenant, name).is_some()) + .map(|id| serde_json::json!({ "id": id, "object": "model", "owned_by": "raisfast" })) + .collect(); + Json(serde_json::json!({ "object": "list", "data": data })).into_response() +} + +fn adaptor_request_url(base_url: &str) -> String { + crate::llm::relay::adaptor::OpenaiAdaptor::request_url(base_url, RelayEndpoint::ChatCompletions) +} + +/// body conversion helper namespace (keeps the handler readable). +struct OpenaiBody; + +impl OpenaiBody { + fn convert( + body: serde_json::Value, + upstream_model: &str, + param_override: Option<&serde_json::Value>, + stream: bool, + ) -> serde_json::Value { + match crate::llm::relay::adaptor::OpenaiAdaptor::convert_chat( + body.clone(), + upstream_model, + param_override, + stream, + ) { + Ok(converted) => converted, + Err(_) => body, + } + } +} + +/// Settlement context carried by the streaming response. +struct SettleCtx { + pool: crate::db::Pool, + charge: billing::PreCharge, + pricing: crate::llm::cache::Pricing, + usage: Arc>, + router: Arc, + tenant: String, + token_id: Option, + user_id: Option, + channel_id: SnowflakeId, + key_index: usize, + model: String, +} + +impl SettleCtx { + fn settle_now(&mut self) { + let usage = self.usage.lock().map(|u| u.clone()).unwrap_or_default(); + let actual = billing::settle_quota(&self.pricing, &usage); + let charge = self.charge.clone(); + let pool = self.pool.clone(); + tokio::spawn(async move { + billing::settle(&pool, &charge, actual).await; + }); + self.router.report_success(self.channel_id, self.key_index); + let log = NewLog { + tenant_id: Some(self.tenant.clone()), + user_id: self.user_id, + token_id: self.token_id, + source: LogSource::Relay, + channel_id: Some(self.channel_id), + key_index: Some(self.key_index as i32), + model_name: self.model.clone(), + is_stream: true, + prompt_tokens: usage.prompt_tokens as i32, + completion_tokens: usage.completion_tokens as i32, + cache_read_tokens: usage.cache_read_tokens as i32, + cache_write_tokens: usage.cache_write_tokens as i32, + quota: actual, + detail: Some( + serde_json::json!({ "pre_consumed": self.charge.pre_consumed, "stream": true }), + ), + elapsed_ms: None, + status_code: Some(200), + error_message: None, + request_id: None, + }; + let pool = self.pool.clone(); + tokio::spawn(async move { + if let Err(err) = crate::llm::models::log::insert_log(&pool, log).await { + tracing::warn!(%err, "llm stream settle log failed"); + } + }); + } +} + +/// Stream wrapper firing settlement exactly once on termination — normal +/// end, upstream error, or client disconnect (Drop; Drop can't await so the +/// settle spawns, design §8.4). +struct SettleStream { + inner: std::pin::Pin, std::io::Error>> + Send>>, + state: SettleCtx, + settled: bool, +} + +impl futures::Stream for SettleStream { + type Item = Result, std::io::Error>; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.inner.poll_next_unpin(cx) { + std::task::Poll::Ready(Some(Ok(bytes))) => std::task::Poll::Ready(Some(Ok(bytes))), + std::task::Poll::Ready(Some(Err(e))) => { + self.finish(); + std::task::Poll::Ready(Some(Err(e))) + } + std::task::Poll::Ready(None) => { + self.finish(); + std::task::Poll::Ready(None) + } + std::task::Poll::Pending => std::task::Poll::Pending, + } + } +} + +impl SettleStream { + fn finish(&mut self) { + if !self.settled { + self.settled = true; + self.state.settle_now(); + } + } +} + +impl Drop for SettleStream { + fn drop(&mut self) { + self.finish(); + } +} diff --git a/crates/core/src/llm/service.rs b/crates/core/src/llm/service.rs new file mode 100644 index 00000000..37a75db4 --- /dev/null +++ b/crates/core/src/llm/service.rs @@ -0,0 +1,1260 @@ +//! LlmRouter — the routing core: channel/key selection, cooldowns, failure +//! classification and last-resort fallback (design §6, §7, §10.1). +//! +//! State layout (all router-level, never inside rebuilt cache structures): +//! - `cursors`: per-channel polling cursors (`AtomicU64`, §6.1) +//! - `cooldowns`: per-key cooldowns with kind + reason (§6.2 three-way) +//! - `transient_fails`: consecutive-failure counters for exponential backoff +//! - `persist_locks`: per-channel serialization for async DB persistence +//! (§6.2: stale-snapshot overwrite prevention) +//! +//! The cache snapshot sits behind a `std::sync::RwLock>`: guards are +//! short in-memory sections with no await, so sync selection/report paths +//! (memory is routing-authoritative, §6.2) stay non-async. +//! +//! `execute()` / `SideEffectGuard` (P2) and the wait queue (§7.6) build on +//! this core; P1 ships selection + failure handling only. + +use std::collections::HashSet; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use tokio::sync::Semaphore; + +use crate::db::Pool; +use crate::errors::app_error::{AppError, AppResult}; +use crate::llm::cache::{CachedChannel, ChannelCache, ModelInfo, RouteKey}; +use crate::llm::models::channel::{LlmChannelStatus, LlmKeyMode, LlmKeyStatus}; +use crate::llm::queue::QueueRegistry; +use crate::types::snowflake_id::SnowflakeId; + +/// Global default retry budget (attempts = retry_times + 1, design §7.3). +pub const DEFAULT_RETRY_TIMES: usize = 2; + +/// Per-token in-flight cap (design §7.6; options-wired later). +pub const TOKEN_MAX_CONCURRENT: i64 = 10; +/// Per-user in-flight cap (design §7.6; multi-token amplification guard). +pub const USER_MAX_CONCURRENT: i64 = 20; + +/// Queue bounds per traffic class (design §7.6 分档). +#[derive(Debug, Clone, Copy)] +pub struct QueueTier { + pub max_waiting: usize, + pub max_waiting_bytes: usize, + pub max_wait_secs: u64, +} + +/// External interactive traffic: shallow queue, fast reject. +pub const RELAY_TIER: QueueTier = QueueTier { + max_waiting: 50, + max_waiting_bytes: 32 * 1024 * 1024, + max_wait_secs: 15, +}; + +/// Internal jobs (agent runs / kb batches): deep queue, patient wait. +pub const INTERNAL_TIER: QueueTier = QueueTier { + max_waiting: 1000, + max_waiting_bytes: 512 * 1024 * 1024, + max_wait_secs: 300, +}; + +const TRANSIENT_BASE_SECS: u64 = 5; +const TRANSIENT_MAX_SECS: u64 = 60; +const WINDOW_DEFAULT_SECS: u64 = 30 * 60; +const WINDOW_MAX_SECS: u64 = 6 * 60 * 60; +/// A `Retry-After` above this reads as a window limit, not a transient blip. +const WINDOW_RETRY_AFTER_SECS: u64 = 60; + +/// Arrears / banned-account keyword defaults; operators can extend at +/// runtime via options (design §7.4). +pub const ARREARS_KEYWORDS: &[&str] = &[ + "your credit balance is too low", + "you exceeded your current quota", + "insufficient balance", + "insufficient_quota", + "this organization has been disabled", + "permission denied", + "the security token included in the request is invalid", + "operation not allowed", + "your account is not authorized", +]; + +/// Window-limit keyword defaults (coding-plan 5h/daily caps, design §6.2). +pub const WINDOW_KEYWORDS: &[&str] = &[ + "usage limit reached", + "will reset at", + "limit will reset", + "rate limit will reset", + "exceeded your usage limit", +]; + +/// Cooldown class (design §6.2): transient keys may be last-resorted, window +/// keys carry an explicit recovery time and must not be hard-tried. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CooldownKind { + Transient, + Window, +} + +#[derive(Debug, Clone)] +struct CooldownUntil { + until: Instant, + kind: CooldownKind, + reason: String, +} + +/// Per-request retry state (design §7.2): the tried set only guards +/// last-resort — normal selection is deduped implicitly by cooldowns. +#[derive(Debug, Default)] +pub struct RetryState { + pub tried: HashSet<(SnowflakeId, usize)>, +} + +/// Resolution context (design §10.1); `group` stays `None` → `"default"` +/// until group routing ships (§16). +pub struct ResolveCtx<'a> { + pub tenant: &'a str, + pub group: Option<&'a str>, + pub pin_channel: Option, +} + +/// One selected upstream endpoint (design §10.1 `ResolvedEndpoint`). +#[derive(Debug, Clone)] +pub struct ResolvedEndpoint { + pub channel_id: SnowflakeId, + pub key_index: usize, + pub base_url: String, + pub api_key: String, + pub provider: String, + pub upstream_model: String, + pub model: Arc, + pub param_override: Option, + pub header_override: Option, +} + +/// Failure report payload for `report_failure` (classification input). +#[derive(Debug, Clone, Default)] +pub struct UpstreamFailure { + pub status: Option, + pub message: String, + pub retry_after: Option, +} + +/// Classified failure (design §6.2, judged in order: window → arrears → +/// status code → transient). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureKind { + Window, + Arrears, + Transient, +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + let h = haystack.to_ascii_lowercase(); + needles.iter().any(|n| h.contains(n)) +} + +/// Classify an upstream failure (design §6.2 three-way, window semantics take +/// priority over status codes so `403 + reset` never disables a key). +pub fn classify_failure(f: &UpstreamFailure) -> FailureKind { + if contains_any(&f.message, WINDOW_KEYWORDS) + || f.retry_after + .map(|d| d.as_secs() > WINDOW_RETRY_AFTER_SECS) + .unwrap_or(false) + { + return FailureKind::Window; + } + let arrears_status = f.status.is_some_and(|s| matches!(s, 401..=403)); + if arrears_status || contains_any(&f.message, ARREARS_KEYWORDS) { + return FailureKind::Arrears; + } + FailureKind::Transient +} + +fn rand_u64() -> u64 { + let mut buf = [0u8; 8]; + if getrandom::fill(&mut buf).is_err() { + return 0; + } + u64::from_le_bytes(buf) +} + +/// The routing core. Shared via `Arc` on `AppState`. +pub struct LlmRouter { + pub(crate) pool: Option, + pub(crate) cache: RwLock>, + cursors: DashMap>, + cooldowns: DashMap<(SnowflakeId, usize), CooldownUntil>, + transient_fails: DashMap<(SnowflakeId, usize), u32>, + persist_locks: DashMap>>, + slots: DashMap<(SnowflakeId, usize), Arc>, + queues: QueueRegistry, + token_inflight: DashMap>, + user_inflight: DashMap>, + latencies: DashMap<(String, String), Arc>>, + pub(crate) providers: + DashMap<(SnowflakeId, usize), Arc>, +} + +impl LlmRouter { + /// Build the router and warm the cache from the database (best-effort: + /// an empty/unreachable DB logs a warning and starts with an empty table). + pub async fn new(pool: Pool) -> Arc { + let router = Arc::new(Self::empty(Some(pool))); + if let Err(err) = router.reload().await { + tracing::warn!(%err, "llm router initial cache load failed, starting empty"); + } + router + } + + fn empty(pool: Option) -> Self { + Self { + pool, + cache: RwLock::new(Arc::new(ChannelCache::default())), + cursors: DashMap::new(), + cooldowns: DashMap::new(), + transient_fails: DashMap::new(), + persist_locks: DashMap::new(), + slots: DashMap::new(), + queues: QueueRegistry::new(), + token_inflight: DashMap::new(), + user_inflight: DashMap::new(), + latencies: DashMap::new(), + providers: DashMap::new(), + } + } + + /// Test constructor: preloaded cache, no DB (persistence disabled). + pub fn from_cache_for_test(cache: ChannelCache) -> Arc { + Arc::new(Self::empty(None).with_cache(cache)) + } + + fn with_cache(mut self, cache: ChannelCache) -> Self { + self.cache = RwLock::new(Arc::new(cache)); + self + } + + fn pool(&self) -> AppResult<&Pool> { + self.pool + .as_ref() + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("llm router has no database pool"))) + } + + /// Reload the whole cache from the database (§7.1 write-path / 5min + /// fallback rebuild). + pub async fn reload(&self) -> AppResult<()> { + let pool = self.pool()?.clone(); + let channels = crate::llm::models::channel::list_channels(&pool, None).await?; + let models = crate::llm::models::model::list_models(&pool, None, None).await?; + *self.cache.write().expect("llm cache lock") = + Arc::new(ChannelCache::build(channels, models)); + Ok(()) + } + + /// Reload one channel entry (incremental write-path invalidation, §7.1); + /// a vanished row removes the channel and its router-level state (§7.1 + /// cleanup of cursors/cooldowns/persist locks). + pub async fn reload_channel(&self, id: SnowflakeId) -> AppResult<()> { + let pool = self.pool()?.clone(); + let row = crate::llm::models::channel::find_by_id(&pool, id, None).await?; + { + let mut guard = self.cache.write().expect("llm cache lock"); + let cache = Arc::make_mut(&mut *guard); + match row { + Some(row) => { + // Capacity edits invalidate cached semaphores/providers; + // in-flight permits keep their old Arcs (safe). + self.slots.retain(|(ch, _), _| *ch != id); + self.providers.retain(|(ch, _), _| *ch != id); + let cached = ChannelCache::from_row(&row); + cache.channels.insert(id, Arc::new(cached)); + } + None => { + cache.channels.remove(&id); + self.cursors.remove(&id); + self.persist_locks.remove(&id); + self.cooldowns.retain(|(ch, _), _| *ch != id); + self.transient_fails.retain(|(ch, _), _| *ch != id); + self.slots.retain(|(ch, _), _| *ch != id); + self.providers.retain(|(ch, _), _| *ch != id); + } + } + cache.rebuild_routes(); + } + Ok(()) + } + + /// Directory lookup through the cache (DB row → built-in seed → None). + pub fn model_info(&self, tenant: &str, name: &str) -> Option> { + self.cache + .read() + .expect("llm cache lock") + .model_info(tenant, name) + } + + /// Clone the current cache snapshot (relay handlers read it per request). + pub fn cache_snapshot(&self) -> Arc { + self.cache.read().expect("llm cache lock").clone() + } + + /// Resolve a model to one ready endpoint (§7.2 selection at attempt 0 + + /// §6.1 key pick). Errors: unknown model / no available channel. + pub fn resolve(&self, ctx: &ResolveCtx<'_>, model: &str) -> AppResult { + let cache = self.cache.read().expect("llm cache lock").clone(); + let info = cache + .model_info(ctx.tenant, model) + .ok_or_else(|| AppError::BadRequest(format!("unknown model: {model}")))?; + let mut retry = RetryState::default(); + let (channel, key_index) = self.select_channel(&cache, ctx, model, 0, &mut retry)?; + Ok(ResolvedEndpoint { + channel_id: channel.id, + key_index, + base_url: channel.base_url.clone(), + api_key: channel.keys[key_index].plain.clone().unwrap_or_default(), + provider: channel.provider.clone(), + upstream_model: mapped_model(&channel, model), + model: info, + param_override: channel.param_override.clone(), + header_override: channel.header_override.clone(), + }) + } + + /// Channel + key selection (design §7.2). Steps: route lookup (public + /// model names only), alive filtering, priority tier (attempt-indexed, + /// empty tiers collapse), weighted random in tier, last-resort among + /// un-tried transient-cooled keys. + pub fn select_channel( + &self, + cache: &ChannelCache, + ctx: &ResolveCtx<'_>, + model: &str, + attempt: usize, + retry: &mut RetryState, + ) -> AppResult<(Arc, usize)> { + let route = RouteKey { + tenant: ctx.tenant.to_owned(), + group: ctx.group.unwrap_or("default").to_owned(), + model: model.to_owned(), + }; + let candidates: Vec> = match ctx.pin_channel { + Some(pin) => { + let ch = cache.channels.get(&pin).cloned().ok_or_else(|| { + AppError::BadRequest(format!("pinned channel {pin} not found")) + })?; + if ch.status != LlmChannelStatus::Enabled { + return Err(AppError::BadRequest(format!( + "pinned channel {pin} is not enabled" + ))); + } + vec![ch] + } + None => cache.candidates(&route).cloned().unwrap_or_default(), + }; + if candidates.is_empty() { + return Err(AppError::BadRequest(format!( + "no channel for model {model}" + ))); + } + + let alive: Vec> = candidates + .iter() + .filter(|ch| ch.status == LlmChannelStatus::Enabled && self.any_ready_key(ch)) + .cloned() + .collect(); + + if alive.is_empty() { + return self.last_resort(&candidates, retry, model); + } + + let mut tiers: Vec = alive.iter().map(|ch| ch.priority).collect(); + tiers.sort_unstable_by(|a, b| b.cmp(a)); + tiers.dedup(); + let tier = tiers[attempt.min(tiers.len() - 1)]; + let tier_channels: Vec> = alive + .iter() + .filter(|ch| ch.priority == tier) + .cloned() + .collect(); + + let channel = weighted_pick(&tier_channels); + let idx = self.select_key(&channel).ok_or_else(|| { + AppError::Internal(anyhow::anyhow!("selected channel has no ready key")) + })?; + Ok((channel, idx)) + } + + /// Pick one key inside a channel (design §6.1): active + not cooled; + /// polling advances the router-level cursor, random draws uniformly. + pub fn select_key(&self, channel: &CachedChannel) -> Option { + let ready: Vec = channel + .keys + .iter() + .enumerate() + .filter(|(i, k)| { + k.status == LlmKeyStatus::Active + && k.plain.is_some() + && !self.cooldown_active(channel.id, *i) + }) + .map(|(i, _)| i) + .collect(); + if ready.is_empty() { + return None; + } + match channel.key_mode { + LlmKeyMode::Random => Some(ready[(rand_u64() as usize) % ready.len()]), + LlmKeyMode::Polling => { + let cursor = self.cursors.entry(channel.id).or_default().clone(); + let len = channel.keys.len().max(1) as u64; + let start = cursor.load(Ordering::Relaxed) % len; + for step in 0..ready.len() as u64 { + let candidate = ((start + step) % len) as usize; + if ready.contains(&candidate) { + cursor.store((candidate as u64 + 1) % len, Ordering::Relaxed); + return Some(candidate); + } + } + Some(ready[0]) + } + } + } + + /// Last-resort fallback (design §7.2 step 5): only transient-cooled, + /// un-tried keys of enabled channels; the earliest-expiring wins; each + /// key is hard-tried at most once per request. Window-cooled and disabled + /// keys never participate. + fn last_resort( + &self, + candidates: &[Arc], + retry: &mut RetryState, + model: &str, + ) -> AppResult<(Arc, usize)> { + let mut best: Option<(Arc, usize, Instant)> = None; + let now = Instant::now(); + for ch in candidates { + if ch.status != LlmChannelStatus::Enabled { + continue; + } + for (idx, _) in ch.keys.iter().enumerate() { + if retry.tried.contains(&(ch.id, idx)) { + continue; + } + if let Some(entry) = self.cooldowns.get(&(ch.id, idx)) + && entry.kind == CooldownKind::Transient + && entry.until > now + { + let better = best + .as_ref() + .is_none_or(|(_, _, until)| entry.until < *until); + if better { + best = Some((ch.clone(), idx, entry.until)); + } + } + } + } + match best { + Some((ch, idx, _)) => { + retry.tried.insert((ch.id, idx)); + Ok((ch, idx)) + } + None => Err(AppError::BadRequest(format!( + "no available channel for model {model}" + ))), + } + } + + /// Whether a key's cooldown is currently active (expired entries are + /// pruned and their backoff counters reset). + fn cooldown_active(&self, channel_id: SnowflakeId, idx: usize) -> bool { + let active = self + .cooldowns + .get(&(channel_id, idx)) + .is_some_and(|e| e.until > Instant::now()); + if active { + return true; + } + self.cooldowns.remove(&(channel_id, idx)); + self.transient_fails.remove(&(channel_id, idx)); + false + } + + fn any_ready_key(&self, ch: &CachedChannel) -> bool { + ch.keys.iter().enumerate().any(|(i, k)| { + k.status == LlmKeyStatus::Active && k.plain.is_some() && !self.cooldown_active(ch.id, i) + }) + } + + /// Report an upstream failure (design §6.2 three-way classification): + /// memory state mutates synchronously, DB persistence is spawned behind + /// the per-channel lock. + pub fn report_failure(&self, channel_id: SnowflakeId, key_index: usize, f: &UpstreamFailure) { + match classify_failure(f) { + FailureKind::Window => { + let mut secs = f + .retry_after + .map(|d| d.as_secs()) + .unwrap_or(WINDOW_DEFAULT_SECS) + .clamp(1, WINDOW_MAX_SECS); + secs = secs.max(1); + self.cooldowns.insert( + (channel_id, key_index), + CooldownUntil { + until: Instant::now() + Duration::from_secs(secs), + kind: CooldownKind::Window, + reason: f.message.chars().take(200).collect(), + }, + ); + tracing::warn!(channel = %channel_id, key_index, reset_secs = secs, "llm key window-limited"); + } + FailureKind::Arrears => { + self.set_key_state_mem(channel_id, key_index, LlmKeyStatus::Disabled, &f.message); + tracing::warn!(channel = %channel_id, key_index, "llm key disabled (arrears/auth)"); + self.spawn_persist_disable(channel_id, key_index, &f.message); + } + FailureKind::Transient => { + let count = { + let mut c = self + .transient_fails + .entry((channel_id, key_index)) + .or_insert(0); + *c += 1; + *c + }; + let mut secs = TRANSIENT_BASE_SECS + .saturating_mul(1u64 << (count - 1).min(4)) + .min(TRANSIENT_MAX_SECS); + if let Some(ra) = f.retry_after { + secs = secs.max(ra.as_secs().clamp(1, TRANSIENT_MAX_SECS)); + } + self.cooldowns.insert( + (channel_id, key_index), + CooldownUntil { + until: Instant::now() + Duration::from_secs(secs), + kind: CooldownKind::Transient, + reason: f.message.chars().take(200).collect(), + }, + ); + } + } + } + + /// Report success: clear the transient backoff counter and any lingering + /// transient cooldown. + pub fn report_success(&self, channel_id: SnowflakeId, key_index: usize) { + self.transient_fails.remove(&(channel_id, key_index)); + if self + .cooldowns + .get(&(channel_id, key_index)) + .is_some_and(|e| e.kind == CooldownKind::Transient) + { + self.cooldowns.remove(&(channel_id, key_index)); + } + } + + /// Acquire the per-channel persistence lock (admin write paths share this + /// serialization point with failure persists, design §6.2). + pub async fn persist_lock(&self, id: SnowflakeId) -> tokio::sync::OwnedMutexGuard<()> { + let lock = self.persist_locks.entry(id).or_default().clone(); + lock.lock_owned().await + } + + /// Invalidate the model-directory cache after directory writes (§7.1: + /// price/status changes take effect immediately, no 5min staleness + /// window). Full reload — cheap at directory scale. + pub async fn invalidate_model_cache(&self) { + if let Err(err) = self.reload().await { + tracing::error!(%err, "llm directory cache reload failed"); + } + } + + /// Snapshot of live cooldowns (admin/status endpoints). + pub fn cooldown_snapshot(&self) -> Vec<(SnowflakeId, usize, CooldownKind, Duration, String)> { + let now = Instant::now(); + self.cooldowns + .iter() + .filter(|e| e.value().until > now) + .map(|e| { + ( + SnowflakeId(*e.key().0), + e.key().1, + e.value().kind, + e.value().until.saturating_duration_since(now), + e.value().reason.clone(), + ) + }) + .collect() + } + + /// Enable one key in memory (admin action / future health recovery, + /// design §6.3: a channel with any active key returns to `enabled`). + pub fn enable_key_mem(&self, channel_id: SnowflakeId, key_index: usize) { + self.set_key_state_mem(channel_id, key_index, LlmKeyStatus::Active, ""); + } + + fn set_key_state_mem( + &self, + channel_id: SnowflakeId, + key_index: usize, + status: LlmKeyStatus, + reason: &str, + ) { + { + let mut guard = self.cache.write().expect("llm cache lock"); + let cache = Arc::make_mut(&mut *guard); + let Some(channel) = cache.channels.get(&channel_id) else { + return; + }; + let mut cloned = (**channel).clone(); + if key_index >= cloned.keys.len() { + return; + } + cloned.keys[key_index].status = status; + let any_active = cloned.keys.iter().any(|k| k.status == LlmKeyStatus::Active); + if status == LlmKeyStatus::Disabled && !any_active { + cloned.status = LlmChannelStatus::AutoDisabled; + tracing::warn!(channel = %channel_id, "llm channel auto-disabled (all keys dead): {reason}"); + } else if status == LlmKeyStatus::Active + && cloned.status == LlmChannelStatus::AutoDisabled + { + cloned.status = LlmChannelStatus::Enabled; + } + cache.channels.insert(channel_id, Arc::new(cloned)); + cache.rebuild_routes(); + } + if status == LlmKeyStatus::Disabled { + self.cooldowns.remove(&(channel_id, key_index)); + self.transient_fails.remove(&(channel_id, key_index)); + } + } + + fn spawn_persist_disable(&self, channel_id: SnowflakeId, key_index: usize, reason: &str) { + let Some(pool) = self.pool.clone() else { + return; + }; + let lock = self.persist_locks.entry(channel_id).or_default().clone(); + let reason = reason.to_owned(); + tokio::spawn(async move { + let _guard = lock.lock().await; + if let Err(err) = crate::llm::models::channel::update_key_status( + &pool, + None, + channel_id, + key_index, + LlmKeyStatus::Disabled, + Some(&reason), + ) + .await + { + tracing::error!(%err, channel = %channel_id, "persist llm key disable failed"); + } + }); + } +} + +fn weighted_pick(channels: &[Arc]) -> Arc { + if channels.len() == 1 { + return channels[0].clone(); + } + let weights: Vec = channels.iter().map(|ch| ch.weight.max(1) as u64).collect(); + let total: u64 = weights.iter().sum(); + let mut pick = rand_u64() % total.max(1); + for (ch, w) in channels.iter().zip(weights) { + if pick < w { + return ch.clone(); + } + pick = pick.saturating_sub(w); + } + channels[0].clone() +} + +/// Apply a channel's model mapping to a public model name (design §7.2: +/// mapping applies only after channel selection; unmapped names pass through). +pub fn mapped_model(channel: &CachedChannel, public: &str) -> String { + if let Some(serde_json::Value::Object(map)) = &channel.model_mapping + && let Some(serde_json::Value::String(upstream)) = map.get(public) + { + return upstream.clone(); + } + public.to_owned() +} + +#[cfg(test)] +impl LlmRouter { + /// Test helper mirroring `report_failure`'s arrears memory mutation + /// without classification. + fn disable_key_for_test(&self, channel_id: SnowflakeId, key_index: usize) { + self.set_key_state_mem(channel_id, key_index, LlmKeyStatus::Disabled, "test"); + } +} + +/// RAII upstream slot: releases the semaphore permit, decrements the +/// per-token/per-user in-flight counters and fan-out notifies every model +/// queue the channel serves (design §7.6 槽生命周期 — the upstream counts a +/// streaming connection until the stream terminates, so the guard must live +/// until then). +pub struct SlotPermit { + router: Arc, + tenant: String, + groups: Vec, + models: Vec, + channel_id: SnowflakeId, + _sem: Option, + token: Option, + user: Option, +} + +impl Drop for SlotPermit { + fn drop(&mut self) { + // permit dropped implicitly; counters decremented; queues woken. + if let Some(token) = self.token + && let Some(c) = self.router.token_inflight.get(&token) + { + c.fetch_sub(1, Ordering::AcqRel); + } + if let Some(user) = self.user + && let Some(c) = self.router.user_inflight.get(&user) + { + c.fetch_sub(1, Ordering::AcqRel); + } + self.router + .queues + .notify_channel(&self.tenant, &self.groups, &self.models); + let _ = self.channel_id; + } +} + +/// Slot acquisition failure. +pub enum SlotError { + /// No route / no candidate at all — bubble the selection error. + NoRoute(AppError), + /// Admission rejection: 503 (queue full) or 429 (wait timeout / + /// caller-level concurrency cap) with an optional Retry-After hint. + Rejected { + status: u16, + message: String, + retry_after_secs: Option, + }, +} + +/// Inputs of [`LlmRouter::acquire_slot`] (design §7.6). +pub struct SlotRequest<'a> { + pub cache: &'a ChannelCache, + pub ctx: &'a ResolveCtx<'a>, + pub model: &'a str, + pub tier: QueueTier, + /// (token_id, user_id) — per-caller concurrency accounting. + pub caller: Option<(SnowflakeId, SnowflakeId)>, + pub body_bytes: usize, + /// Per-request wait budget shared across retry attempts (§7.6). + pub deadline: Instant, +} + +fn route_key_of(ctx: &ResolveCtx<'_>, model: &str) -> RouteKey { + RouteKey { + tenant: ctx.tenant.to_owned(), + group: ctx.group.unwrap_or("default").to_owned(), + model: model.to_owned(), + } +} + +impl LlmRouter { + fn try_inflight( + map: &DashMap>, + id: SnowflakeId, + limit: i64, + what: &str, + ) -> Result<(), SlotError> { + let counter = map + .entry(id) + .or_insert_with(|| Arc::new(AtomicI64::new(0))) + .clone(); + let now = counter.fetch_add(1, Ordering::AcqRel) + 1; + if now > limit { + counter.fetch_sub(1, Ordering::AcqRel); + return Err(SlotError::Rejected { + status: 429, + message: format!("{what} concurrency limit reached ({limit})"), + retry_after_secs: Some(5), + }); + } + Ok(()) + } + + fn rollback_inflight(map: &DashMap>, id: Option) { + if let Some(id) = id + && let Some(c) = map.get(&id) + { + c.fetch_sub(1, Ordering::AcqRel); + } + } + + fn route_has_active_key(&self, cache: &ChannelCache, route: &RouteKey) -> bool { + cache.candidates(route).is_some_and(|cands| { + cands.iter().any(|ch| { + ch.status == LlmChannelStatus::Enabled + && ch.keys.iter().any(|k| k.status == LlmKeyStatus::Active) + }) + }) + } + + /// Record a request latency sample into the per-(tenant, model) EWMA + /// (Retry-After estimation source, design §7.6). + pub fn record_latency(&self, tenant: &str, model: &str, secs: f64) { + let entry = self + .latencies + .entry((tenant.to_owned(), model.to_owned())) + .or_insert_with(|| Arc::new(std::sync::Mutex::new(30.0))); + if let Ok(mut ewma) = entry.lock() { + *ewma = *ewma * 0.8 + secs.clamp(0.05, 600.0) * 0.2; + } + } + + /// Average request latency for Retry-After estimates (default 30s). + pub fn avg_latency_secs(&self, tenant: &str, model: &str) -> f64 { + self.latencies + .get(&(tenant.to_owned(), model.to_owned())) + .and_then(|e| e.lock().ok().map(|g| *g)) + .unwrap_or(30.0) + } + + /// Total queued waiters (metrics). + pub fn total_waiting(&self) -> usize { + self.queues.total_waiting() + } + + /// Acquire one upstream slot for a request (design §7.6): direct + /// fast-path when a key has spare capacity, otherwise the FIFO wait + /// queue under dual bounds. `deadline` is the per-request wait budget + /// (shared across retry attempts). + pub async fn acquire_slot( + self: &Arc, + req: &SlotRequest<'_>, + retry: &mut RetryState, + ) -> Result<(Arc, usize, SlotPermit), SlotError> { + let SlotRequest { + cache, + ctx, + model, + tier, + caller, + body_bytes, + deadline, + } = *req; + let (token, user) = match caller { + Some((t, u)) => { + Self::try_inflight(&self.token_inflight, t, TOKEN_MAX_CONCURRENT, "token")?; + Self::try_inflight(&self.user_inflight, u, USER_MAX_CONCURRENT, "user")?; + (Some(t), Some(u)) + } + None => (None, None), + }; + let rollback = |token, user| { + Self::rollback_inflight(&self.token_inflight, token); + Self::rollback_inflight(&self.user_inflight, user); + }; + + let route = route_key_of(ctx, model); + let mut spins: u32 = 0; + loop { + match self.select_channel(cache, ctx, model, 0, retry) { + Ok((ch, idx)) => { + let capacity = ch.keys[idx].max_concurrency.or(ch.default_max_concurrency); + match capacity { + None => { + let permit = SlotPermit { + router: self.clone(), + tenant: ch.tenant.clone(), + groups: ch.groups.clone(), + models: ch.models.clone(), + channel_id: ch.id, + _sem: None, + token, + user, + }; + return Ok((ch, idx, permit)); + } + Some(cap) => { + let sem = self + .slots + .entry((ch.id, idx)) + .or_insert_with(|| Arc::new(Semaphore::new(cap.max(1) as usize))) + .clone(); + if let Ok(permit) = sem.clone().try_acquire_owned() { + let guard = SlotPermit { + router: self.clone(), + tenant: ch.tenant.clone(), + groups: ch.groups.clone(), + models: ch.models.clone(), + channel_id: ch.id, + _sem: Some(permit), + token, + user, + }; + return Ok((ch, idx, guard)); + } + } + } + } + Err(err) => { + if !self.route_has_active_key(cache, &route) { + rollback(token, user); + return Err(SlotError::NoRoute(err)); + } + } + } + // All keys busy (or raced): enter the FIFO queue for this route. + let queue = self.queues.get_or_create(&route); + match queue.enqueue(body_bytes, tier.max_waiting, tier.max_waiting_bytes) { + Err(full) => { + rollback(token, user); + return Err(SlotError::Rejected { + status: 503, + message: format!( + "overloaded: {} requests already waiting (max {})", + full.waiting, tier.max_waiting + ), + retry_after_secs: None, + }); + } + Ok(ticket) => { + match ticket.wait(deadline).await { + Ok(()) => { + // Turn taken: retry selection while a slot should + // be free; bound hot spins for race safety. + spins += 1; + if spins > 64 { + rollback(token, user); + return Err(SlotError::Rejected { + status: 429, + message: "could not acquire a slot after queue turn".to_owned(), + retry_after_secs: Some(2), + }); + } + } + Err(timeout) => { + rollback(token, user); + let retry_after = ((timeout.position as f64 + 1.0) + * self.avg_latency_secs(ctx.tenant, model)) + .ceil() as u64; + return Err(SlotError::Rejected { + status: 429, + message: "queue wait budget exhausted".to_owned(), + retry_after_secs: Some(retry_after.max(1)), + }); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::cache::{CachedKey, ChannelCache}; + use crate::llm::models::channel::{LlmChannel, LlmKeyEntry, keys_value}; + + fn row(id: i64, priority: i64, keys: &[&str], models: &str) -> LlmChannel { + let entries: Vec = keys + .iter() + .map(|k| LlmKeyEntry { + key: (*k).to_owned(), + status: LlmKeyStatus::Active, + disabled_reason: None, + disabled_at: None, + max_concurrency: None, + }) + .collect(); + LlmChannel { + id: SnowflakeId(id), + tenant_id: Some("default".to_owned()), + name: format!("ch-{id}"), + provider: "openai".to_owned(), + base_url: "https://example.test/v1".to_owned(), + api_keys: keys_value(&entries), + key_mode: LlmKeyMode::Polling, + status: LlmChannelStatus::Enabled, + models: models.to_owned(), + model_mapping: None, + priority, + weight: 0, + channel_groups: "default".to_owned(), + auto_ban: true, + param_override: None, + header_override: None, + config: None, + used_quota: 0, + test_model: None, + test_time: None, + response_time: None, + created_at: crate::utils::tz::now_utc(), + updated_at: crate::utils::tz::now_utc(), + } + } + + fn router(rows: Vec) -> Arc { + // Tests bypass AES: build the cache directly from pre-decrypted keys. + let mut cache = ChannelCache::default(); + for row in rows { + let mut cached = ChannelCache::from_row(&row); + cached.keys = row + .api_keys + .as_array() + .map(|arr| { + arr.iter() + .enumerate() + .map(|(i, _)| CachedKey { + plain: Some(format!("key-{}-{}", row.id.0, i)), + status: LlmKeyStatus::Active, + max_concurrency: None, + }) + .collect() + }) + .unwrap_or_default(); + cache.channels.insert(cached.id, Arc::new(cached)); + } + cache.rebuild_routes(); + LlmRouter::from_cache_for_test(cache) + } + + fn ctx<'a>() -> ResolveCtx<'a> { + ResolveCtx { + tenant: "default", + group: None, + pin_channel: None, + } + } + + #[test] + fn polling_rotates_keys() { + let r = router(vec![row(1, 0, &["a", "b", "c"], "m1")]); + let ch = r.cache.read().unwrap().channels[&SnowflakeId(1)].clone(); + let seq: Vec = (0..6).map(|_| r.select_key(&ch).unwrap()).collect(); + assert_eq!(seq, vec![0, 1, 2, 0, 1, 2]); + } + + #[test] + fn disabled_key_never_selected() { + let r = router(vec![row(1, 0, &["a", "b"], "m1")]); + r.disable_key_for_test(SnowflakeId(1), 0); + let ch = r.cache.read().unwrap().channels[&SnowflakeId(1)].clone(); + for _ in 0..10 { + assert_eq!(r.select_key(&ch).unwrap(), 1); + } + } + + #[test] + fn tier_escalation_and_collapse() { + let r = router(vec![row(1, 10, &["a"], "m1"), row(2, 1, &["b"], "m1")]); + let mut retry = RetryState::default(); + let (a, _) = r + .select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 0, + &mut retry, + ) + .unwrap(); + assert_eq!(a.id, SnowflakeId(1)); + let (b, _) = r + .select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 1, + &mut retry, + ) + .unwrap(); + assert_eq!(b.id, SnowflakeId(2)); + + // Tier 0 channel fully cooled → attempt 0 collapses onto tier 1. + r.report_failure( + SnowflakeId(1), + 0, + &UpstreamFailure { + status: Some(429), + message: "rate limited".into(), + retry_after: None, + }, + ); + let mut retry = RetryState::default(); + let (c, _) = r + .select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 0, + &mut retry, + ) + .unwrap(); + assert_eq!(c.id, SnowflakeId(2)); + } + + #[test] + fn last_resort_transient_once_per_request() { + let r = router(vec![row(1, 0, &["a"], "m1")]); + r.report_failure( + SnowflakeId(1), + 0, + &UpstreamFailure { + status: Some(429), + message: "rate limited".into(), + retry_after: None, + }, + ); + let mut retry = RetryState::default(); + let (ch, idx) = r + .select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 0, + &mut retry, + ) + .expect("last-resort picks the transient-cooled key"); + assert_eq!((ch.id, idx), (SnowflakeId(1), 0)); + assert!( + r.select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 1, + &mut retry + ) + .is_err(), + "same key must not be hard-tried twice per request" + ); + let mut fresh = RetryState::default(); + assert!( + r.select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 0, + &mut fresh + ) + .is_ok(), + "a new request may last-resort again" + ); + } + + #[test] + fn window_cooldown_is_not_last_resorted() { + let r = router(vec![row(1, 0, &["a"], "m1")]); + r.report_failure( + SnowflakeId(1), + 0, + &UpstreamFailure { + status: Some(429), + message: "usage limit reached, will reset at 5pm".into(), + retry_after: None, + }, + ); + let mut retry = RetryState::default(); + assert!( + r.select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 0, + &mut retry + ) + .is_err(), + "window-cooled keys must fast-fail, not hard-try" + ); + } + + #[test] + fn arrears_disables_key_and_channel() { + let r = router(vec![row(1, 0, &["a"], "m1")]); + r.report_failure( + SnowflakeId(1), + 0, + &UpstreamFailure { + status: Some(401), + message: "invalid api key".into(), + retry_after: None, + }, + ); + let ch = r.cache.read().unwrap().channels[&SnowflakeId(1)].clone(); + assert_eq!(ch.keys[0].status, LlmKeyStatus::Disabled); + assert_eq!(ch.status, LlmChannelStatus::AutoDisabled); + let mut retry = RetryState::default(); + assert!( + r.select_channel( + &r.cache.read().unwrap().clone(), + &ctx(), + "m1", + 0, + &mut retry + ) + .is_err() + ); + } + + #[test] + fn reset_semantics_beats_403_status() { + let f = UpstreamFailure { + status: Some(403), + message: "You have exhausted your premium request allowance; will reset at 2026-09-11" + .into(), + retry_after: None, + }; + assert_eq!(classify_failure(&f), FailureKind::Window); + } + + #[test] + fn classification_table() { + let arrears_kw = UpstreamFailure { + status: Some(429), + message: "You exceeded your current quota, check your plan".into(), + retry_after: None, + }; + assert_eq!(classify_failure(&arrears_kw), FailureKind::Arrears); + let transient = UpstreamFailure { + status: Some(500), + message: "upstream boom".into(), + retry_after: None, + }; + assert_eq!(classify_failure(&transient), FailureKind::Transient); + let small_retry_after = UpstreamFailure { + status: Some(429), + message: "slow down".into(), + retry_after: Some(Duration::from_secs(3)), + }; + assert_eq!(classify_failure(&small_retry_after), FailureKind::Transient); + let big_retry_after = UpstreamFailure { + status: Some(429), + message: "slow down".into(), + retry_after: Some(Duration::from_secs(3600)), + }; + assert_eq!(classify_failure(&big_retry_after), FailureKind::Window); + } + + #[test] + fn builtin_model_seed_resolves() { + let r = router(vec![]); + assert!(r.model_info("default", "gpt-4o").is_some()); + assert!(r.model_info("default", "totally-unknown-model").is_none()); + } + + #[test] + fn success_clears_transient_state() { + let r = router(vec![row(1, 0, &["a"], "m1")]); + r.report_failure( + SnowflakeId(1), + 0, + &UpstreamFailure { + status: Some(429), + message: "rl".into(), + retry_after: None, + }, + ); + r.report_success(SnowflakeId(1), 0); + let ch = r.cache.read().unwrap().channels[&SnowflakeId(1)].clone(); + assert!(r.select_key(&ch).is_some()); + } +} diff --git a/crates/core/src/llm/tests_db.rs b/crates/core/src/llm/tests_db.rs new file mode 100644 index 00000000..97d65c04 --- /dev/null +++ b/crates/core/src/llm/tests_db.rs @@ -0,0 +1,307 @@ +//! DB-backed tests for the llm module: relay auth chain, quota billing and +//! key-state persistence — each test gets its own SQLite in-memory schema +//! via `crate::test_pool!()`. + +use crate::commands::CreateUserCmd; +use crate::llm::models::token::{self, LlmTokenStatus}; +use crate::llm::relay::auth; +use crate::models::user::{self, UserStatus}; +use crate::types::snowflake_id::SnowflakeId; + +async fn pool() -> crate::db::Pool { + crate::test_pool!() +} + +async fn make_user(p: &crate::db::Pool, status: UserStatus) -> SnowflakeId { + let username = format!("llm-test-{}", crate::utils::id::new_id()); + let cmd = CreateUserCmd::new(username, crate::models::user::RegisteredVia::Email); + let u = user::create(p, &cmd, None).await.expect("user"); + if status != UserStatus::Active { + user::update_status(p, u.id, status, None) + .await + .expect("status"); + } + u.id +} + +async fn make_token( + p: &crate::db::Pool, + user_id: SnowflakeId, + remain: i64, + unlimited: bool, +) -> (String, crate::llm::models::token::LlmToken) { + let plain = auth::generate_sk(); + let t = token::create_token( + p, + None, + user_id, + "test", + &crate::services::api_token::hash_token(&plain), + remain, + unlimited, + None, + None, + None, + ) + .await + .expect("token"); + (plain, t) +} + +#[tokio::test] +async fn auth_full_chain_happy_path() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let (plain, _t) = make_token(&p, uid, 100, false).await; + let id = auth::authenticate(&p, &plain, "1.2.3.4") + .await + .expect("auth ok"); + assert_eq!(id.token.user_id, uid); +} + +#[tokio::test] +async fn auth_rejects_unknown_key_and_banned_owner() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let (_plain, _t) = make_token(&p, uid, 100, false).await; + assert!( + auth::authenticate(&p, "sk-does-not-exist", "") + .await + .is_err() + ); + + let banned = make_user(&p, UserStatus::Banned).await; + let (plain_b, _tb) = make_token(&p, banned, 100, false).await; + assert!( + auth::authenticate(&p, &plain_b, "").await.is_err(), + "banned owner kills the token (§9.1)" + ); +} + +#[tokio::test] +async fn auth_lazy_flips_expired_and_exhausted() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + + // Expired: expired_at in the past → 401 + status flipped persistently. + let (plain_e, t_e) = make_token(&p, uid, 100, false).await; + let past = crate::utils::tz::now_utc() - chrono::Duration::seconds(10); + let sql = format!( + "UPDATE llm_tokens SET expired_at = {} WHERE id = {}", + crate::db::Driver::ph(1), + crate::db::Driver::ph(2) + ); + use crate::db::driver::DbDriver; + sqlx::query(crate::db::safe_sql(&sql)) + .bind(past) + .bind(t_e.id) + .execute(&p) + .await + .expect("set expired"); + assert!(auth::authenticate(&p, &plain_e, "").await.is_err()); + let after = token::find_by_id(&p, t_e.id, None).await.unwrap().unwrap(); + assert_eq!(after.status, LlmTokenStatus::Expired, "lazy flip"); + + // Exhausted: zero remaining quota → 401 + flipped. + let (plain_x, t_x) = make_token(&p, uid, 0, false).await; + assert!(auth::authenticate(&p, &plain_x, "").await.is_err()); + let after_x = token::find_by_id(&p, t_x.id, None).await.unwrap().unwrap(); + assert_eq!(after_x.status, LlmTokenStatus::Exhausted); +} + +#[tokio::test] +async fn auth_ip_allowlist_enforced() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let plain = auth::generate_sk(); + token::create_token( + &p, + None, + uid, + "ip", + &crate::services::api_token::hash_token(&plain), + 100, + false, + None, + None, + Some("10.1.0.0/16\n127.0.0.1"), + ) + .await + .expect("token"); + assert!(auth::authenticate(&p, &plain, "10.1.2.3").await.is_ok()); + assert!(auth::authenticate(&p, &plain, "127.0.0.1").await.is_ok()); + assert!(auth::authenticate(&p, &plain, "8.8.8.8").await.is_err()); +} + +#[tokio::test] +async fn billing_preconsume_settle_refund_cycle() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let (_plain, t) = make_token(&p, uid, 1000, false).await; + + // Pre-consume 300 → remain 700. + let charge = crate::llm::relay::billing::pre_consume(&p, t.id, false, 300) + .await + .expect("hold"); + assert_eq!(charge.pre_consumed, 300); + let mid = token::find_by_id(&p, t.id, None).await.unwrap().unwrap(); + assert_eq!(mid.remain_quota, 700); + + // Actual 100 → refund 200 → remain 900, used tracks the actual 100. + crate::llm::relay::billing::settle(&p, &charge, 100).await; + let after = token::find_by_id(&p, t.id, None).await.unwrap().unwrap(); + assert_eq!(after.remain_quota, 900); + assert_eq!(after.used_quota, 100); + + // Full refund restores to the original hold. + let charge2 = crate::llm::relay::billing::pre_consume(&p, t.id, false, 250) + .await + .expect("hold2"); + crate::llm::relay::billing::refund_all(&p, &charge2).await; + let final_row = token::find_by_id(&p, t.id, None).await.unwrap().unwrap(); + assert_eq!(final_row.remain_quota, 900); +} + +#[tokio::test] +async fn billing_preconsume_rejects_insufficient() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let (_plain, t) = make_token(&p, uid, 50, false).await; + let err = crate::llm::relay::billing::pre_consume(&p, t.id, false, 300) + .await + .expect_err("must reject"); + assert!(matches!( + err, + crate::errors::app_error::AppError::TooManyRequests(_) + )); + // Overdraw protection: unchanged balance. + let row = token::find_by_id(&p, t.id, None).await.unwrap().unwrap(); + assert_eq!(row.remain_quota, 50); +} + +#[tokio::test] +async fn billing_under_hold_top_up_collects_or_logs() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let (_plain, t) = make_token(&p, uid, 500, false).await; + let charge = crate::llm::relay::billing::pre_consume(&p, t.id, false, 100) + .await + .expect("hold"); + // Actual 150 > hold 100 → collects the 50 diff. + crate::llm::relay::billing::settle(&p, &charge, 150).await; + let row = token::find_by_id(&p, t.id, None).await.unwrap().unwrap(); + assert_eq!(row.remain_quota, 350); +} + +#[tokio::test] +async fn billing_unlimited_token_skips_holds() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let (_plain, t) = make_token(&p, uid, 0, true).await; + let charge = crate::llm::relay::billing::pre_consume(&p, t.id, true, 12345) + .await + .expect("no hold for unlimited"); + crate::llm::relay::billing::settle(&p, &charge, 999).await; + crate::llm::relay::billing::refund_all(&p, &charge).await; + let row = token::find_by_id(&p, t.id, None).await.unwrap().unwrap(); + assert_eq!(row.remain_quota, 0, "unlimited never touches remain_quota"); +} + +#[tokio::test] +async fn key_status_persists_in_pool_json() { + let p = pool().await; + use crate::llm::models::channel::{self, LlmKeyStatus, NewChannel}; + let entries = vec![crate::llm::models::channel::LlmKeyEntry { + key: "enc:v1:fake".to_owned(), + status: LlmKeyStatus::Active, + disabled_reason: None, + disabled_at: None, + max_concurrency: None, + }]; + let row = channel::create_channel( + &p, + None, + NewChannel { + name: format!("ch-{}", crate::utils::id::new_id()), + provider: "openai".to_owned(), + base_url: "https://x.test/v1".to_owned(), + api_keys: channel::keys_value(&entries), + key_mode: crate::llm::models::channel::LlmKeyMode::Polling, + models: "m1".to_owned(), + model_mapping: None, + priority: 0, + weight: 0, + channel_groups: "default".to_owned(), + auto_ban: true, + param_override: None, + header_override: None, + config: None, + test_model: None, + }, + ) + .await + .expect("channel"); + + let updated = channel::update_key_status( + &p, + None, + row.id, + 0, + LlmKeyStatus::Disabled, + Some("401 unauthorized"), + ) + .await + .expect("update key"); + assert_eq!(updated[0].status, LlmKeyStatus::Disabled); + assert_eq!( + updated[0].disabled_reason.as_deref(), + Some("401 unauthorized") + ); + + // Reload from DB: the JSON pool persisted the mutation. + let reloaded = channel::find_by_id(&p, row.id, None) + .await + .unwrap() + .unwrap(); + let entries = channel::parse_keys(&reloaded); + assert_eq!(entries[0].status, LlmKeyStatus::Disabled); +} + +#[tokio::test] +async fn log_insert_roundtrip() { + let p = pool().await; + let uid = make_user(&p, UserStatus::Active).await; + let (_plain, t) = make_token(&p, uid, 100, false).await; + crate::llm::models::log::insert_log( + &p, + crate::llm::models::log::NewLog { + tenant_id: Some("default".to_owned()), + user_id: Some(uid), + token_id: Some(t.id), + source: crate::llm::models::log::LogSource::Relay, + model_name: "gpt-4o".to_owned(), + prompt_tokens: 10, + completion_tokens: 5, + quota: 42, + is_stream: true, + ..Default::default() + }, + ) + .await + .expect("insert log"); + let (items, total) = crate::llm::models::log::query_paged( + &p, + None, + &crate::llm::models::log::LogFilters { + token_id: Some(t.id.to_string()), + ..Default::default() + }, + 1, + 10, + ) + .await + .expect("query"); + assert_eq!(total, 1); + assert_eq!(items[0].model_name, "gpt-4o"); + assert_eq!(items[0].quota, 42); +} diff --git a/crates/core/src/server.rs b/crates/core/src/server.rs index 120de667..f89a5560 100644 --- a/crates/core/src/server.rs +++ b/crates/core/src/server.rs @@ -273,6 +273,10 @@ async fn build_app( api_v1 = api_v1.merge(crate::flows::handler::routes(&mut registry, config)); } + if config.builtins.llm_gateway { + api_v1 = api_v1.merge(crate::llm::handler::routes(&mut registry, config)); + } + api_v1 = crate::content_type::handler::register_content_routes( api_v1, &state.content_type_registry, @@ -478,6 +482,11 @@ async fn build_app( .route("/feed.xml", get(rss::feed)) .route("/api/v1/info", get(server_info_handler)) .nest(crate::constants::API_PREFIX, api_v1) + .merge(if config.builtins.llm_gateway { + crate::llm::relay::routes() + } else { + axum::Router::new() + }) .nest_service("/uploads", ServeDir::new(&upload_dir)) .nest_service("/static", ServeDir::new(&static_dir)) .nest_service("/widget", ServeDir::new(&config.widget_dir)) diff --git a/crates/core/src/worker/handlers.rs b/crates/core/src/worker/handlers.rs index 454acb57..5b0c841c 100644 --- a/crates/core/src/worker/handlers.rs +++ b/crates/core/src/worker/handlers.rs @@ -75,6 +75,7 @@ pub mod ingress_pull; pub mod ingress_retry; pub mod itg_egress_cleanup; pub mod kb; +pub mod llm_health; pub mod order_expire; pub mod payment_expire; pub mod payment_reconcile; diff --git a/crates/core/src/worker/handlers/llm_health.rs b/crates/core/src/worker/handlers/llm_health.rs new file mode 100644 index 00000000..a66d6291 --- /dev/null +++ b/crates/core/src/worker/handlers/llm_health.rs @@ -0,0 +1,165 @@ +//! Cron handler: llm_channel_health — probe channels and auto-recover +//! auto-disabled ones (design §7.5): every auto-disabled channel plus a +//! sampled slice of enabled channels gets a `max_tokens=1` ping on its +//! `test_model`; a success re-enables exactly the tested key (§6.3 +//! per-key recovery semantics). + +use std::sync::Arc; +use std::time::Instant; + +use crate::config::app::AppConfig; +use crate::errors::app_error::AppResult; +use crate::llm::models::channel::{LlmChannel, LlmChannelStatus, LlmKeyStatus}; +use crate::worker::handler::HandlerMeta; +use crate::worker::{Job, JobHandler}; + +/// Metadata for the admin task menu. +pub const META: HandlerMeta = HandlerMeta { + id: "llm_channel_health", + display_name: "LLM Channel Health Probe", + description: "Pings auto-disabled + sampled enabled channels (max_tokens=1) and re-enables recovered keys", + category: "AI / LLM", + params_schema: None, + icon: Some("activity"), +}; + +pub struct LlmHealthHandler { + pool: crate::db::Pool, + #[allow(dead_code)] + config: Arc, +} + +impl LlmHealthHandler { + /// Creates the handler. + #[must_use] + pub fn new(pool: crate::db::Pool, config: Arc) -> Self { + Self { pool, config } + } +} + +#[async_trait::async_trait] +impl JobHandler for LlmHealthHandler { + async fn handle(&self, job: &Job) -> AppResult<()> { + let _ = job; + let pool = self.pool.clone(); + let channels = crate::llm::models::channel::list_channels(&pool, None).await?; + let mut probed = 0usize; + let mut recovered = 0usize; + for row in channels { + let auto_disabled = row.status == LlmChannelStatus::AutoDisabled; + // Sample: every auto-disabled channel + every 10th enabled one. + let should_probe = auto_disabled || probed.is_multiple_of(10); + probed += 1; + if !should_probe { + continue; + } + match probe_channel(&row).await { + ProbeOutcome::Skip => continue, + ProbeOutcome::Failed(detail) => { + tracing::debug!(channel = %row.id, %detail, "llm health probe failed"); + } + ProbeOutcome::Ok => { + if auto_disabled { + // Re-enable the first disabled key only, then flip the + // channel back on (§6.3: only the tested key recovers). + let entries = crate::llm::models::channel::parse_keys(&row); + if let Some(idx) = entries + .iter() + .position(|e| e.status == LlmKeyStatus::Disabled) + { + let _ = crate::llm::models::channel::update_key_status( + &pool, + None, + row.id, + idx, + LlmKeyStatus::Active, + Some("health probe ok"), + ) + .await; + } + let _ = crate::llm::models::channel::update_status( + &pool, + None, + row.id, + LlmChannelStatus::Enabled, + ) + .await; + recovered += 1; + } + } + } + } + tracing::info!(probed, recovered, "llm channel health sweep done"); + Ok(()) + } +} + +enum ProbeOutcome { + Ok, + Failed(String), + Skip, +} + +async fn probe_channel(row: &LlmChannel) -> ProbeOutcome { + let entries = crate::llm::models::channel::parse_keys(row); + let Some(entry) = entries + .iter() + .find(|e| e.status == LlmKeyStatus::Active) + .or_else(|| entries.iter().find(|e| e.status == LlmKeyStatus::Disabled)) + else { + return ProbeOutcome::Skip; + }; + let Some(key) = crate::llm::crypto::decrypt(&entry.key) else { + return ProbeOutcome::Skip; + }; + let model = row + .test_model + .clone() + .or_else(|| { + row.models + .split(',') + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }) + .unwrap_or_default(); + if model.is_empty() { + return ProbeOutcome::Skip; + } + let started = Instant::now(); + let client = crate::llm::relay::shared_client(); + let url = format!("{}/chat/completions", row.base_url.trim_end_matches('/')); + let body = serde_json::json!({ + "model": model, + "messages": [{ "role": "user", "content": "ping" }], + "max_tokens": 1 + }); + let resp = client + .post(&url) + .headers(crate::llm::relay::OpenaiHeaders::for_key( + &key, + row.header_override.as_ref(), + )) + .json(&body) + .send() + .await; + let elapsed_ms = started.elapsed().as_millis() as i32; + let _ = elapsed_ms; + match resp { + Ok(r) if r.status().is_success() => ProbeOutcome::Ok, + Ok(r) => ProbeOutcome::Failed(format!( + "HTTP {}: {}", + r.status().as_u16(), + r.text().await.unwrap_or_default() + )), + Err(e) => ProbeOutcome::Failed(format!("transport: {e}")), + } +} + +crate::register_cron_handler!(&META, |deps| { + Box::new(LlmHealthHandler::new( + deps.pool.clone(), + deps.config.clone(), + )) +}); diff --git a/crates/core/src/worker/job_queue.rs b/crates/core/src/worker/job_queue.rs index decd00e9..37cc4e63 100644 --- a/crates/core/src/worker/job_queue.rs +++ b/crates/core/src/worker/job_queue.rs @@ -468,9 +468,9 @@ impl JobQueue for DefaultJobQueue { query = query.bind(page_size).bind(offset); let rows: Vec = query.fetch_all(&self.pool).await?; - let mut count_query = sqlx::query_scalar::( - crate::db::safe_sql(&format!("SELECT COUNT(*) FROM jobs{where_sql}")), - ); + let mut count_query = sqlx::query_scalar::(crate::db::safe_sql( + &format!("SELECT COUNT(*) FROM jobs{where_sql}"), + )); if let Some(s) = filter.status { count_query = count_query.bind(s); } @@ -502,11 +502,10 @@ impl JobQueue for DefaultJobQueue { } async fn list_job_types(&self) -> AppResult> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT DISTINCT job_type FROM jobs ORDER BY job_type", - ) - .fetch_all(&self.pool) - .await?; + let rows: Vec<(String,)> = + sqlx::query_as("SELECT DISTINCT job_type FROM jobs ORDER BY job_type") + .fetch_all(&self.pool) + .await?; Ok(rows.into_iter().map(|(t,)| t).collect()) } @@ -813,10 +812,30 @@ mod tests { let jobs = q.dequeue(1).await.unwrap(); q.complete(&jobs[0].id).await.unwrap(); - let (pending, _) = q.list(JobFilter { status: Some(JobStatus::Pending), ..Default::default() }, 1, 10).await.unwrap(); + let (pending, _) = q + .list( + JobFilter { + status: Some(JobStatus::Pending), + ..Default::default() + }, + 1, + 10, + ) + .await + .unwrap(); assert_eq!(pending.len(), 1); - let (completed, _) = q.list(JobFilter { status: Some(JobStatus::Completed), ..Default::default() }, 1, 10).await.unwrap(); + let (completed, _) = q + .list( + JobFilter { + status: Some(JobStatus::Completed), + ..Default::default() + }, + 1, + 10, + ) + .await + .unwrap(); assert_eq!(completed.len(), 1); } diff --git a/crates/core/tests/api.rs b/crates/core/tests/api.rs index 92af5199..a1d7bbd5 100644 --- a/crates/core/tests/api.rs +++ b/crates/core/tests/api.rs @@ -192,6 +192,9 @@ async fn build_test_app(pool: raisfast::db::Pool) -> (axum::Router, AppState) { pool.clone(), ))), search: Arc::new(NoopSearchEngine), + llm_router: raisfast::llm::service::LlmRouter::from_cache_for_test( + raisfast::llm::cache::ChannelCache::default(), + ), content_type_registry: content_registry.clone(), emitter: emitter.clone(), protocol_registry: test_protocols.clone(), diff --git a/migrations/mysql/20260909_0001_kb_query_logs_observability.sql b/migrations/mysql/20260909_0001_kb_query_logs_observability.sql deleted file mode 100644 index 2ab6ce69..00000000 --- a/migrations/mysql/20260909_0001_kb_query_logs_observability.sql +++ /dev/null @@ -1,10 +0,0 @@ --- kb_query_logs observability columns (kb-observability-design §3.4). --- Fresh installs get these via schema.mysql.sql; existing DBs apply --- this file via the `db migrate` CLI (tracked in `_migrations`). - -ALTER TABLE kb_query_logs ADD COLUMN rewritten_question TEXT; -ALTER TABLE kb_query_logs ADD COLUMN kb_ids JSON; -ALTER TABLE kb_query_logs ADD COLUMN latency_ms BIGINT; -ALTER TABLE kb_query_logs ADD COLUMN run_id BIGINT; -ALTER TABLE kb_query_logs ADD COLUMN error TEXT; -ALTER TABLE kb_query_logs ADD COLUMN source VARCHAR(16) NOT NULL DEFAULT 'ask'; diff --git a/migrations/mysql/schema.mysql.sql b/migrations/mysql/schema.mysql.sql index 12fb7441..15c28ad6 100644 --- a/migrations/mysql/schema.mysql.sql +++ b/migrations/mysql/schema.mysql.sql @@ -425,7 +425,6 @@ CREATE TABLE IF NOT EXISTS categories ( name VARCHAR(255) NOT NULL, description TEXT, slug VARCHAR(255) NOT NULL, - description TEXT, parent_id BIGINT, sort_order BIGINT NOT NULL DEFAULT 0, created_by BIGINT, @@ -1697,3 +1696,97 @@ CREATE TABLE IF NOT EXISTS kb_runs ( INDEX idx_kb_runs_kind (kind, status, created_at), INDEX idx_kb_runs_sweep (status, created_at) ); + +-- ===== LLM foundation (dev-docs/llm/design.md §5) ===== + +CREATE TABLE IF NOT EXISTS llm_channels ( + id BIGINT PRIMARY KEY, + tenant_id VARCHAR(36) NOT NULL DEFAULT 'default', + name VARCHAR(255) NOT NULL, + provider VARCHAR(50) NOT NULL, + base_url VARCHAR(500) NOT NULL, + api_keys JSON NOT NULL, + key_mode VARCHAR(20) NOT NULL DEFAULT 'polling', + status VARCHAR(20) NOT NULL DEFAULT 'enabled', + models TEXT NOT NULL, + model_mapping JSON, + priority BIGINT NOT NULL DEFAULT 0, + weight INT NOT NULL DEFAULT 0, + channel_groups TEXT NOT NULL, + auto_ban TINYINT(1) NOT NULL DEFAULT 1, + param_override JSON, + header_override JSON, + config JSON, + used_quota BIGINT NOT NULL DEFAULT 0, + test_model VARCHAR(255), + test_time DATETIME, + response_time INT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE (tenant_id, name), + INDEX idx_llm_channels_tenant (tenant_id, status) +); + +CREATE TABLE IF NOT EXISTS llm_tokens ( + id BIGINT PRIMARY KEY, + tenant_id VARCHAR(36) NOT NULL DEFAULT 'default', + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(128) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'enabled', + remain_quota BIGINT NOT NULL DEFAULT 0, + used_quota BIGINT NOT NULL DEFAULT 0, + unlimited_quota TINYINT(1) NOT NULL DEFAULT 0, + expired_at DATETIME, + allowed_models TEXT, + allowed_ips TEXT, + token_group VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + accessed_at DATETIME, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_llm_tokens_user (user_id) +); + +CREATE TABLE IF NOT EXISTS llm_models ( + id BIGINT PRIMARY KEY, + tenant_id VARCHAR(36) NOT NULL DEFAULT 'default', + name VARCHAR(255) NOT NULL, + model_type VARCHAR(20) NOT NULL DEFAULT 'chat', + price_mode VARCHAR(20) NOT NULL DEFAULT 'ratio', + model_ratio DOUBLE NOT NULL DEFAULT 1.0, + completion_ratio DOUBLE NOT NULL DEFAULT 1.0, + cache_ratio DOUBLE, + cache_write_ratio DOUBLE, + call_price DOUBLE, + params JSON, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE (tenant_id, name) +); + +CREATE TABLE IF NOT EXISTS llm_logs ( + id BIGINT PRIMARY KEY, + tenant_id VARCHAR(36) NOT NULL DEFAULT 'default', + request_id VARCHAR(64), + user_id BIGINT, + token_id BIGINT, + source VARCHAR(20) NOT NULL DEFAULT 'relay', + channel_id BIGINT, + key_index INT, + model_name VARCHAR(255) NOT NULL, + is_stream TINYINT(1) NOT NULL DEFAULT 0, + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + cache_read_tokens INT NOT NULL DEFAULT 0, + cache_write_tokens INT NOT NULL DEFAULT 0, + quota BIGINT NOT NULL DEFAULT 0, + detail JSON, + elapsed_ms INT, + status_code INT, + error_message TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_llm_logs_tenant (tenant_id, created_at), + INDEX idx_llm_logs_channel (channel_id, created_at), + INDEX idx_llm_logs_token (token_id, created_at) +); diff --git a/migrations/postgres/20260909_0001_kb_query_logs_observability.sql b/migrations/postgres/20260909_0001_kb_query_logs_observability.sql deleted file mode 100644 index 778be669..00000000 --- a/migrations/postgres/20260909_0001_kb_query_logs_observability.sql +++ /dev/null @@ -1,10 +0,0 @@ --- kb_query_logs observability columns (kb-observability-design §3.4). --- Fresh installs get these via schema.postgres.sql; existing DBs apply --- this file via the `db migrate` CLI (tracked in `_migrations`). - -ALTER TABLE kb_query_logs ADD COLUMN rewritten_question TEXT; -ALTER TABLE kb_query_logs ADD COLUMN kb_ids JSONB; -ALTER TABLE kb_query_logs ADD COLUMN latency_ms BIGINT; -ALTER TABLE kb_query_logs ADD COLUMN run_id BIGINT; -ALTER TABLE kb_query_logs ADD COLUMN error TEXT; -ALTER TABLE kb_query_logs ADD COLUMN source TEXT NOT NULL DEFAULT 'ask'; diff --git a/migrations/postgres/schema.postgres.sql b/migrations/postgres/schema.postgres.sql index 33bed155..0b2ad35c 100644 --- a/migrations/postgres/schema.postgres.sql +++ b/migrations/postgres/schema.postgres.sql @@ -1764,3 +1764,97 @@ CREATE INDEX IF NOT EXISTS idx_kb_runs_kb ON kb_runs(kb_id, kind, created_at); CREATE INDEX IF NOT EXISTS idx_kb_runs_agent ON kb_runs(agent_id, created_at); CREATE INDEX IF NOT EXISTS idx_kb_runs_kind ON kb_runs(kind, status, created_at); CREATE INDEX IF NOT EXISTS idx_kb_runs_sweep ON kb_runs(status, created_at); + +-- ===== LLM foundation (dev-docs/llm/design.md §5) ===== + +CREATE TABLE IF NOT EXISTS llm_channels ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + name VARCHAR(255) NOT NULL, + provider VARCHAR(50) NOT NULL, + base_url VARCHAR(500) NOT NULL, + api_keys JSONB NOT NULL, + key_mode VARCHAR(20) NOT NULL DEFAULT 'polling', + status VARCHAR(20) NOT NULL DEFAULT 'enabled', + models TEXT NOT NULL, + model_mapping JSONB, + priority BIGINT NOT NULL DEFAULT 0, + weight INTEGER NOT NULL DEFAULT 0, + channel_groups TEXT NOT NULL DEFAULT 'default', + auto_ban BOOLEAN NOT NULL DEFAULT TRUE, + param_override JSONB, + header_override JSONB, + config JSONB, + used_quota BIGINT NOT NULL DEFAULT 0, + test_model VARCHAR(255), + test_time TIMESTAMPTZ(0), + response_time INTEGER, + created_at TIMESTAMPTZ(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ(0) NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, name) +); +CREATE INDEX IF NOT EXISTS idx_llm_channels_tenant ON llm_channels(tenant_id, status); + +CREATE TABLE IF NOT EXISTS llm_tokens ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(128) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'enabled', + remain_quota BIGINT NOT NULL DEFAULT 0, + used_quota BIGINT NOT NULL DEFAULT 0, + unlimited_quota BOOLEAN NOT NULL DEFAULT FALSE, + expired_at TIMESTAMPTZ(0), + allowed_models TEXT, + allowed_ips TEXT, + token_group VARCHAR(255), + created_at TIMESTAMPTZ(0) NOT NULL DEFAULT NOW(), + accessed_at TIMESTAMPTZ(0), + updated_at TIMESTAMPTZ(0) NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_llm_tokens_user ON llm_tokens(user_id); + +CREATE TABLE IF NOT EXISTS llm_models ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + name VARCHAR(255) NOT NULL, + model_type VARCHAR(20) NOT NULL DEFAULT 'chat', + price_mode VARCHAR(20) NOT NULL DEFAULT 'ratio', + model_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0, + completion_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0, + cache_ratio DOUBLE PRECISION, + cache_write_ratio DOUBLE PRECISION, + call_price DOUBLE PRECISION, + params JSONB, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ(0) NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, name) +); + +CREATE TABLE IF NOT EXISTS llm_logs ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + request_id VARCHAR(64), + user_id BIGINT, + token_id BIGINT, + source VARCHAR(20) NOT NULL DEFAULT 'relay', + channel_id BIGINT, + key_index INTEGER, + model_name VARCHAR(255) NOT NULL, + is_stream BOOLEAN NOT NULL DEFAULT FALSE, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + quota BIGINT NOT NULL DEFAULT 0, + detail JSONB, + elapsed_ms INTEGER, + status_code INTEGER, + error_message TEXT, + created_at TIMESTAMPTZ(0) NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_llm_logs_tenant ON llm_logs(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_llm_logs_channel ON llm_logs(channel_id, created_at); +CREATE INDEX IF NOT EXISTS idx_llm_logs_token ON llm_logs(token_id, created_at); diff --git a/migrations/sqlite/20260909_0001_kb_query_logs_observability.sql b/migrations/sqlite/20260909_0001_kb_query_logs_observability.sql deleted file mode 100644 index c5e421e4..00000000 --- a/migrations/sqlite/20260909_0001_kb_query_logs_observability.sql +++ /dev/null @@ -1,10 +0,0 @@ --- kb_query_logs observability columns (kb-observability-design §3.4). --- Fresh installs get these via schema.sqlite.sql; existing DBs apply --- this file via the `db migrate` CLI (tracked in `_migrations`). - -ALTER TABLE kb_query_logs ADD COLUMN rewritten_question TEXT; -ALTER TABLE kb_query_logs ADD COLUMN kb_ids TEXT; -ALTER TABLE kb_query_logs ADD COLUMN latency_ms BIGINT; -ALTER TABLE kb_query_logs ADD COLUMN run_id BIGINT; -ALTER TABLE kb_query_logs ADD COLUMN error TEXT; -ALTER TABLE kb_query_logs ADD COLUMN source TEXT NOT NULL DEFAULT 'ask'; diff --git a/migrations/sqlite/schema.sqlite.sql b/migrations/sqlite/schema.sqlite.sql index 0c979b73..b2dd0826 100644 --- a/migrations/sqlite/schema.sqlite.sql +++ b/migrations/sqlite/schema.sqlite.sql @@ -1756,3 +1756,97 @@ CREATE INDEX IF NOT EXISTS idx_kb_runs_kb ON kb_runs(kb_id, kind, created_at); CREATE INDEX IF NOT EXISTS idx_kb_runs_agent ON kb_runs(agent_id, created_at); CREATE INDEX IF NOT EXISTS idx_kb_runs_kind ON kb_runs(kind, status, created_at); CREATE INDEX IF NOT EXISTS idx_kb_runs_sweep ON kb_runs(status, created_at); + +-- ===== LLM foundation (dev-docs/llm/design.md §5) ===== + +CREATE TABLE IF NOT EXISTS llm_channels ( + id INTEGER PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + provider VARCHAR(50) NOT NULL, + base_url VARCHAR(500) NOT NULL, + api_keys TEXT NOT NULL, + key_mode VARCHAR(20) NOT NULL DEFAULT 'polling', + status VARCHAR(20) NOT NULL DEFAULT 'enabled', + models TEXT NOT NULL, + model_mapping TEXT, + priority INTEGER NOT NULL DEFAULT 0, + weight INTEGER NOT NULL DEFAULT 0, + channel_groups TEXT NOT NULL DEFAULT 'default', + auto_ban INTEGER NOT NULL DEFAULT 1, + param_override TEXT, + header_override TEXT, + config TEXT, + used_quota INTEGER NOT NULL DEFAULT 0, + test_model VARCHAR(255), + test_time TEXT, + response_time INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE (tenant_id, name) +); +CREATE INDEX IF NOT EXISTS idx_llm_channels_tenant ON llm_channels(tenant_id, status); + +CREATE TABLE IF NOT EXISTS llm_tokens ( + id INTEGER PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + key_hash VARCHAR(128) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'enabled', + remain_quota INTEGER NOT NULL DEFAULT 0, + used_quota INTEGER NOT NULL DEFAULT 0, + unlimited_quota INTEGER NOT NULL DEFAULT 0, + expired_at TEXT, + allowed_models TEXT, + allowed_ips TEXT, + token_group VARCHAR(255), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + accessed_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_llm_tokens_user ON llm_tokens(user_id); + +CREATE TABLE IF NOT EXISTS llm_models ( + id INTEGER PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + name VARCHAR(255) NOT NULL, + model_type VARCHAR(20) NOT NULL DEFAULT 'chat', + price_mode VARCHAR(20) NOT NULL DEFAULT 'ratio', + model_ratio REAL NOT NULL DEFAULT 1.0, + completion_ratio REAL NOT NULL DEFAULT 1.0, + cache_ratio REAL, + cache_write_ratio REAL, + call_price REAL, + params TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE (tenant_id, name) +); + +CREATE TABLE IF NOT EXISTS llm_logs ( + id INTEGER PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + request_id VARCHAR(64), + user_id INTEGER, + token_id INTEGER, + source VARCHAR(20) NOT NULL DEFAULT 'relay', + channel_id INTEGER, + key_index INTEGER, + model_name VARCHAR(255) NOT NULL, + is_stream INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + quota INTEGER NOT NULL DEFAULT 0, + detail TEXT, + elapsed_ms INTEGER, + status_code INTEGER, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_llm_logs_tenant ON llm_logs(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_llm_logs_channel ON llm_logs(channel_id, created_at); +CREATE INDEX IF NOT EXISTS idx_llm_logs_token ON llm_logs(token_id, created_at);