Просмотр исходного кода

fix: provider total limit editable + enforce + reset (#535)

Ding 1 месяц назад
Родитель
Сommit
07f35c53b0

+ 2 - 0
drizzle/0045_mushy_human_torch.sql

@@ -0,0 +1,2 @@
+ALTER TABLE "providers" ADD COLUMN "limit_total_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "total_cost_reset_at" timestamp with time zone;

+ 2292 - 0
drizzle/meta/0045_snapshot.json

@@ -0,0 +1,2292 @@
+{
+  "id": "ceb5d052-434d-4d5a-b8f0-e8f50eb0e2a6",
+  "prevId": "e612b78e-b03e-45cf-8ec3-80c13f47b6cc",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.error_rules": {
+      "name": "error_rules",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "pattern": {
+          "name": "pattern",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'regex'"
+        },
+        "category": {
+          "name": "category",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_response": {
+          "name": "override_response",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_status_code": {
+          "name": "override_status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_error_rules_enabled": {
+          "name": "idx_error_rules_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "unique_pattern": {
+          "name": "unique_pattern",
+          "columns": [
+            {
+              "expression": "pattern",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_category": {
+          "name": "idx_category",
+          "columns": [
+            {
+              "expression": "category",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_match_type": {
+          "name": "idx_match_type",
+          "columns": [
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "can_login_web_ui": {
+          "name": "can_login_web_ui",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "request_sequence": {
+          "name": "request_sequence",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "endpoint": {
+          "name": "endpoint",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "ttfb_ms": {
+          "name": "ttfb_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_5m_input_tokens": {
+          "name": "cache_creation_5m_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_1h_input_tokens": {
+          "name": "cache_creation_1h_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_applied": {
+          "name": "cache_ttl_applied",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_applied": {
+          "name": "context_1m_applied",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_stack": {
+          "name": "error_stack",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_cause": {
+          "name": "error_cause",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_reason": {
+          "name": "blocked_reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "messages_count": {
+          "name": "messages_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_seq": {
+          "name": "idx_message_request_session_seq",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "request_sequence",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_endpoint": {
+          "name": "idx_message_request_endpoint",
+          "columns": [
+            {
+              "expression": "endpoint",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.notification_settings": {
+      "name": "notification_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "use_legacy_mode": {
+          "name": "use_legacy_mode",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_enabled": {
+          "name": "circuit_breaker_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_webhook": {
+          "name": "circuit_breaker_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_enabled": {
+          "name": "daily_leaderboard_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "daily_leaderboard_webhook": {
+          "name": "daily_leaderboard_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_time": {
+          "name": "daily_leaderboard_time",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'09:00'"
+        },
+        "daily_leaderboard_top_n": {
+          "name": "daily_leaderboard_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cost_alert_enabled": {
+          "name": "cost_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cost_alert_webhook": {
+          "name": "cost_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_alert_threshold": {
+          "name": "cost_alert_threshold",
+          "type": "numeric(5, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.80'"
+        },
+        "cost_alert_check_interval": {
+          "name": "cost_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.notification_target_bindings": {
+      "name": "notification_target_bindings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "notification_type": {
+          "name": "notification_type",
+          "type": "notification_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "target_id": {
+          "name": "target_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "schedule_cron": {
+          "name": "schedule_cron",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "schedule_timezone": {
+          "name": "schedule_timezone",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'Asia/Shanghai'"
+        },
+        "template_override": {
+          "name": "template_override",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "unique_notification_target_binding": {
+          "name": "unique_notification_target_binding",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_type": {
+          "name": "idx_notification_bindings_type",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_target": {
+          "name": "idx_notification_bindings_target",
+          "columns": [
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "notification_target_bindings_target_id_webhook_targets_id_fk": {
+          "name": "notification_target_bindings_target_id_webhook_targets_id_fk",
+          "tableFrom": "notification_target_bindings",
+          "tableTo": "webhook_targets",
+          "columnsFrom": [
+            "target_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "preserve_client_ip": {
+          "name": "preserve_client_ip",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "join_claude_pool": {
+          "name": "join_claude_pool",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "codex_instructions_strategy": {
+          "name": "codex_instructions_strategy",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "mcp_passthrough_type": {
+          "name": "mcp_passthrough_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'none'"
+        },
+        "mcp_passthrough_url": {
+          "name": "mcp_passthrough_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "total_cost_reset_at": {
+          "name": "total_cost_reset_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "max_retry_attempts": {
+          "name": "max_retry_attempts",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "circuit_breaker_failure_threshold": {
+          "name": "circuit_breaker_failure_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "circuit_breaker_open_duration": {
+          "name": "circuit_breaker_open_duration",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1800000
+        },
+        "circuit_breaker_half_open_success_threshold": {
+          "name": "circuit_breaker_half_open_success_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 2
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "first_byte_timeout_streaming_ms": {
+          "name": "first_byte_timeout_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "streaming_idle_timeout_ms": {
+          "name": "streaming_idle_timeout_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "request_timeout_non_streaming_ms": {
+          "name": "request_timeout_non_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_preference": {
+          "name": "context_1m_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.request_filters": {
+      "name": "request_filters",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "target": {
+          "name": "target",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "replacement": {
+          "name": "replacement",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "binding_type": {
+          "name": "binding_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'global'"
+        },
+        "provider_ids": {
+          "name": "provider_ids",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "group_tags": {
+          "name": "group_tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_request_filters_enabled": {
+          "name": "idx_request_filters_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_scope": {
+          "name": "idx_request_filters_scope",
+          "columns": [
+            {
+              "expression": "scope",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_action": {
+          "name": "idx_request_filters_action",
+          "columns": [
+            {
+              "expression": "action",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_binding": {
+          "name": "idx_request_filters_binding",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "binding_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.sensitive_words": {
+      "name": "sensitive_words",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "word": {
+          "name": "word",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'contains'"
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_sensitive_words_enabled": {
+          "name": "idx_sensitive_words_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_sensitive_words_created_at": {
+          "name": "idx_sensitive_words_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "currency_display": {
+          "name": "currency_display",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'USD'"
+        },
+        "billing_model_source": {
+          "name": "billing_model_source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'original'"
+        },
+        "enable_auto_cleanup": {
+          "name": "enable_auto_cleanup",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "cleanup_retention_days": {
+          "name": "cleanup_retention_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cleanup_schedule": {
+          "name": "cleanup_schedule",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0 2 * * *'"
+        },
+        "cleanup_batch_size": {
+          "name": "cleanup_batch_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10000
+        },
+        "enable_client_version_check": {
+          "name": "enable_client_version_check",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "verbose_provider_error": {
+          "name": "verbose_provider_error",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_http2": {
+          "name": "enable_http2",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "intercept_anthropic_warmup_requests": {
+          "name": "intercept_anthropic_warmup_requests",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "tags": {
+          "name": "tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_clients": {
+          "name": "allowed_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_enabled_expires_at": {
+          "name": "idx_users_enabled_expires_at",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "expires_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.webhook_targets": {
+      "name": "webhook_targets",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "webhook_provider_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "webhook_url": {
+          "name": "webhook_url",
+          "type": "varchar(1024)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_bot_token": {
+          "name": "telegram_bot_token",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_chat_id": {
+          "name": "telegram_chat_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "dingtalk_secret": {
+          "name": "dingtalk_secret",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_template": {
+          "name": "custom_template",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_headers": {
+          "name": "custom_headers",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "last_test_at": {
+          "name": "last_test_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_test_result": {
+          "name": "last_test_result",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    },
+    "public.notification_type": {
+      "name": "notification_type",
+      "schema": "public",
+      "values": [
+        "circuit_breaker",
+        "daily_leaderboard",
+        "cost_alert"
+      ]
+    },
+    "public.webhook_provider_type": {
+      "name": "webhook_provider_type",
+      "schema": "public",
+      "values": [
+        "wechat",
+        "feishu",
+        "dingtalk",
+        "telegram",
+        "custom"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -316,6 +316,13 @@
       "when": 1767449652342,
       "tag": "0044_uneven_donald_blake",
       "breakpoints": true
+    },
+    {
+      "idx": 45,
+      "version": "7",
+      "when": 1767521897438,
+      "tag": "0045_mushy_human_torch",
+      "breakpoints": true
     }
   ]
 }

+ 9 - 0
messages/en/settings.json

@@ -660,6 +660,10 @@
       "resetCircuitSuccess": "Circuit breaker reset",
       "resetCircuitSuccessDesc": "Provider \"{name}\" circuit breaker status cleared",
       "resetCircuitFailed": "Failed to reset circuit breaker",
+      "resetUsageTitle": "Reset total usage",
+      "resetUsageSuccess": "Total usage reset",
+      "resetUsageSuccessDesc": "Provider \"{name}\" total usage has been reset",
+      "resetUsageFailed": "Failed to reset total usage",
       "toggleSuccess": "Provider {status}",
       "toggleSuccessDesc": "Provider \"{name}\" status updated",
       "toggleFailed": "Toggle failed",
@@ -1267,6 +1271,7 @@
             "daily": "Day: ${amount} (reset ${resetTime})",
             "weekly": "Week: ${amount}",
             "monthly": "Month: ${amount}",
+            "total": "Total: ${amount}",
             "concurrent": "Concurrent: {count}",
             "none": "Unlimited"
           },
@@ -1300,6 +1305,10 @@
             "label": "Monthly Spend Limit (USD)",
             "placeholder": "Leave empty for unlimited"
           },
+          "limitTotal": {
+            "label": "Total Spend Limit (USD)",
+            "placeholder": "Leave empty for unlimited"
+          },
           "limitConcurrent": {
             "label": "Concurrent Sessions Limit",
             "placeholder": "0 means unlimited"

+ 9 - 0
messages/ja/settings.json

@@ -1161,6 +1161,7 @@
             "fiveHour": "5h: ${amount}",
             "weekly": "週: ${amount}",
             "monthly": "月: ${amount}",
+            "total": "総: ${amount}",
             "concurrent": "同時: {count}",
             "none": "無制限"
           },
@@ -1176,6 +1177,10 @@
             "label": "月の上限 (USD)",
             "placeholder": "空欄で無制限"
           },
+          "limitTotal": {
+            "label": "総消費上限 (USD)",
+            "placeholder": "空欄で無制限"
+          },
           "limitConcurrent": {
             "label": "同時セッション上限",
             "placeholder": "0 は無制限"
@@ -1570,6 +1575,10 @@
       "resetCircuitSuccess": "サーキットブレーカーがリセットされました",
       "resetCircuitSuccessDesc": "プロバイダー \"{name}\" のサーキットブレーカーステータスがクリアされました",
       "resetCircuitFailed": "サーキットブレーカーのリセットに失敗しました",
+      "resetUsageTitle": "総用量をリセット",
+      "resetUsageSuccess": "総用量をリセットしました",
+      "resetUsageSuccessDesc": "プロバイダー \"{name}\" の総用量をリセットしました",
+      "resetUsageFailed": "総用量のリセットに失敗しました",
       "toggleSuccess": "プロバイダーが{status}になりました",
       "toggleSuccessDesc": "プロバイダー \"{name}\" のステータスが更新されました",
       "toggleFailed": "切り替えに失敗しました",

+ 9 - 0
messages/ru/settings.json

@@ -1161,6 +1161,7 @@
             "fiveHour": "5ч: ${amount}",
             "weekly": "Неделя: ${amount}",
             "monthly": "Месяц: ${amount}",
+            "total": "Всего: ${amount}",
             "concurrent": "Параллельно: {count}",
             "none": "Без ограничений"
           },
@@ -1176,6 +1177,10 @@
             "label": "Месячный лимит (USD)",
             "placeholder": "Пусто — без ограничений"
           },
+          "limitTotal": {
+            "label": "Общий лимит (USD)",
+            "placeholder": "Пусто — без ограничений"
+          },
           "limitConcurrent": {
             "label": "Лимит параллельных сессий",
             "placeholder": "0 — без ограничений"
@@ -1570,6 +1575,10 @@
       "resetCircuitSuccess": "Автоматический выключатель сброшен",
       "resetCircuitSuccessDesc": "Статус автоматического выключателя провайдера \"{name}\" очищен",
       "resetCircuitFailed": "Не удалось сбросить автоматический выключатель",
+      "resetUsageTitle": "Сбросить общий расход",
+      "resetUsageSuccess": "Общий расход сброшен",
+      "resetUsageSuccessDesc": "Общий расход провайдера \"{name}\" был сброшен",
+      "resetUsageFailed": "Не удалось сбросить общий расход",
       "toggleSuccess": "Провайдер {status}",
       "toggleSuccessDesc": "Статус провайдера \"{name}\" обновлен",
       "toggleFailed": "Не удалось переключить",

+ 9 - 0
messages/zh-CN/settings.json

@@ -183,6 +183,10 @@
       "resetCircuitSuccess": "熔断器已重置",
       "resetCircuitSuccessDesc": "供应商 \"{name}\" 的熔断状态已解除",
       "resetCircuitFailed": "重置熔断器失败",
+      "resetUsageTitle": "重置总用量",
+      "resetUsageSuccess": "总用量已重置",
+      "resetUsageSuccessDesc": "供应商 \"{name}\" 的总用量已重置",
+      "resetUsageFailed": "重置总用量失败",
       "toggleSuccess": "供应商已{status}",
       "toggleSuccessDesc": "供应商 \"{name}\" 状态已更新",
       "toggleFailed": "状态切换失败",
@@ -771,6 +775,7 @@
             "daily": "日: {amount} (重置 {resetTime})",
             "weekly": "周: {amount}",
             "monthly": "月: {amount}",
+            "total": "总: {amount}",
             "concurrent": "并发: {count}",
             "none": "无限制"
           },
@@ -804,6 +809,10 @@
             "label": "月消费上限 (USD)",
             "placeholder": "留空表示无限制"
           },
+          "limitTotal": {
+            "label": "总消费上限 (USD)",
+            "placeholder": "留空表示无限制"
+          },
           "limitConcurrent": {
             "label": "并发 Session 上限",
             "placeholder": "0 表示无限制"

+ 9 - 0
messages/zh-TW/settings.json

@@ -1161,6 +1161,7 @@
             "fiveHour": "5h:${amount}",
             "weekly": "週:${amount}",
             "monthly": "月:${amount}",
+            "total": "總:${amount}",
             "concurrent": "並發:{count}",
             "none": "無限制"
           },
@@ -1176,6 +1177,10 @@
             "label": "月消費上限 (USD)",
             "placeholder": "留空表示無限制"
           },
+          "limitTotal": {
+            "label": "總消費上限 (USD)",
+            "placeholder": "留空表示無限制"
+          },
           "limitConcurrent": {
             "label": "並發 Session 上限",
             "placeholder": "0 表示無限制"
@@ -1576,6 +1581,10 @@
       "resetCircuitSuccess": "熔斷器已重置",
       "resetCircuitSuccessDesc": "供應商 \"{name}\" 的熔斷狀態已解除",
       "resetCircuitFailed": "重置熔斷器失敗",
+      "resetUsageTitle": "重置總用量",
+      "resetUsageSuccess": "總用量已重置",
+      "resetUsageSuccessDesc": "供應商 \"{name}\" 的總用量已重置",
+      "resetUsageFailed": "重置總用量失敗",
       "toggleSuccess": "供應商已{status}",
       "toggleSuccessDesc": "供應商 \"{name}\" 狀態已更新",
       "toggleFailed": "狀態切換失敗",

+ 33 - 0
src/actions/providers.ts

@@ -38,6 +38,7 @@ import {
   findAllProviders,
   findProviderById,
   getProviderStatistics,
+  resetProviderTotalCostResetAt,
   updateProvider,
 } from "@/repository/provider";
 import type { CacheTtlPreference } from "@/types/cache";
@@ -203,6 +204,7 @@ export async function getProviders(): Promise<ProviderDisplay[]> {
         dailyResetTime: provider.dailyResetTime,
         limitWeeklyUsd: provider.limitWeeklyUsd,
         limitMonthlyUsd: provider.limitMonthlyUsd,
+        limitTotalUsd: provider.limitTotalUsd,
         limitConcurrentSessions: provider.limitConcurrentSessions,
         maxRetryAttempts: provider.maxRetryAttempts,
         circuitBreakerFailureThreshold: provider.circuitBreakerFailureThreshold,
@@ -352,6 +354,7 @@ export async function addProvider(data: {
   daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
+  limit_total_usd?: number | null;
   limit_concurrent_sessions?: number | null;
   cache_ttl_preference?: CacheTtlPreference | null;
   context_1m_preference?: Context1mPreference | null;
@@ -421,6 +424,7 @@ export async function addProvider(data: {
       daily_reset_time: validated.daily_reset_time ?? "00:00",
       limit_weekly_usd: validated.limit_weekly_usd ?? null,
       limit_monthly_usd: validated.limit_monthly_usd ?? null,
+      limit_total_usd: validated.limit_total_usd ?? null,
       limit_concurrent_sessions: validated.limit_concurrent_sessions ?? 0,
       max_retry_attempts: validated.max_retry_attempts ?? null,
       circuit_breaker_failure_threshold: validated.circuit_breaker_failure_threshold ?? 5,
@@ -507,6 +511,7 @@ export async function editProvider(
     daily_reset_time?: string;
     limit_weekly_usd?: number | null;
     limit_monthly_usd?: number | null;
+    limit_total_usd?: number | null;
     limit_concurrent_sessions?: number | null;
     cache_ttl_preference?: "inherit" | "5m" | "1h";
     context_1m_preference?: Context1mPreference | null;
@@ -714,6 +719,34 @@ export async function resetProviderCircuit(providerId: number): Promise<ActionRe
   }
 }
 
+/**
+ * 手动重置供应商“总用量”(用于总消费上限 limit_total_usd)
+ *
+ * 说明:
+ * - 不删除历史请求日志,仅更新 providers.total_cost_reset_at 作为聚合下限。
+ */
+export async function resetProviderTotalUsage(providerId: number): Promise<ActionResult> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    const ok = await resetProviderTotalCostResetAt(providerId, new Date());
+    if (!ok) {
+      return { ok: false, error: "供应商不存在" };
+    }
+
+    revalidatePath("/settings/providers");
+    revalidatePath("/dashboard/quotas/providers");
+    return { ok: true };
+  } catch (error) {
+    logger.error("重置供应商总用量失败:", error);
+    const message = error instanceof Error ? error.message : "重置供应商总用量失败";
+    return { ok: false, error: message };
+  }
+}
+
 /**
  * 获取供应商限额使用情况
  */

+ 46 - 5
src/app/[locale]/settings/providers/_components/forms/provider-form.tsx

@@ -33,7 +33,12 @@ import { Switch } from "@/components/ui/switch";
 import { TagInput } from "@/components/ui/tag-input";
 import { PROVIDER_DEFAULTS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants";
 import type { Context1mPreference } from "@/lib/special-attributes";
-import { extractBaseUrl, isValidUrl, validateNumericField } from "@/lib/utils/validation";
+import {
+  extractBaseUrl,
+  isValidUrl,
+  validateNumericField,
+  validatePositiveDecimalField,
+} from "@/lib/utils/validation";
 import type { McpPassthroughType, ProviderDisplay, ProviderType } from "@/types/provider";
 import { ModelMultiSelect } from "../model-multi-select";
 import { ModelRedirectEditor } from "../model-redirect-editor";
@@ -113,6 +118,9 @@ export function ProviderForm({
   const [limitMonthlyUsd, setLimitMonthlyUsd] = useState<number | null>(
     sourceProvider?.limitMonthlyUsd ?? null
   );
+  const [limitTotalUsd, setLimitTotalUsd] = useState<number | null>(
+    sourceProvider?.limitTotalUsd ?? null
+  );
   const [limitConcurrentSessions, setLimitConcurrentSessions] = useState<number | null>(
     sourceProvider?.limitConcurrentSessions ?? null
   );
@@ -332,6 +340,7 @@ export function ProviderForm({
             daily_reset_time?: string;
             limit_weekly_usd?: number | null;
             limit_monthly_usd?: number | null;
+            limit_total_usd?: number | null;
             limit_concurrent_sessions?: number | null;
             cache_ttl_preference?: "inherit" | "5m" | "1h";
             context_1m_preference?: Context1mPreference | null;
@@ -370,6 +379,7 @@ export function ProviderForm({
             daily_reset_time: dailyResetTime,
             limit_weekly_usd: limitWeeklyUsd,
             limit_monthly_usd: limitMonthlyUsd,
+            limit_total_usd: limitTotalUsd,
             limit_concurrent_sessions: limitConcurrentSessions ?? 0,
             cache_ttl_preference: cacheTtlPreference,
             context_1m_preference: context1mPreference,
@@ -430,6 +440,7 @@ export function ProviderForm({
             daily_reset_time: dailyResetTime,
             limit_weekly_usd: limitWeeklyUsd,
             limit_monthly_usd: limitMonthlyUsd,
+            limit_total_usd: limitTotalUsd,
             limit_concurrent_sessions: limitConcurrentSessions ?? 0,
             cache_ttl_preference: cacheTtlPreference,
             context_1m_preference: context1mPreference,
@@ -487,6 +498,7 @@ export function ProviderForm({
           setDailyResetTime("00:00");
           setLimitWeeklyUsd(null);
           setLimitMonthlyUsd(null);
+          setLimitTotalUsd(null);
           setLimitConcurrentSessions(null);
           setMaxRetryAttempts(null);
           setFailureThreshold(5);
@@ -1036,6 +1048,12 @@ export function ProviderForm({
                           amount: limitMonthlyUsd,
                         })
                       );
+                    if (limitTotalUsd)
+                      limits.push(
+                        t("sections.rateLimit.summary.total", {
+                          amount: limitTotalUsd,
+                        })
+                      );
                     if (limitConcurrentSessions)
                       limits.push(
                         t("sections.rateLimit.summary.concurrent", {
@@ -1060,7 +1078,7 @@ export function ProviderForm({
                       id={isEdit ? "edit-limit-5h" : "limit-5h"}
                       type="number"
                       value={limit5hUsd?.toString() ?? ""}
-                      onChange={(e) => setLimit5hUsd(validateNumericField(e.target.value))}
+                      onChange={(e) => setLimit5hUsd(validatePositiveDecimalField(e.target.value))}
                       placeholder={t("sections.rateLimit.limit5h.placeholder")}
                       disabled={isPending}
                       min="0"
@@ -1075,7 +1093,9 @@ export function ProviderForm({
                       id={isEdit ? "edit-limit-daily" : "limit-daily"}
                       type="number"
                       value={limitDailyUsd?.toString() ?? ""}
-                      onChange={(e) => setLimitDailyUsd(validateNumericField(e.target.value))}
+                      onChange={(e) =>
+                        setLimitDailyUsd(validatePositiveDecimalField(e.target.value))
+                      }
                       placeholder={t("sections.rateLimit.limitDaily.placeholder")}
                       disabled={isPending}
                       min="0"
@@ -1139,13 +1159,32 @@ export function ProviderForm({
                       id={isEdit ? "edit-limit-weekly" : "limit-weekly"}
                       type="number"
                       value={limitWeeklyUsd?.toString() ?? ""}
-                      onChange={(e) => setLimitWeeklyUsd(validateNumericField(e.target.value))}
+                      onChange={(e) =>
+                        setLimitWeeklyUsd(validatePositiveDecimalField(e.target.value))
+                      }
                       placeholder={t("sections.rateLimit.limitWeekly.placeholder")}
                       disabled={isPending}
                       min="0"
                       step="0.01"
                     />
                   </div>
+                  <div className="space-y-2">
+                    <Label htmlFor={isEdit ? "edit-limit-total" : "limit-total"}>
+                      {t("sections.rateLimit.limitTotal.label")}
+                    </Label>
+                    <Input
+                      id={isEdit ? "edit-limit-total" : "limit-total"}
+                      type="number"
+                      value={limitTotalUsd?.toString() ?? ""}
+                      onChange={(e) =>
+                        setLimitTotalUsd(validatePositiveDecimalField(e.target.value))
+                      }
+                      placeholder={t("sections.rateLimit.limitTotal.placeholder")}
+                      disabled={isPending}
+                      min="0"
+                      step="0.01"
+                    />
+                  </div>
                 </div>
 
                 <div className="grid grid-cols-2 gap-4">
@@ -1157,7 +1196,9 @@ export function ProviderForm({
                       id={isEdit ? "edit-limit-monthly" : "limit-monthly"}
                       type="number"
                       value={limitMonthlyUsd?.toString() ?? ""}
-                      onChange={(e) => setLimitMonthlyUsd(validateNumericField(e.target.value))}
+                      onChange={(e) =>
+                        setLimitMonthlyUsd(validatePositiveDecimalField(e.target.value))
+                      }
                       placeholder={t("sections.rateLimit.limitMonthly.placeholder")}
                       disabled={isPending}
                       min="0"

+ 44 - 0
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -20,6 +20,7 @@ import {
   getUnmaskedProviderKey,
   removeProvider,
   resetProviderCircuit,
+  resetProviderTotalUsage,
 } from "@/actions/providers";
 import { FormErrorBoundary } from "@/components/form-error-boundary";
 import {
@@ -89,6 +90,7 @@ export function ProviderRichListItem({
   const [copied, setCopied] = useState(false);
   const [clipboardAvailable, setClipboardAvailable] = useState(false);
   const [resetPending, startResetTransition] = useTransition();
+  const [resetUsagePending, startResetUsageTransition] = useTransition();
   const [deletePending, startDeleteTransition] = useTransition();
   const [togglePending, startToggleTransition] = useTransition();
 
@@ -255,6 +257,32 @@ export function ProviderRichListItem({
     });
   };
 
+  // 处理手动重置总用量(总限额用)
+  const handleResetTotalUsage = () => {
+    startResetUsageTransition(async () => {
+      try {
+        const res = await resetProviderTotalUsage(provider.id);
+        if (res.ok) {
+          toast.success(tList("resetUsageSuccess"), {
+            description: tList("resetUsageSuccessDesc", { name: provider.name }),
+          });
+          queryClient.invalidateQueries({ queryKey: ["providers"] });
+          queryClient.invalidateQueries({ queryKey: ["providers-health"] });
+          router.refresh();
+        } else {
+          toast.error(tList("resetUsageFailed"), {
+            description: res.error || tList("unknownError"),
+          });
+        }
+      } catch (error) {
+        console.error("重置总用量失败:", error);
+        toast.error(tList("resetUsageFailed"), {
+          description: tList("deleteError"),
+        });
+      }
+    });
+  };
+
   // 处理启用/禁用切换
   const handleToggle = () => {
     startToggleTransition(async () => {
@@ -566,6 +594,22 @@ export function ProviderRichListItem({
             </Button>
           )}
 
+          {/* 总用量重置按钮(仅配置了总限额时显示) */}
+          {canEdit && provider.limitTotalUsd !== null && provider.limitTotalUsd > 0 && (
+            <Button
+              size="icon"
+              variant="ghost"
+              title={tList("resetUsageTitle")}
+              onClick={(e) => {
+                e.stopPropagation();
+                handleResetTotalUsage();
+              }}
+              disabled={resetUsagePending}
+            >
+              <RotateCcw className="h-4 w-4 text-blue-600" />
+            </Button>
+          )}
+
           {/* 删除按钮 */}
           {canEdit && (
             <AlertDialog>

+ 54 - 0
src/app/v1/_lib/proxy/provider-selector.ts

@@ -594,6 +594,42 @@ export class ProxyProviderResolver {
     }
     // No auth group info (effectiveGroup is null) can reuse any provider
 
+    // 会话复用也必须遵守限额(否则会绕过“达到限额即禁用”的语义)
+    const costCheck = await RateLimitService.checkCostLimits(provider.id, "provider", {
+      limit_5h_usd: provider.limit5hUsd,
+      limit_daily_usd: provider.limitDailyUsd,
+      daily_reset_mode: provider.dailyResetMode,
+      daily_reset_time: provider.dailyResetTime,
+      limit_weekly_usd: provider.limitWeeklyUsd,
+      limit_monthly_usd: provider.limitMonthlyUsd,
+    });
+
+    if (!costCheck.allowed) {
+      logger.debug("ProviderSelector: Session provider cost limit exceeded, reject reuse", {
+        sessionId: session.sessionId,
+        providerId: provider.id,
+      });
+      return null;
+    }
+
+    const totalCheck = await RateLimitService.checkTotalCostLimit(
+      provider.id,
+      "provider",
+      provider.limitTotalUsd,
+      {
+        resetAt: provider.totalCostResetAt,
+      }
+    );
+
+    if (!totalCheck.allowed) {
+      logger.debug("ProviderSelector: Session provider total cost limit exceeded, reject reuse", {
+        sessionId: session.sessionId,
+        providerId: provider.id,
+        reason: totalCheck.reason,
+      });
+      return null;
+    }
+
     logger.info("ProviderSelector: Reusing provider", {
       providerName: provider.name,
       providerId: provider.id,
@@ -923,6 +959,24 @@ export class ProxyProviderResolver {
           return null;
         }
 
+        // 2. 检查总消费上限(无重置窗口,达到后需要管理员取消限额或手动重置)
+        const totalCheck = await RateLimitService.checkTotalCostLimit(
+          p.id,
+          "provider",
+          p.limitTotalUsd,
+          {
+            resetAt: p.totalCostResetAt,
+          }
+        );
+
+        if (!totalCheck.allowed) {
+          logger.debug("ProviderSelector: Provider total cost limit exceeded", {
+            providerId: p.id,
+            reason: totalCheck.reason,
+          });
+          return null;
+        }
+
         // 并发 Session 限制已移至原子性检查(avoid race condition)
 
         return p;

+ 2 - 3
src/app/v1/_lib/proxy/rate-limit-guard.ts

@@ -46,7 +46,7 @@ export class ProxyRateLimitGuard {
       key.id,
       "key",
       key.limitTotalUsd ?? null,
-      key.key
+      { keyHash: key.key }
     );
 
     if (!keyTotalCheck.allowed) {
@@ -76,8 +76,7 @@ export class ProxyRateLimitGuard {
     const userTotalCheck = await RateLimitService.checkTotalCostLimit(
       user.id,
       "user",
-      user.limitTotalUsd ?? null,
-      undefined
+      user.limitTotalUsd ?? null
     );
 
     if (!userTotalCheck.allowed) {

+ 2 - 0
src/drizzle/schema.ts

@@ -198,6 +198,8 @@ export const providers = pgTable('providers', {
     .notNull(), // HH:mm 格式,如 "18:00"(仅 fixed 模式使用)
   limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }),
   limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }),
+  limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }),
+  totalCostResetAt: timestamp('total_cost_reset_at', { withTimezone: true }),
   limitConcurrentSessions: integer('limit_concurrent_sessions').default(0),
 
   // 熔断器配置(每个供应商独立配置)

+ 38 - 15
src/lib/rate-limit/service.ts

@@ -75,7 +75,12 @@ import {
   TRACK_COST_DAILY_ROLLING_WINDOW,
 } from "@/lib/redis/lua-scripts";
 import { SessionTracker } from "@/lib/session-tracker";
-import { sumKeyTotalCost, sumUserCostInTimeRange, sumUserTotalCost } from "@/repository/statistics";
+import {
+  sumKeyTotalCost,
+  sumProviderTotalCost,
+  sumUserCostInTimeRange,
+  sumUserTotalCost,
+} from "@/repository/statistics";
 import {
   type DailyResetMode,
   getTimeRangeForPeriodWithMode,
@@ -274,9 +279,9 @@ export class RateLimitService {
    */
   static async checkTotalCostLimit(
     entityId: number,
-    entityType: "key" | "user",
+    entityType: "key" | "user" | "provider",
     limitTotalUsd: number | null,
-    keyHash?: string
+    options?: { keyHash?: string; resetAt?: Date | null }
   ): Promise<{ allowed: boolean; current?: number; reason?: string }> {
     if (limitTotalUsd === null || limitTotalUsd === undefined || limitTotalUsd <= 0) {
       return { allowed: true };
@@ -284,8 +289,19 @@ export class RateLimitService {
 
     try {
       let current = 0;
-      const cacheKey =
-        entityType === "key" ? `total_cost:key:${keyHash}` : `total_cost:user:${entityId}`;
+      const cacheKey = (() => {
+        if (entityType === "key") {
+          return `total_cost:key:${options?.keyHash}`;
+        }
+        if (entityType === "user") {
+          return `total_cost:user:${entityId}`;
+        }
+        const resetAtMs =
+          options?.resetAt instanceof Date && !Number.isNaN(options.resetAt.getTime())
+            ? options.resetAt.getTime()
+            : "none";
+        return `total_cost:provider:${entityId}:${resetAtMs}`;
+      })();
       const cacheTtl = 300; // 5 minutes
 
       // 尝试从 Redis 缓存获取
@@ -298,13 +314,15 @@ export class RateLimitService {
           } else {
             // 缓存未命中,查询数据库
             if (entityType === "key") {
-              if (!keyHash) {
+              if (!options?.keyHash) {
                 logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement");
                 return { allowed: true };
               }
-              current = await sumKeyTotalCost(keyHash);
-            } else {
+              current = await sumKeyTotalCost(options.keyHash);
+            } else if (entityType === "user") {
               current = await sumUserTotalCost(entityId);
+            } else {
+              current = await sumProviderTotalCost(entityId, options?.resetAt ?? null);
             }
             // 异步写入缓存,不阻塞请求
             redis.setex(cacheKey, cacheTtl, current.toString()).catch((err) => {
@@ -315,32 +333,37 @@ export class RateLimitService {
           // Redis 读取失败,降级到数据库查询
           logger.warn("[RateLimit] Redis cache read failed, falling back to database:", redisError);
           if (entityType === "key") {
-            if (!keyHash) {
+            if (!options?.keyHash) {
               return { allowed: true };
             }
-            current = await sumKeyTotalCost(keyHash);
-          } else {
+            current = await sumKeyTotalCost(options.keyHash);
+          } else if (entityType === "user") {
             current = await sumUserTotalCost(entityId);
+          } else {
+            current = await sumProviderTotalCost(entityId, options?.resetAt ?? null);
           }
         }
       } else {
         // Redis 不可用,直接查询数据库
         if (entityType === "key") {
-          if (!keyHash) {
+          if (!options?.keyHash) {
             logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement");
             return { allowed: true };
           }
-          current = await sumKeyTotalCost(keyHash);
-        } else {
+          current = await sumKeyTotalCost(options.keyHash);
+        } else if (entityType === "user") {
           current = await sumUserTotalCost(entityId);
+        } else {
+          current = await sumProviderTotalCost(entityId, options?.resetAt ?? null);
         }
       }
 
       if (current >= limitTotalUsd) {
+        const typeName = entityType === "key" ? "Key" : entityType === "user" ? "User" : "供应商";
         return {
           allowed: false,
           current,
-          reason: `${entityType === "key" ? "Key" : "User"} total spending limit reached (${current.toFixed(4)}/${limitTotalUsd})`,
+          reason: `${typeName} total spending limit reached (${current.toFixed(4)}/${limitTotalUsd})`,
         };
       }
 

+ 1 - 0
src/lib/utils/index.ts

@@ -32,4 +32,5 @@ export {
   isValidUrl,
   maskKey,
   validateNumericField,
+  validatePositiveDecimalField,
 } from "./validation";

+ 1 - 0
src/lib/utils/validation/index.ts

@@ -4,6 +4,7 @@ export {
   clampWeight,
   formatTpmDisplay,
   validateNumericField,
+  validatePositiveDecimalField,
 } from "./provider";
 
 import { logger } from "@/lib/logger";

+ 14 - 0
src/lib/utils/validation/provider.ts

@@ -9,6 +9,20 @@ export function validateNumericField(value: string): number | null {
   return num > 0 ? num : null;
 }
 
+/**
+ * 金额字段验证:要么不填(null),要么是大于0的数值(最多保留两位小数)
+ *
+ * 说明:
+ * - 0 或负数视为未设置(与后端“<=0 不限额”的语义保持一致)
+ * - 这里做两位小数截断,避免输入过多小数导致显示/存储不一致
+ */
+export function validatePositiveDecimalField(value: string): number | null {
+  if (!value.trim()) return null;
+  const num = Number.parseFloat(value);
+  if (!Number.isFinite(num) || num <= 0) return null;
+  return Math.round(num * 100) / 100;
+}
+
 /**
  * 限制权重值在有效范围内
  */

+ 12 - 0
src/lib/validation/schemas.ts

@@ -397,6 +397,12 @@ export const CreateProviderSchema = z.object({
     .max(200000, "月消费上限不能超过200000美元")
     .nullable()
     .optional(),
+  limit_total_usd: z.coerce
+    .number()
+    .min(0, "总消费上限不能为负数")
+    .max(10000000, "总消费上限不能超过10000000美元")
+    .nullable()
+    .optional(),
   limit_concurrent_sessions: z.coerce
     .number()
     .int("并发Session上限必须是整数")
@@ -562,6 +568,12 @@ export const UpdateProviderSchema = z
       .max(200000, "月消费上限不能超过200000美元")
       .nullable()
       .optional(),
+    limit_total_usd: z.coerce
+      .number()
+      .min(0, "总消费上限不能为负数")
+      .max(10000000, "总消费上限不能超过10000000美元")
+      .nullable()
+      .optional(),
     limit_concurrent_sessions: z.coerce
       .number()
       .int("并发Session上限必须是整数")

+ 5 - 0
src/repository/_shared/transformers.ts

@@ -82,6 +82,11 @@ export function toProvider(dbProvider: any): Provider {
     dailyResetTime: dbProvider?.dailyResetTime ?? "00:00",
     limitWeeklyUsd: dbProvider?.limitWeeklyUsd ? parseFloat(dbProvider.limitWeeklyUsd) : null,
     limitMonthlyUsd: dbProvider?.limitMonthlyUsd ? parseFloat(dbProvider.limitMonthlyUsd) : null,
+    limitTotalUsd:
+      dbProvider?.limitTotalUsd !== null && dbProvider?.limitTotalUsd !== undefined
+        ? parseFloat(dbProvider.limitTotalUsd)
+        : null,
+    totalCostResetAt: dbProvider?.totalCostResetAt ? new Date(dbProvider.totalCostResetAt) : null,
     limitConcurrentSessions: dbProvider?.limitConcurrentSessions ?? 0,
     maxRetryAttempts:
       dbProvider?.maxRetryAttempts !== undefined && dbProvider?.maxRetryAttempts !== null

+ 34 - 0
src/repository/provider.ts

@@ -36,6 +36,8 @@ export async function createProvider(providerData: CreateProviderData): Promise<
       providerData.limit_weekly_usd != null ? providerData.limit_weekly_usd.toString() : null,
     limitMonthlyUsd:
       providerData.limit_monthly_usd != null ? providerData.limit_monthly_usd.toString() : null,
+    limitTotalUsd:
+      providerData.limit_total_usd != null ? providerData.limit_total_usd.toString() : null,
     limitConcurrentSessions: providerData.limit_concurrent_sessions,
     maxRetryAttempts: providerData.max_retry_attempts ?? null,
     circuitBreakerFailureThreshold: providerData.circuit_breaker_failure_threshold ?? 5,
@@ -81,6 +83,8 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     dailyResetTime: providers.dailyResetTime,
     limitWeeklyUsd: providers.limitWeeklyUsd,
     limitMonthlyUsd: providers.limitMonthlyUsd,
+    limitTotalUsd: providers.limitTotalUsd,
+    totalCostResetAt: providers.totalCostResetAt,
     limitConcurrentSessions: providers.limitConcurrentSessions,
     maxRetryAttempts: providers.maxRetryAttempts,
     circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
@@ -136,6 +140,8 @@ export async function findProviderList(
       dailyResetTime: providers.dailyResetTime,
       limitWeeklyUsd: providers.limitWeeklyUsd,
       limitMonthlyUsd: providers.limitMonthlyUsd,
+      limitTotalUsd: providers.limitTotalUsd,
+      totalCostResetAt: providers.totalCostResetAt,
       limitConcurrentSessions: providers.limitConcurrentSessions,
       maxRetryAttempts: providers.maxRetryAttempts,
       circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
@@ -202,6 +208,8 @@ export async function findAllProviders(): Promise<Provider[]> {
       dailyResetTime: providers.dailyResetTime,
       limitWeeklyUsd: providers.limitWeeklyUsd,
       limitMonthlyUsd: providers.limitMonthlyUsd,
+      limitTotalUsd: providers.limitTotalUsd,
+      totalCostResetAt: providers.totalCostResetAt,
       limitConcurrentSessions: providers.limitConcurrentSessions,
       maxRetryAttempts: providers.maxRetryAttempts,
       circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
@@ -262,6 +270,8 @@ export async function findProviderById(id: number): Promise<Provider | null> {
       dailyResetTime: providers.dailyResetTime,
       limitWeeklyUsd: providers.limitWeeklyUsd,
       limitMonthlyUsd: providers.limitMonthlyUsd,
+      limitTotalUsd: providers.limitTotalUsd,
+      totalCostResetAt: providers.totalCostResetAt,
       limitConcurrentSessions: providers.limitConcurrentSessions,
       maxRetryAttempts: providers.maxRetryAttempts,
       circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
@@ -343,6 +353,9 @@ export async function updateProvider(
   if (providerData.limit_monthly_usd !== undefined)
     dbData.limitMonthlyUsd =
       providerData.limit_monthly_usd != null ? providerData.limit_monthly_usd.toString() : null;
+  if (providerData.limit_total_usd !== undefined)
+    dbData.limitTotalUsd =
+      providerData.limit_total_usd != null ? providerData.limit_total_usd.toString() : null;
   if (providerData.limit_concurrent_sessions !== undefined)
     dbData.limitConcurrentSessions = providerData.limit_concurrent_sessions;
   if (providerData.max_retry_attempts !== undefined)
@@ -402,6 +415,8 @@ export async function updateProvider(
       dailyResetTime: providers.dailyResetTime,
       limitWeeklyUsd: providers.limitWeeklyUsd,
       limitMonthlyUsd: providers.limitMonthlyUsd,
+      limitTotalUsd: providers.limitTotalUsd,
+      totalCostResetAt: providers.totalCostResetAt,
       limitConcurrentSessions: providers.limitConcurrentSessions,
       maxRetryAttempts: providers.maxRetryAttempts,
       circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
@@ -439,6 +454,25 @@ export async function deleteProvider(id: number): Promise<boolean> {
   return result.length > 0;
 }
 
+/**
+ * 手动重置供应商“总消费”统计起点
+ *
+ * 说明:
+ * - 不删除 message_request 历史记录,仅通过 resetAt 作为聚合下限实现“从 0 重新累计”。
+ */
+export async function resetProviderTotalCostResetAt(
+  providerId: number,
+  resetAt: Date
+): Promise<boolean> {
+  const result = await db
+    .update(providers)
+    .set({ totalCostResetAt: resetAt, updatedAt: new Date() })
+    .where(and(eq(providers.id, providerId), isNull(providers.deletedAt)))
+    .returning({ id: providers.id });
+
+  return result.length > 0;
+}
+
 /**
  * 获取所有不同的供应商分组标签
  * 用于用户表单中的供应商分组选择建议

+ 33 - 0
src/repository/statistics.ts

@@ -819,6 +819,39 @@ export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365)
   return Number(result[0]?.total || 0);
 }
 
+/**
+ * 查询供应商历史总消费
+ * 用于供应商总消费限额检查(limit_total_usd)。
+ *
+ * 重要语义:
+ * - 总限额必须是“从 resetAt 起累计到现在”的结果;resetAt 为空时表示从历史最早记录开始累计。
+ * - 这里不再做 365 天时间截断,否则会导致达到总限额后“过期自动恢复”,违背禁用语义。
+ *
+ * @param providerId - 供应商 ID
+ * @param resetAt - 手动重置时间(用于实现“从 0 重新累计”)
+ */
+export async function sumProviderTotalCost(
+  providerId: number,
+  resetAt?: Date | null
+): Promise<number> {
+  const effectiveStart =
+    resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) ? resetAt : null;
+
+  const result = await db
+    .select({ total: sql<number>`COALESCE(SUM(${messageRequest.costUsd}), 0)` })
+    .from(messageRequest)
+    .where(
+      and(
+        eq(messageRequest.providerId, providerId),
+        isNull(messageRequest.deletedAt),
+        EXCLUDE_WARMUP_CONDITION,
+        ...(effectiveStart ? [gte(messageRequest.createdAt, effectiveStart)] : [])
+      )
+    );
+
+  return Number(result[0]?.total || 0);
+}
+
 /**
  * 查询用户在指定时间范围内的消费总和
  * 用于用户层限额百分比显示

+ 7 - 0
src/types/provider.ts

@@ -70,6 +70,10 @@ export interface Provider {
   dailyResetTime: string;
   limitWeeklyUsd: number | null;
   limitMonthlyUsd: number | null;
+  // 总消费上限(手动重置后从 0 重新累计)
+  limitTotalUsd: number | null;
+  // 总消费重置时间:用于实现“达到总限额后手动重置用量”
+  totalCostResetAt: Date | null;
   limitConcurrentSessions: number;
 
   // 熔断器配置(每个供应商独立配置)
@@ -146,6 +150,7 @@ export interface ProviderDisplay {
   dailyResetTime: string;
   limitWeeklyUsd: number | null;
   limitMonthlyUsd: number | null;
+  limitTotalUsd: number | null;
   limitConcurrentSessions: number;
   // 熔断器配置
   maxRetryAttempts: number | null;
@@ -209,6 +214,7 @@ export interface CreateProviderData {
   daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
+  limit_total_usd?: number | null;
   limit_concurrent_sessions?: number;
 
   // 熔断器配置
@@ -274,6 +280,7 @@ export interface UpdateProviderData {
   daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
+  limit_total_usd?: number | null;
   limit_concurrent_sessions?: number;
 
   // 熔断器配置

+ 57 - 0
tests/unit/lib/rate-limit/cost-limits.test.ts

@@ -38,6 +38,7 @@ const statisticsMock = {
   // total cost
   sumKeyTotalCost: vi.fn(async () => 0),
   sumUserTotalCost: vi.fn(async () => 0),
+  sumProviderTotalCost: vi.fn(async () => 0),
 
   // fixed-window sums
   sumKeyCostInTimeRange: vi.fn(async () => 0),
@@ -201,6 +202,62 @@ describe("RateLimitService - cost limits and quota checks", () => {
     expect(redisClient.setex).toHaveBeenCalledWith("total_cost:user:7", 300, "5");
   });
 
+  it("checkTotalCostLimit:Provider Redis miss 时应 fallback DB 并写回缓存(cache key 应包含 resetAt)", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const resetAt = new Date(nowMs - 123_000);
+
+    redisClient.get.mockResolvedValueOnce(null);
+    statisticsMock.sumProviderTotalCost.mockResolvedValueOnce(5);
+
+    const result = await RateLimitService.checkTotalCostLimit(9, "provider", 10, {
+      resetAt,
+    });
+
+    expect(result.allowed).toBe(true);
+    expect(result.current).toBe(5);
+    expect(statisticsMock.sumProviderTotalCost).toHaveBeenCalledTimes(1);
+    expect(statisticsMock.sumProviderTotalCost).toHaveBeenCalledWith(9, resetAt);
+    expect(redisClient.setex).toHaveBeenCalledWith(
+      `total_cost:provider:9:${resetAt.getTime()}`,
+      300,
+      "5"
+    );
+  });
+
+  it("checkTotalCostLimit:Provider resetAt 为空时应使用 none key 并回退到 DB", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockResolvedValueOnce(null);
+    statisticsMock.sumProviderTotalCost.mockResolvedValueOnce(5);
+
+    const result = await RateLimitService.checkTotalCostLimit(9, "provider", 10, {
+      resetAt: null,
+    });
+
+    expect(result.allowed).toBe(true);
+    expect(result.current).toBe(5);
+    expect(statisticsMock.sumProviderTotalCost).toHaveBeenCalledWith(9, null);
+    expect(redisClient.setex).toHaveBeenCalledWith("total_cost:provider:9:none", 300, "5");
+  });
+
+  it("checkTotalCostLimit:Provider Redis cache hit 且已超限时应返回 not allowed(按 resetAt key 命中)", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const resetAt = new Date(nowMs - 456_000);
+
+    redisClient.get.mockImplementation(async (key: string) => {
+      if (key === `total_cost:provider:9:${resetAt.getTime()}`) return "20";
+      return null;
+    });
+
+    const result = await RateLimitService.checkTotalCostLimit(9, "provider", 10, {
+      resetAt,
+    });
+    expect(result.allowed).toBe(false);
+    expect(result.current).toBe(20);
+  });
+
   it("checkUserDailyCost:fixed 模式 cache hit 超限时应拦截", async () => {
     const { RateLimitService } = await import("@/lib/rate-limit");
 

+ 163 - 0
tests/unit/proxy/provider-selector-total-limit.test.ts

@@ -0,0 +1,163 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import type { Provider } from "@/types/provider";
+
+const circuitBreakerMocks = vi.hoisted(() => ({
+  isCircuitOpen: vi.fn(async () => false),
+  getCircuitState: vi.fn(() => "closed"),
+}));
+
+vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks);
+
+const sessionManagerMocks = vi.hoisted(() => ({
+  SessionManager: {
+    getSessionProvider: vi.fn(async () => null as number | null),
+  },
+}));
+
+vi.mock("@/lib/session-manager", () => sessionManagerMocks);
+
+const providerRepositoryMocks = vi.hoisted(() => ({
+  findProviderById: vi.fn(async () => null as Provider | null),
+  findAllProviders: vi.fn(async () => [] as Provider[]),
+}));
+
+vi.mock("@/repository/provider", () => providerRepositoryMocks);
+
+const rateLimitMocks = vi.hoisted(() => ({
+  RateLimitService: {
+    checkCostLimits: vi.fn(async () => ({ allowed: true })),
+    checkTotalCostLimit: vi.fn(async () => ({ allowed: true, current: 0 })),
+  },
+}));
+
+vi.mock("@/lib/rate-limit", () => rateLimitMocks);
+
+beforeEach(() => {
+  vi.resetAllMocks();
+});
+
+describe("ProxyProviderResolver.filterByLimits - provider total limit", () => {
+  test("当供应商达到总消费上限时应被过滤掉", async () => {
+    const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+    const resetAt = new Date("2026-01-04T00:00:00.000Z");
+
+    const providers: Provider[] = [
+      {
+        id: 1,
+        name: "p1",
+        isEnabled: true,
+        providerType: "claude",
+        groupTag: null,
+        weight: 1,
+        priority: 0,
+        costMultiplier: 1,
+        // rate limit fields
+        limit5hUsd: null,
+        limitDailyUsd: null,
+        dailyResetMode: "fixed",
+        dailyResetTime: "00:00",
+        limitWeeklyUsd: null,
+        limitMonthlyUsd: null,
+        limitTotalUsd: 10,
+        totalCostResetAt: resetAt,
+        limitConcurrentSessions: 0,
+      } as unknown as Provider,
+      {
+        id: 2,
+        name: "p2",
+        isEnabled: true,
+        providerType: "claude",
+        groupTag: null,
+        weight: 1,
+        priority: 0,
+        costMultiplier: 1,
+        limit5hUsd: null,
+        limitDailyUsd: null,
+        dailyResetMode: "fixed",
+        dailyResetTime: "00:00",
+        limitWeeklyUsd: null,
+        limitMonthlyUsd: null,
+        limitTotalUsd: null,
+        totalCostResetAt: null,
+        limitConcurrentSessions: 0,
+      } as unknown as Provider,
+    ];
+
+    rateLimitMocks.RateLimitService.checkTotalCostLimit.mockImplementation(async (id: number) => {
+      if (id === 1) return { allowed: false, current: 10, reason: "limit reached" };
+      return { allowed: true, current: 0 };
+    });
+
+    const filtered = await (ProxyProviderResolver as any).filterByLimits(providers);
+    expect(filtered.map((p: Provider) => p.id)).toEqual([2]);
+
+    expect(rateLimitMocks.RateLimitService.checkTotalCostLimit).toHaveBeenCalledWith(
+      1,
+      "provider",
+      10,
+      { resetAt }
+    );
+  });
+});
+
+describe("ProxyProviderResolver.findReusable - provider total limit", () => {
+  test("当会话复用的供应商达到总限额时应拒绝复用", async () => {
+    const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+    const resetAt = new Date("2026-01-04T00:00:00.000Z");
+
+    sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(1);
+    providerRepositoryMocks.findProviderById.mockResolvedValueOnce({
+      id: 1,
+      name: "p1",
+      isEnabled: true,
+      providerType: "claude",
+      groupTag: null,
+      weight: 1,
+      priority: 0,
+      costMultiplier: 1,
+      limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: 10,
+      totalCostResetAt: resetAt,
+      limitConcurrentSessions: 0,
+    } as unknown as Provider);
+
+    rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
+      allowed: false,
+      current: 10,
+      reason: "limit reached",
+    });
+
+    const session = {
+      sessionId: "s1",
+      shouldReuseProvider: () => true,
+      authState: null,
+      getCurrentModel: () => null,
+    } as any;
+
+    const reused = await (ProxyProviderResolver as any).findReusable(session);
+    expect(reused).toBeNull();
+
+    expect(rateLimitMocks.RateLimitService.checkCostLimits).toHaveBeenCalledWith(1, "provider", {
+      limit_5h_usd: null,
+      limit_daily_usd: null,
+      daily_reset_mode: "fixed",
+      daily_reset_time: "00:00",
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    expect(rateLimitMocks.RateLimitService.checkTotalCostLimit).toHaveBeenCalledWith(
+      1,
+      "provider",
+      10,
+      { resetAt }
+    );
+  });
+});

+ 314 - 0
tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx

@@ -0,0 +1,314 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { Dialog } from "@/components/ui/dialog";
+import { ProviderForm } from "@/app/[locale]/settings/providers/_components/forms/provider-form";
+
+const sonnerMocks = vi.hoisted(() => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+vi.mock("sonner", () => sonnerMocks);
+
+const providersActionMocks = vi.hoisted(() => ({
+  addProvider: vi.fn(async () => ({ ok: true })),
+  editProvider: vi.fn(async () => ({ ok: true })),
+  removeProvider: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/providers", () => providersActionMocks);
+
+const requestFiltersActionMocks = vi.hoisted(() => ({
+  getDistinctProviderGroupsAction: vi.fn(async () => ({ ok: true, data: [] })),
+}));
+vi.mock("@/actions/request-filters", () => requestFiltersActionMocks);
+
+function loadMessages() {
+  const base = path.join(process.cwd(), "messages/en");
+  const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8"));
+
+  return {
+    common: read("common.json"),
+    errors: read("errors.json"),
+    ui: read("ui.json"),
+    forms: read("forms.json"),
+    settings: read("settings.json"),
+  };
+}
+
+function render(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(node);
+  });
+
+  return {
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+function setNativeValue(element: HTMLInputElement, value: string) {
+  const prototype = Object.getPrototypeOf(element) as unknown as { value?: unknown };
+  const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
+  if (descriptor?.set) {
+    descriptor.set.call(element, value);
+    return;
+  }
+  element.value = value;
+}
+
+describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    // happy-dom 在部分运行时可能不会提供完整的 Storage 实现,这里做最小 mock,避免组件读写报错
+    // 仅用于本测试文件,避免污染全局行为
+    const storage = (() => {
+      let store: Record<string, string> = {};
+      return {
+        getItem: (key: string) => (Object.hasOwn(store, key) ? store[key] : null),
+        setItem: (key: string, value: string) => {
+          store[key] = String(value);
+        },
+        removeItem: (key: string) => {
+          delete store[key];
+        },
+        clear: () => {
+          store = {};
+        },
+        key: (index: number) => Object.keys(store)[index] ?? null,
+        get length() {
+          return Object.keys(store).length;
+        },
+      };
+    })();
+
+    Object.defineProperty(globalThis, "localStorage", {
+      value: storage,
+      configurable: true,
+    });
+
+    storage.setItem("provider-form-sections", JSON.stringify({ rateLimit: true }));
+  });
+
+  test("填写总消费上限后提交应调用 editProvider 且 payload 携带 limit_total_usd", async () => {
+    const messages = loadMessages();
+
+    const provider = {
+      id: 1,
+      name: "p",
+      url: "https://example.com",
+      maskedKey: "xxxxxx",
+      isEnabled: true,
+      weight: 1,
+      priority: 0,
+      costMultiplier: 1,
+      groupTag: null,
+      providerType: "claude",
+      preserveClientIp: false,
+      modelRedirects: null,
+      allowedModels: null,
+      joinClaudePool: false,
+      codexInstructionsStrategy: "auto",
+      mcpPassthroughType: "none",
+      mcpPassthroughUrl: null,
+      limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitConcurrentSessions: 0,
+      maxRetryAttempts: null,
+      circuitBreakerFailureThreshold: 5,
+      circuitBreakerOpenDuration: 1800000,
+      circuitBreakerHalfOpenSuccessThreshold: 2,
+      proxyUrl: null,
+      proxyFallbackToDirect: false,
+      firstByteTimeoutStreamingMs: 0,
+      streamingIdleTimeoutMs: 0,
+      requestTimeoutNonStreamingMs: 0,
+      websiteUrl: null,
+      faviconUrl: null,
+      cacheTtlPreference: null,
+      context1mPreference: null,
+      tpm: null,
+      rpm: null,
+      rpd: null,
+      cc: null,
+      createdAt: "2026-01-04",
+      updatedAt: "2026-01-04",
+    } as any;
+
+    const { unmount } = render(
+      <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
+        <Dialog open onOpenChange={() => {}}>
+          <ProviderForm mode="edit" provider={provider} enableMultiProviderTypes />
+        </Dialog>
+      </NextIntlClientProvider>
+    );
+
+    // 等待 useEffect 从 localStorage 打开折叠区域
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    const totalInput = document.getElementById("edit-limit-total") as HTMLInputElement | null;
+    expect(totalInput).toBeTruthy();
+
+    await act(async () => {
+      if (!totalInput) return;
+      setNativeValue(totalInput, "10.5");
+      totalInput.dispatchEvent(new Event("input", { bubbles: true }));
+      totalInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    const form = document.body.querySelector("form") as HTMLFormElement | null;
+    expect(form).toBeTruthy();
+
+    await act(async () => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    // React 的 transition 可能会延后调度,这里给一个很小的等待窗口,避免测试偶发抢跑
+    for (let i = 0; i < 5; i++) {
+      if (providersActionMocks.editProvider.mock.calls.length > 0) break;
+      await act(async () => {
+        await new Promise((r) => setTimeout(r, 0));
+      });
+    }
+
+    expect(providersActionMocks.editProvider).toHaveBeenCalledTimes(1);
+    const [, payload] = providersActionMocks.editProvider.mock.calls[0] as [number, any];
+    expect(Object.hasOwn(payload, "limit_total_usd")).toBe(true);
+    expect(payload.limit_total_usd).toBe(10.5);
+
+    unmount();
+  });
+});
+
+describe("ProviderForm: 新增成功后应重置总消费上限输入", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    const storage = (() => {
+      let store: Record<string, string> = {};
+      return {
+        getItem: (key: string) => (Object.hasOwn(store, key) ? store[key] : null),
+        setItem: (key: string, value: string) => {
+          store[key] = String(value);
+        },
+        removeItem: (key: string) => {
+          delete store[key];
+        },
+        clear: () => {
+          store = {};
+        },
+        key: (index: number) => Object.keys(store)[index] ?? null,
+        get length() {
+          return Object.keys(store).length;
+        },
+      };
+    })();
+
+    Object.defineProperty(globalThis, "localStorage", {
+      value: storage,
+      configurable: true,
+    });
+
+    storage.setItem("provider-form-sections", JSON.stringify({ rateLimit: true }));
+  });
+
+  test("提交新增后应清空 limit_total_usd,避免连续添加沿用上一次输入", async () => {
+    const messages = loadMessages();
+
+    const { unmount } = render(
+      <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
+        <Dialog open onOpenChange={() => {}}>
+          <ProviderForm mode="create" enableMultiProviderTypes />
+        </Dialog>
+      </NextIntlClientProvider>
+    );
+
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    const nameInput = document.getElementById("name") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+    const keyInput = document.getElementById("key") as HTMLInputElement | null;
+    expect(nameInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+    expect(keyInput).toBeTruthy();
+
+    await act(async () => {
+      if (!nameInput || !urlInput || !keyInput) return;
+      setNativeValue(nameInput, "p2");
+      nameInput.dispatchEvent(new Event("input", { bubbles: true }));
+      nameInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(urlInput, "https://example.com");
+      urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      urlInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(keyInput, "k");
+      keyInput.dispatchEvent(new Event("input", { bubbles: true }));
+      keyInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    const totalInput = document.getElementById("limit-total") as HTMLInputElement | null;
+    expect(totalInput).toBeTruthy();
+
+    await act(async () => {
+      if (!totalInput) return;
+      setNativeValue(totalInput, "10.5");
+      totalInput.dispatchEvent(new Event("input", { bubbles: true }));
+      totalInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    const form = document.body.querySelector("form") as HTMLFormElement | null;
+    expect(form).toBeTruthy();
+
+    await act(async () => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    for (let i = 0; i < 5; i++) {
+      if (providersActionMocks.addProvider.mock.calls.length > 0) break;
+      await act(async () => {
+        await new Promise((r) => setTimeout(r, 0));
+      });
+    }
+
+    expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(1);
+    const [payload] = providersActionMocks.addProvider.mock.calls[0] as [any];
+    expect(payload.limit_total_usd).toBe(10.5);
+
+    // 等待一次调度,让 React 处理新增成功后的 state 重置
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+
+    // 成功后应清空输入(state -> null -> input value 变为空字符串)
+    expect((document.getElementById("limit-total") as HTMLInputElement | null)?.value ?? null).toBe(
+      ""
+    );
+
+    unmount();
+  });
+});