diff --git a/drizzle/0045_mushy_human_torch.sql b/drizzle/0045_mushy_human_torch.sql new file mode 100644 index 000000000..6186efcdc --- /dev/null +++ b/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; \ No newline at end of file diff --git a/drizzle/meta/0045_snapshot.json b/drizzle/meta/0045_snapshot.json new file mode 100644 index 000000000..87471805e --- /dev/null +++ b/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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b676d0264..7fe5da327 100644 --- a/drizzle/meta/_journal.json +++ b/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 } ] } \ No newline at end of file diff --git a/messages/en/settings.json b/messages/en/settings.json index f95ffc0de..7bf16211a 100644 --- a/messages/en/settings.json +++ b/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" diff --git a/messages/ja/settings.json b/messages/ja/settings.json index 8d93e5f5d..29d021427 100644 --- a/messages/ja/settings.json +++ b/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": "切り替えに失敗しました", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 1531a8e04..ee406d008 100644 --- a/messages/ru/settings.json +++ b/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": "Не удалось переключить", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 7a5f4c41a..0fe9147b2 100644 --- a/messages/zh-CN/settings.json +++ b/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 表示无限制" diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index f7758333f..7b0572f08 100644 --- a/messages/zh-TW/settings.json +++ b/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": "狀態切換失敗", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index cc9984ea9..953cbc0ac 100644 --- a/src/actions/providers.ts +++ b/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 { 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 { + 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 }; + } +} + /** * 获取供应商限额使用情况 */ diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx index d241912cf..aaed753e4 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx +++ b/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( sourceProvider?.limitMonthlyUsd ?? null ); + const [limitTotalUsd, setLimitTotalUsd] = useState( + sourceProvider?.limitTotalUsd ?? null + ); const [limitConcurrentSessions, setLimitConcurrentSessions] = useState( 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" /> +
+ + + setLimitTotalUsd(validatePositiveDecimalField(e.target.value)) + } + placeholder={t("sections.rateLimit.limitTotal.placeholder")} + disabled={isPending} + min="0" + step="0.01" + /> +
@@ -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" diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 8fb666d16..cbf4b4d88 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/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({ )} + {/* 总用量重置按钮(仅配置了总限额时显示) */} + {canEdit && provider.limitTotalUsd !== null && provider.limitTotalUsd > 0 && ( + + )} + {/* 删除按钮 */} {canEdit && ( diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 4c708eb8c..5e991093a 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/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; diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 4b4a08906..e5ba1683f 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/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) { diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 6d80c8ea6..76b0fdde6 100644 --- a/src/drizzle/schema.ts +++ b/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), // 熔断器配置(每个供应商独立配置) diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index e6fe888ea..6451ffb7e 100644 --- a/src/lib/rate-limit/service.ts +++ b/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})`, }; } diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index c8d2f3337..bc25ec410 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -32,4 +32,5 @@ export { isValidUrl, maskKey, validateNumericField, + validatePositiveDecimalField, } from "./validation"; diff --git a/src/lib/utils/validation/index.ts b/src/lib/utils/validation/index.ts index 23cc9f794..d5ae57261 100644 --- a/src/lib/utils/validation/index.ts +++ b/src/lib/utils/validation/index.ts @@ -4,6 +4,7 @@ export { clampWeight, formatTpmDisplay, validateNumericField, + validatePositiveDecimalField, } from "./provider"; import { logger } from "@/lib/logger"; diff --git a/src/lib/utils/validation/provider.ts b/src/lib/utils/validation/provider.ts index 369b591d1..5476223b3 100644 --- a/src/lib/utils/validation/provider.ts +++ b/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; +} + /** * 限制权重值在有效范围内 */ diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 406157740..8a8fffa4f 100644 --- a/src/lib/validation/schemas.ts +++ b/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上限必须是整数") diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 57fa9c07a..bcc9b4abe 100644 --- a/src/repository/_shared/transformers.ts +++ b/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 diff --git a/src/repository/provider.ts b/src/repository/provider.ts index ae9dffc4e..2440e1afe 100644 --- a/src/repository/provider.ts +++ b/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 { 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 { 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 { return result.length > 0; } +/** + * 手动重置供应商“总消费”统计起点 + * + * 说明: + * - 不删除 message_request 历史记录,仅通过 resetAt 作为聚合下限实现“从 0 重新累计”。 + */ +export async function resetProviderTotalCostResetAt( + providerId: number, + resetAt: Date +): Promise { + 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; +} + /** * 获取所有不同的供应商分组标签 * 用于用户表单中的供应商分组选择建议 diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index bfd29ae80..2ac24e250 100644 --- a/src/repository/statistics.ts +++ b/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 { + const effectiveStart = + resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) ? resetAt : null; + + const result = await db + .select({ total: sql`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); +} + /** * 查询用户在指定时间范围内的消费总和 * 用于用户层限额百分比显示 diff --git a/src/types/provider.ts b/src/types/provider.ts index e3490fa60..ed3333197 100644 --- a/src/types/provider.ts +++ b/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; // 熔断器配置 diff --git a/tests/unit/lib/rate-limit/cost-limits.test.ts b/tests/unit/lib/rate-limit/cost-limits.test.ts index b92e250af..aa3634baf 100644 --- a/tests/unit/lib/rate-limit/cost-limits.test.ts +++ b/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"); diff --git a/tests/unit/proxy/provider-selector-total-limit.test.ts b/tests/unit/proxy/provider-selector-total-limit.test.ts new file mode 100644 index 000000000..ecb93f34e --- /dev/null +++ b/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 } + ); + }); +}); diff --git a/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx b/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx new file mode 100644 index 000000000..aae246353 --- /dev/null +++ b/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 = {}; + 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( + + {}}> + + + + ); + + // 等待 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 = {}; + 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( + + {}}> + + + + ); + + 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(); + }); +});