diff --git a/drizzle/0078_wild_scourge.sql b/drizzle/0078_wild_scourge.sql new file mode 100644 index 000000000..62ce629d8 --- /dev/null +++ b/drizzle/0078_wild_scourge.sql @@ -0,0 +1,12 @@ +ALTER TABLE "keys" ADD COLUMN "limit_concurrent_uas" integer DEFAULT 0;--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN "limit_concurrent_uas" integer DEFAULT 0;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "limit_concurrent_uas" integer;--> statement-breakpoint +ALTER TABLE "keys" + ADD CONSTRAINT "keys_limit_concurrent_uas_non_negative" + CHECK ("limit_concurrent_uas" >= 0);--> statement-breakpoint +ALTER TABLE "providers" + ADD CONSTRAINT "providers_limit_concurrent_uas_non_negative" + CHECK ("limit_concurrent_uas" >= 0);--> statement-breakpoint +ALTER TABLE "users" + ADD CONSTRAINT "users_limit_concurrent_uas_non_negative" + CHECK ("limit_concurrent_uas" IS NULL OR "limit_concurrent_uas" >= 0); diff --git a/drizzle/meta/0078_snapshot.json b/drizzle/meta/0078_snapshot.json new file mode 100644 index 000000000..09fd26e40 --- /dev/null +++ b/drizzle/meta/0078_snapshot.json @@ -0,0 +1,3928 @@ +{ + "id": "ef3a73f0-fba7-40a4-8f77-22cac4b92283", + "prevId": "22eb3652-56d7-4a04-9845-5fa18210ef90", + "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 with time zone", + "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 + }, + "limit_concurrent_uas": { + "name": "limit_concurrent_uas", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "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_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "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": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "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": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "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 + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": 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_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "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 AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "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_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_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 AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "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_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "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_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "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_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "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": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "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 + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "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": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "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 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "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 + }, + "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.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "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": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "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": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_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.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 + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "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 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "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" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": 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 + }, + "limit_concurrent_uas": { + "name": "limit_concurrent_uas", + "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 + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_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_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "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": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "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 + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_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 + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "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 + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "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 + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "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": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "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": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_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": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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(200)", + "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 + }, + "limit_concurrent_uas": { + "name": "limit_concurrent_uas", + "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" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "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_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "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", + "cache_hit_rate_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 fb7b5a646..191a24a72 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1772219877045, "tag": "0077_nappy_giant_man", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1772345662783, + "tag": "0078_wild_scourge", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 15195b0c5..d032e7f7f 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -910,9 +910,15 @@ }, "limitConcurrentSessions": { "label": "Concurrent Session Limit", - "placeholder": "0 means unlimited", - "description": "Number of simultaneous conversations", - "descriptionWithUserLimit": "Maximum sessions (User limit: {limit})" + "placeholder": "0 means inherit user limit", + "description": "Number of simultaneous conversations (0 inherits user limit)", + "descriptionWithUserLimit": "Number of simultaneous conversations (0 inherits user limit; user limit: {limit})" + }, + "limitConcurrentUas": { + "label": "Concurrent UA Limit", + "placeholder": "0 means inherit user limit", + "description": "Number of active UAs (User-Agent) (0 inherits user limit)", + "descriptionWithUserLimit": "Number of active UAs (User-Agent) (0 inherits user limit; user limit: {limit})" }, "providerGroup": { "label": "Provider Group", @@ -1014,6 +1020,11 @@ "placeholder": "0 means unlimited", "description": "Number of simultaneous conversations" }, + "limitConcurrentUas": { + "label": "Concurrent UA Limit", + "placeholder": "0 means unlimited", + "description": "Number of active UAs (User-Agent)" + }, "isEnabled": { "label": "Enable User", "description": "Disabled users cannot access the API" @@ -1659,7 +1670,8 @@ "limitWeekly": "Weekly Limit", "limitMonthly": "Monthly Limit", "limitTotal": "Total Limit", - "limitSessions": "Concurrent Sessions" + "limitSessions": "Concurrent Sessions", + "limitUas": "Concurrent UAs" }, "ruleTypes": { "limitRpm": "RPM limit", @@ -1668,7 +1680,8 @@ "limitWeekly": "Weekly limit", "limitMonthly": "Monthly limit", "limitTotal": "Total limit", - "limitSessions": "Concurrent sessions" + "limitSessions": "Concurrent sessions", + "limitUas": "Concurrent UAs" }, "errors": { "missingType": "Please select a limit type", diff --git a/messages/en/errors.json b/messages/en/errors.json index cf7fcfc16..a6bce20f0 100644 --- a/messages/en/errors.json +++ b/messages/en/errors.json @@ -57,6 +57,7 @@ "RATE_LIMIT_MONTHLY_EXCEEDED": "Monthly cost limit exceeded: ${current} USD (limit: ${limit} USD). Resets at {resetTime}", "RATE_LIMIT_TOTAL_EXCEEDED": "Total spending limit exceeded: ${current} USD (limit: ${limit} USD)", "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED": "Concurrent sessions limit exceeded: {current} sessions (limit: {limit}). Please wait for active sessions to complete", + "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED": "Concurrent user agents limit exceeded: {current} UAs (limit: {limit}). Please wait for active UAs to free up", "RATE_LIMIT_DAILY_QUOTA_EXCEEDED": "Daily quota exceeded: ${current} USD (limit: ${limit} USD). Resets at {resetTime}", "RATE_LIMIT_DAILY_ROLLING_EXCEEDED": "24-hour rolling window cost limit exceeded: ${current} USD (limit: ${limit} USD). Usage gradually expires over the past 24 hours", @@ -82,6 +83,7 @@ "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Key monthly limit ({keyLimit}) cannot exceed user limit ({userLimit})", "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Key total limit ({keyLimit}) cannot exceed user limit ({userLimit})", "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Key concurrent session limit ({keyLimit}) cannot exceed user limit ({userLimit})", + "KEY_LIMIT_CONCURRENT_UAS_EXCEEDS_USER_LIMIT": "Key concurrent UA limit ({keyLimit}) cannot exceed user limit ({userLimit})", "NO_DEFAULT_GROUP_PERMISSION": "No permission to use default group. You don't have a Key with default group", "NO_GROUP_PERMISSION": "No permission to use the following groups: {groups}" } diff --git a/messages/en/quota.json b/messages/en/quota.json index d50b2b534..70eebe1a8 100644 --- a/messages/en/quota.json +++ b/messages/en/quota.json @@ -103,6 +103,7 @@ "limitMonthly": "Monthly Limit", "limitTotal": "Total Limit", "limitConcurrent": "Concurrent Sessions", + "limitConcurrentUas": "Concurrent UAs", "role": { "admin": "Admin", "user": "User" @@ -336,6 +337,12 @@ "description": "Number of simultaneous conversations", "descriptionWithUserLimit": "Cannot exceed user session limit ({limit})" }, + "limitConcurrentUas": { + "label": "Concurrent UA Limit", + "placeholder": "0 means unlimited", + "description": "Number of active UAs (User-Agent)", + "descriptionWithUserLimit": "Cannot exceed user UA limit ({limit})" + }, "providerGroup": { "label": "Provider Group", "placeholder": "Enter provider group tags, press Enter to add", diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index aec6461f4..7cc73b3fe 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -131,6 +131,10 @@ "label": "Concurrent Sessions Limit", "placeholder": "0 means unlimited" }, + "limitConcurrentUas": { + "label": "Concurrent UA Limit", + "placeholder": "0 means unlimited" + }, "limitDaily": { "label": "Daily Spend Limit (USD)", "placeholder": "Leave empty for unlimited" @@ -149,6 +153,7 @@ }, "summary": { "concurrent": "Concurrent: {count}", + "concurrentUas": "UAs: {count}", "daily": "Day: ${amount} (reset ${resetTime})", "fiveHour": "5h: ${amount}", "monthly": "Month: ${amount}", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 1555e52af..264c6d238 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -902,9 +902,15 @@ }, "limitConcurrentSessions": { "label": "同時セッション上限", - "placeholder": "0は無制限を意味します", - "description": "同時に実行される会話の数", - "descriptionWithUserLimit": "最大セッション数 (ユーザー上限: {limit})" + "placeholder": "0 はユーザー上限を継承します", + "description": "同時に実行される会話の数 (0 はユーザー上限を継承)", + "descriptionWithUserLimit": "同時に実行される会話の数 (0 はユーザー上限を継承; ユーザー上限: {limit})" + }, + "limitConcurrentUas": { + "label": "同時 UA 上限", + "placeholder": "0 はユーザー上限を継承します", + "description": "アクティブな UA (User-Agent) の数 (0 はユーザー上限を継承)", + "descriptionWithUserLimit": "アクティブな UA (User-Agent) の数 (0 はユーザー上限を継承; ユーザー上限: {limit})" }, "providerGroup": { "label": "プロバイダーグループ", @@ -1001,6 +1007,11 @@ "placeholder": "0は無制限を意味します", "description": "同時に実行される会話の数" }, + "limitConcurrentUas": { + "label": "同時 UA 上限", + "placeholder": "0は無制限を意味します", + "description": "アクティブな UA (User-Agent) の数" + }, "isEnabled": { "label": "ユーザーを有効化", "description": "無効にするとユーザーはAPIにアクセスできなくなります" @@ -1611,7 +1622,8 @@ "limitWeekly": "週次上限", "limitMonthly": "月次上限", "limitTotal": "総上限", - "limitSessions": "同時セッション" + "limitSessions": "同時セッション", + "limitUas": "同時 UA" }, "dailyMode": { "fixed": "固定時刻でリセット", diff --git a/messages/ja/errors.json b/messages/ja/errors.json index 1bd1d63a6..1259e414b 100644 --- a/messages/ja/errors.json +++ b/messages/ja/errors.json @@ -55,6 +55,7 @@ "RATE_LIMIT_MONTHLY_EXCEEDED": "月次コスト制限を超過しました:${current} USD(制限:${limit} USD)。{resetTime} にリセットされます", "RATE_LIMIT_TOTAL_EXCEEDED": "総支出制限を超過しました:${current} / ${limit} USD", "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED": "同時セッション制限を超過しました:現在 {current} セッション(制限:{limit})。アクティブなセッションが完了するまでお待ちください", + "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED": "同時 UA 制限を超過しました:現在 {current} 個(制限:{limit} 個)。アクティブな UA が解放されるまでお待ちください", "RESOURCE_BUSY": "リソースは現在使用中です", "INVALID_STATE": "現在の状態では操作が許可されていません", "CONFLICT": "操作の競合", @@ -77,6 +78,7 @@ "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "キーの月次上限({keyLimit})はユーザー上限({userLimit})を超えられません", "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "キーの総上限({keyLimit})はユーザー上限({userLimit})を超えられません", "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "キーの同時セッション上限({keyLimit})はユーザー上限({userLimit})を超えられません", + "KEY_LIMIT_CONCURRENT_UAS_EXCEEDS_USER_LIMIT": "キーの同時 UA 上限({keyLimit})はユーザー上限({userLimit})を超えられません", "EXPIRES_AT_FIELD": "有効期限", "EXPIRES_AT_MUST_BE_FUTURE": "有効期限は将来の日付である必要があります", "EXPIRES_AT_TOO_FAR": "有効期限は10年を超えることはできません", diff --git a/messages/ja/quota.json b/messages/ja/quota.json index 874c033bf..c142b6559 100644 --- a/messages/ja/quota.json +++ b/messages/ja/quota.json @@ -87,7 +87,8 @@ "warning": "制限に近い (>60%)", "exceeded": "超過 (≥100%)" }, - "expiresAtLabel": "有効期限" + "expiresAtLabel": "有効期限", + "limitConcurrentUas": "同時 UA" }, "providers": { "title": "プロバイダークォータ統計", @@ -313,6 +314,12 @@ "description": "同時実行可能な会話数", "descriptionWithUserLimit": "ユーザーのセッション制限を超えることはできません ({limit})" }, + "limitConcurrentUas": { + "label": "同時 UA 上限", + "placeholder": "0 = 無制限", + "description": "アクティブな UA(User-Agent)の数", + "descriptionWithUserLimit": "ユーザーの UA 制限を超えることはできません ({limit})" + }, "providerGroup": { "label": "プロバイダーグループ", "placeholder": "プロバイダーグループタグを入力し、Enterで追加", diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 3ec41f904..e232b3556 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -132,6 +132,10 @@ "label": "同時セッション上限", "placeholder": "0 は無制限" }, + "limitConcurrentUas": { + "label": "同時 UA 上限", + "placeholder": "0 は無制限" + }, "limitDaily": { "label": "1日の支出上限 (USD)", "placeholder": "空欄は無制限" @@ -150,6 +154,7 @@ }, "summary": { "concurrent": "同時: {count}", + "concurrentUas": "UA: {count}", "daily": "日: {amount}({resetTime}にリセット)", "fiveHour": "5h: ${amount}", "monthly": "月: ${amount}", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 64a2c8211..3e36e09f4 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -904,9 +904,15 @@ }, "limitConcurrentSessions": { "label": "Лимит параллельных сеансов", - "placeholder": "0 означает неограниченно", - "description": "Количество одновременных разговоров", - "descriptionWithUserLimit": "Максимум сеансов (Лимит пользователя: {limit})" + "placeholder": "0 наследует лимит пользователя", + "description": "Количество одновременных разговоров (0 наследует лимит пользователя)", + "descriptionWithUserLimit": "Количество одновременных разговоров (0 наследует лимит пользователя; лимит пользователя: {limit})" + }, + "limitConcurrentUas": { + "label": "Лимит одновременных UA", + "placeholder": "0 наследует лимит пользователя", + "description": "Количество активных UA (User-Agent) (0 наследует лимит пользователя)", + "descriptionWithUserLimit": "Количество активных UA (User-Agent) (0 наследует лимит пользователя; лимит пользователя: {limit})" }, "providerGroup": { "label": "Группа провайдеров", @@ -1003,6 +1009,11 @@ "placeholder": "0 означает неограниченно", "description": "Количество одновременных разговоров" }, + "limitConcurrentUas": { + "label": "Лимит одновременных UA", + "placeholder": "0 означает неограниченно", + "description": "Количество активных UA (User-Agent)" + }, "isEnabled": { "label": "Активировать пользователя", "description": "Отключенные пользователи не смогут использовать API" @@ -1642,7 +1653,8 @@ "limitWeekly": "Недельный лимит", "limitMonthly": "Месячный лимит", "limitTotal": "Общий лимит", - "limitSessions": "Одновременные сессии" + "limitSessions": "Одновременные сессии", + "limitUas": "Одновременные UA" }, "ruleTypes": { "limitRpm": "Лимит RPM", @@ -1651,7 +1663,8 @@ "limitWeekly": "Недельный лимит", "limitMonthly": "Месячный лимит", "limitTotal": "Общий лимит", - "limitSessions": "Одновременные сессии" + "limitSessions": "Одновременные сессии", + "limitUas": "Одновременные UA" }, "errors": { "missingType": "Пожалуйста, выберите тип лимита", diff --git a/messages/ru/errors.json b/messages/ru/errors.json index e2fec48cf..2d614db28 100644 --- a/messages/ru/errors.json +++ b/messages/ru/errors.json @@ -55,6 +55,7 @@ "RATE_LIMIT_MONTHLY_EXCEEDED": "Превышен месячный лимит расходов: ${current} USD (лимит: ${limit} USD). Сброс в {resetTime}", "RATE_LIMIT_TOTAL_EXCEEDED": "Превышен общий лимит расходов: ${current} / ${limit} USD", "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED": "Превышен лимит одновременных сессий: {current} сессий (лимит: {limit}). Пожалуйста, дождитесь завершения активных сессий", + "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED": "Превышен лимит одновременных UA: {current} (лимит: {limit}). Пожалуйста, дождитесь освобождения активных UA", "RESOURCE_BUSY": "Ресурс в настоящее время используется", "INVALID_STATE": "Операция не разрешена в текущем состоянии", "CONFLICT": "Конфликт операции", @@ -77,6 +78,7 @@ "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Месячный лимит ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})", "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Общий лимит ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})", "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Лимит одновременных сессий ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})", + "KEY_LIMIT_CONCURRENT_UAS_EXCEEDS_USER_LIMIT": "Лимит одновременных UA ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})", "EXPIRES_AT_FIELD": "Дата истечения", "EXPIRES_AT_MUST_BE_FUTURE": "Дата истечения должна быть в будущем", "EXPIRES_AT_TOO_FAR": "Дата истечения не может превышать 10 лет", diff --git a/messages/ru/quota.json b/messages/ru/quota.json index 293e2d2fb..834ee0337 100644 --- a/messages/ru/quota.json +++ b/messages/ru/quota.json @@ -103,6 +103,7 @@ "limitMonthly": "Месячный лимит", "limitTotal": "Общий лимит", "limitConcurrent": "Параллельные сессии", + "limitConcurrentUas": "Одновременные UA", "role": { "admin": "Администратор", "user": "Пользователь" @@ -336,6 +337,12 @@ "description": "Количество одновременных диалогов", "descriptionWithUserLimit": "Не может превышать лимит пользователя ({limit})" }, + "limitConcurrentUas": { + "label": "Лимит одновременных UA", + "placeholder": "0 = без ограничений", + "description": "Количество активных UA (User-Agent)", + "descriptionWithUserLimit": "Не может превышать лимит UA пользователя ({limit})" + }, "providerGroup": { "label": "Группа провайдеров", "placeholder": "Введите теги групп провайдеров и нажмите Enter", diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 594eef96f..6fe7d84d2 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -132,6 +132,10 @@ "label": "Лимит параллельных сессий", "placeholder": "0 — без ограничений" }, + "limitConcurrentUas": { + "label": "Лимит одновременных UA", + "placeholder": "0 — без ограничений" + }, "limitDaily": { "label": "Дневной лимит расходов (USD)", "placeholder": "Пусто — без ограничений" @@ -150,6 +154,7 @@ }, "summary": { "concurrent": "Параллельно: {count}", + "concurrentUas": "UA: {count}", "daily": "день: {amount} (сброс {resetTime})", "fiveHour": "5ч: ${amount}", "monthly": "Месяц: ${amount}", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 6743b6857..a58904179 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -911,9 +911,15 @@ }, "limitConcurrentSessions": { "label": "并发 Session 上限", - "placeholder": "0 表示无限制", - "description": "同时运行的对话数量", - "descriptionWithUserLimit": "同时运行的对话数量(用户限额: {limit})" + "placeholder": "0 表示继承用户限额", + "description": "同时运行的对话数量(0 表示继承用户限额)", + "descriptionWithUserLimit": "同时运行的对话数量(0 表示继承用户限额;用户限额: {limit})" + }, + "limitConcurrentUas": { + "label": "并发 UA 上限", + "placeholder": "0 表示继承用户限额", + "description": "同时活跃的 UA(User-Agent)数量(0 表示继承用户限额)", + "descriptionWithUserLimit": "同时活跃的 UA(User-Agent)数量(0 表示继承用户限额;用户限额: {limit})" }, "providerGroup": { "label": "供应商分组", @@ -1015,6 +1021,11 @@ "placeholder": "0 表示无限制", "description": "同时运行的对话数量" }, + "limitConcurrentUas": { + "label": "并发 UA 上限", + "placeholder": "0 表示无限制", + "description": "同时活跃的 UA(User-Agent)数量" + }, "isEnabled": { "label": "启用用户", "description": "禁用后用户将无法使用 API" @@ -1634,7 +1645,8 @@ "limitWeekly": "周限额", "limitMonthly": "月限额", "limitTotal": "总限额", - "limitSessions": "并发 Session" + "limitSessions": "并发 Session", + "limitUas": "并发 UA" }, "dailyMode": { "fixed": "固定时间重置", diff --git a/messages/zh-CN/errors.json b/messages/zh-CN/errors.json index de1573a05..9f3d1845b 100644 --- a/messages/zh-CN/errors.json +++ b/messages/zh-CN/errors.json @@ -55,6 +55,7 @@ "RATE_LIMIT_MONTHLY_EXCEEDED": "月消费超限:当前 ${current} USD(限制:${limit} USD)。将于 {resetTime} 重置", "RATE_LIMIT_TOTAL_EXCEEDED": "总消费上限已达到:${current} / ${limit} USD", "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED": "并发 Session 超限:当前 {current} 个(限制:{limit} 个)。请等待活跃 Session 完成", + "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED": "并发 UA 超限:当前 {current} 个(限制:{limit} 个)。请等待活跃 UA 释放", "RESOURCE_BUSY": "资源正在使用中", "INVALID_STATE": "当前状态不允许此操作", "CONFLICT": "操作冲突", @@ -81,6 +82,7 @@ "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Key的月消费上限({keyLimit})不能超过用户限额({userLimit})", "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Key的总消费上限({keyLimit})不能超过用户限额({userLimit})", "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Key的并发Session上限({keyLimit})不能超过用户限额({userLimit})", + "KEY_LIMIT_CONCURRENT_UAS_EXCEEDS_USER_LIMIT": "Key的并发UA上限({keyLimit})不能超过用户限额({userLimit})", "NO_DEFAULT_GROUP_PERMISSION": "无权使用 default 分组,您当前没有 default 分组的 Key", "NO_GROUP_PERMISSION": "无权使用以下分组: {groups}" } diff --git a/messages/zh-CN/quota.json b/messages/zh-CN/quota.json index b8b16dbc3..718450201 100644 --- a/messages/zh-CN/quota.json +++ b/messages/zh-CN/quota.json @@ -103,6 +103,7 @@ "limitMonthly": "月限额", "limitTotal": "总限额", "limitConcurrent": "并发 Session", + "limitConcurrentUas": "并发 UA", "role": { "admin": "管理员", "user": "用户" @@ -336,6 +337,12 @@ "description": "同时运行的对话数量", "descriptionWithUserLimit": "同时运行的对话数量(用户限额: {limit})" }, + "limitConcurrentUas": { + "label": "并发 UA 上限", + "placeholder": "0 表示无限制", + "description": "同时活跃的 UA(User-Agent)数量", + "descriptionWithUserLimit": "同时活跃的 UA(User-Agent)数量(用户限额: {limit})" + }, "providerGroup": { "label": "供应商分组", "placeholder": "输入供应商分组标签,按回车添加", diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index 05a815026..c35c931b4 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -249,6 +249,7 @@ "monthly": "月: {amount}", "total": "总: {amount}", "concurrent": "并发: {count}", + "concurrentUas": "并发 UA: {count}", "none": "无限制" }, "limit5h": { @@ -288,6 +289,10 @@ "limitConcurrent": { "label": "并发 Session 上限", "placeholder": "0 表示无限制" + }, + "limitConcurrentUas": { + "label": "并发 UA 上限", + "placeholder": "0 表示无限制" } }, "limits": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index e28e8eb07..9e647d6da 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -901,9 +901,15 @@ }, "limitConcurrentSessions": { "label": "並發 Session 上限", - "placeholder": "0 表示無限制", - "description": "同時執行的對話數量", - "descriptionWithUserLimit": "最大 Session 數(使用者上限:{limit})" + "placeholder": "0 表示繼承使用者上限", + "description": "同時執行的對話數量(0 表示繼承使用者上限)", + "descriptionWithUserLimit": "同時執行的對話數量(0 表示繼承使用者上限;使用者上限:{limit})" + }, + "limitConcurrentUas": { + "label": "並發 UA 上限", + "placeholder": "0 表示繼承使用者上限", + "description": "同時活躍的 UA(User-Agent)數量(0 表示繼承使用者上限)", + "descriptionWithUserLimit": "同時活躍的 UA(User-Agent)數量(0 表示繼承使用者上限;使用者上限:{limit})" }, "providerGroup": { "label": "供應商分組", @@ -1000,6 +1006,11 @@ "placeholder": "0 表示無限制", "description": "同時執行的對話數量" }, + "limitConcurrentUas": { + "label": "並發 UA 上限", + "placeholder": "0 表示無限制", + "description": "同時活躍的 UA(User-Agent)數量" + }, "isEnabled": { "label": "啟用使用者", "description": "停用後使用者將無法使用 API" @@ -1619,7 +1630,8 @@ "limitWeekly": "週限額", "limitMonthly": "月限額", "limitTotal": "總限額", - "limitSessions": "並發 Session" + "limitSessions": "並發 Session", + "limitUas": "並發 UA" }, "dailyMode": { "fixed": "固定時間重設", diff --git a/messages/zh-TW/errors.json b/messages/zh-TW/errors.json index 845119ceb..8e908a821 100644 --- a/messages/zh-TW/errors.json +++ b/messages/zh-TW/errors.json @@ -55,6 +55,7 @@ "RATE_LIMIT_MONTHLY_EXCEEDED": "月消費超限:當前 ${current} USD(限制:${limit} USD)。將於 {resetTime} 重置", "RATE_LIMIT_TOTAL_EXCEEDED": "總消費上限已達到:${current} / ${limit} USD", "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED": "並發 Session 超限:當前 {current} 個(限制:{limit} 個)。請等待活躍 Session 完成", + "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED": "並發 UA 超限:當前 {current} 個(限制:{limit} 個)。請等待活躍 UA 釋放", "RESOURCE_BUSY": "資源正在使用中", "INVALID_STATE": "當前狀態不允許此操作", "CONFLICT": "操作衝突", @@ -77,6 +78,7 @@ "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Key 的每月消費上限({keyLimit})不能超過使用者限額({userLimit})", "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Key 的總消費上限({keyLimit})不能超過使用者限額({userLimit})", "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Key 的並發 Session 上限({keyLimit})不能超過使用者限額({userLimit})", + "KEY_LIMIT_CONCURRENT_UAS_EXCEEDS_USER_LIMIT": "Key 的並發 UA 上限({keyLimit})不能超過使用者限額({userLimit})", "EXPIRES_AT_FIELD": "過期時間", "EXPIRES_AT_MUST_BE_FUTURE": "過期時間必須是未來時間", "EXPIRES_AT_TOO_FAR": "過期時間不能超過10年", diff --git a/messages/zh-TW/quota.json b/messages/zh-TW/quota.json index 8d2eb86c9..3ac325a70 100644 --- a/messages/zh-TW/quota.json +++ b/messages/zh-TW/quota.json @@ -85,7 +85,8 @@ "warning": "接近限額 (>60%)", "exceeded": "已超限 (≥100%)" }, - "expiresAtLabel": "過期時間" + "expiresAtLabel": "過期時間", + "limitConcurrentUas": "並發 UA" }, "providers": { "title": "供應商限額統計", @@ -311,6 +312,12 @@ "description": "同時運行的對話數量", "descriptionWithUserLimit": "不能超過使用者並發限額 ({limit})" }, + "limitConcurrentUas": { + "label": "並發 UA 上限", + "placeholder": "0 表示無限制", + "description": "同時活躍的 UA(User-Agent)數量", + "descriptionWithUserLimit": "不能超過使用者並發 UA 限額 ({limit})" + }, "providerGroup": { "label": "供應商分組", "placeholder": "輸入供應商分組標籤,按 Enter 新增", diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 49fc6b4f1..c3ff2e9fb 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -132,6 +132,10 @@ "label": "並發 Session 上限", "placeholder": "0 表示無限制" }, + "limitConcurrentUas": { + "label": "並發 UA 上限", + "placeholder": "0 表示無限制" + }, "limitDaily": { "label": "每日消費上限(USD)", "placeholder": "留空表示無限制" @@ -150,6 +154,7 @@ }, "summary": { "concurrent": "並發:{count}", + "concurrentUas": "並發 UA:{count}", "daily": "日: {amount}(重置 {resetTime})", "fiveHour": "5h:${amount}", "monthly": "月:${amount}", diff --git a/src/actions/keys.ts b/src/actions/keys.ts index d329c138f..c61aa0be7 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -99,6 +99,7 @@ export async function addKey(data: { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; providerGroup?: string | null; cacheTtlPreference?: "inherit" | "5m" | "1h"; }): Promise> { @@ -172,6 +173,7 @@ export async function addKey(data: { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, providerGroup: providerGroupForKey, cacheTtlPreference: data.cacheTtlPreference, }); @@ -282,6 +284,22 @@ export async function addKey(data: { }; } + if ( + validatedData.limitConcurrentUas != null && + validatedData.limitConcurrentUas > 0 && + user.limitConcurrentUas != null && + user.limitConcurrentUas > 0 && + validatedData.limitConcurrentUas > user.limitConcurrentUas + ) { + return { + ok: false, + error: tError("KEY_LIMIT_CONCURRENT_UAS_EXCEEDS_USER_LIMIT", { + keyLimit: String(validatedData.limitConcurrentUas), + userLimit: String(user.limitConcurrentUas), + }), + }; + } + const generatedKey = `sk-${randomBytes(16).toString("hex")}`; // 转换 expiresAt: undefined → null(永不过期),string → Date(按系统时区解析) @@ -306,6 +324,7 @@ export async function addKey(data: { limit_monthly_usd: validatedData.limitMonthlyUsd, limit_total_usd: validatedData.limitTotalUsd, limit_concurrent_sessions: validatedData.limitConcurrentSessions, + limit_concurrent_uas: validatedData.limitConcurrentUas, provider_group: validatedData.providerGroup, cache_ttl_preference: validatedData.cacheTtlPreference, }); @@ -342,6 +361,7 @@ export async function editKey( limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; providerGroup?: string | null; cacheTtlPreference?: "inherit" | "5m" | "1h"; } @@ -502,6 +522,22 @@ export async function editKey( }; } + if ( + validatedData.limitConcurrentUas != null && + validatedData.limitConcurrentUas > 0 && + user.limitConcurrentUas != null && + user.limitConcurrentUas > 0 && + validatedData.limitConcurrentUas > user.limitConcurrentUas + ) { + return { + ok: false, + error: tError("KEY_LIMIT_CONCURRENT_UAS_EXCEEDS_USER_LIMIT", { + keyLimit: String(validatedData.limitConcurrentUas), + userLimit: String(user.limitConcurrentUas), + }), + }; + } + // 移除 providerGroup 子集校验(用户分组由 Key 分组自动计算) // 转换 expiresAt(按系统时区解析): @@ -544,6 +580,7 @@ export async function editKey( limit_monthly_usd: validatedData.limitMonthlyUsd, limit_total_usd: validatedData.limitTotalUsd, limit_concurrent_sessions: validatedData.limitConcurrentSessions, + limit_concurrent_uas: validatedData.limitConcurrentUas, // providerGroup 为 admin-only 字段:非管理员不允许更新该字段 ...(isAdmin ? { provider_group: normalizeProviderGroup(validatedData.providerGroup) } : {}), cache_ttl_preference: validatedData.cacheTtlPreference, diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 712f6928d..3bed82a10 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -289,6 +289,7 @@ export async function getProviders(): Promise { limitMonthlyUsd: provider.limitMonthlyUsd, limitTotalUsd: provider.limitTotalUsd, limitConcurrentSessions: provider.limitConcurrentSessions, + limitConcurrentUas: provider.limitConcurrentUas, maxRetryAttempts: provider.maxRetryAttempts, circuitBreakerFailureThreshold: provider.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: provider.circuitBreakerOpenDuration, @@ -495,6 +496,7 @@ export async function addProvider(data: { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; + limit_concurrent_uas?: number | null; cache_ttl_preference?: CacheTtlPreference | null; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; @@ -572,6 +574,7 @@ export async function addProvider(data: { limit_monthly_usd: validated.limit_monthly_usd ?? null, limit_total_usd: validated.limit_total_usd ?? null, limit_concurrent_sessions: validated.limit_concurrent_sessions ?? 0, + limit_concurrent_uas: validated.limit_concurrent_uas ?? 0, max_retry_attempts: validated.max_retry_attempts ?? null, circuit_breaker_failure_threshold: validated.circuit_breaker_failure_threshold ?? 5, circuit_breaker_open_duration: validated.circuit_breaker_open_duration ?? 1800000, @@ -669,6 +672,7 @@ export async function editProvider( limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; + limit_concurrent_uas?: number | null; cache_ttl_preference?: "inherit" | "5m" | "1h"; swap_cache_ttl_billing?: boolean; context_1m_preference?: Context1mPreference | null; @@ -1284,6 +1288,7 @@ const SINGLE_EDIT_PREIMAGE_FIELD_TO_PROVIDER_KEY: Record limit_monthly_usd: "limitMonthlyUsd", limit_total_usd: "limitTotalUsd", limit_concurrent_sessions: "limitConcurrentSessions", + limit_concurrent_uas: "limitConcurrentUas", cache_ttl_preference: "cacheTtlPreference", swap_cache_ttl_billing: "swapCacheTtlBilling", context_1m_preference: "context1mPreference", @@ -1500,6 +1505,9 @@ function mapApplyUpdatesToRepositoryFormat( if (applyUpdates.limit_concurrent_sessions !== undefined) { result.limitConcurrentSessions = applyUpdates.limit_concurrent_sessions; } + if (applyUpdates.limit_concurrent_uas !== undefined) { + result.limitConcurrentUas = applyUpdates.limit_concurrent_uas; + } if (applyUpdates.circuit_breaker_failure_threshold !== undefined) { result.circuitBreakerFailureThreshold = applyUpdates.circuit_breaker_failure_threshold; } @@ -1570,6 +1578,7 @@ const PATCH_FIELD_TO_PROVIDER_KEY: Record { limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + limitConcurrentUas: user.limitConcurrentUas ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, isEnabled: user.isEnabled, @@ -320,6 +321,7 @@ export async function getUsers(): Promise { limitMonthlyUsd: key.limitMonthlyUsd, limitTotalUsd: key.limitTotalUsd, limitConcurrentSessions: key.limitConcurrentSessions || 0, + limitConcurrentUas: key.limitConcurrentUas || 0, providerGroup: key.providerGroup, }; }), @@ -340,6 +342,7 @@ export async function getUsers(): Promise { limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + limitConcurrentUas: user.limitConcurrentUas ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, isEnabled: user.isEnabled, @@ -544,6 +547,7 @@ export async function getUsersBatch( limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + limitConcurrentUas: user.limitConcurrentUas ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, isEnabled: user.isEnabled, @@ -587,6 +591,7 @@ export async function getUsersBatch( limitMonthlyUsd: key.limitMonthlyUsd, limitTotalUsd: key.limitTotalUsd, limitConcurrentSessions: key.limitConcurrentSessions || 0, + limitConcurrentUas: key.limitConcurrentUas || 0, providerGroup: key.providerGroup, }; }), @@ -607,6 +612,7 @@ export async function getUsersBatch( limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + limitConcurrentUas: user.limitConcurrentUas ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, isEnabled: user.isEnabled, @@ -694,6 +700,7 @@ export async function getUsersBatchCore( limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + limitConcurrentUas: user.limitConcurrentUas ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, isEnabled: user.isEnabled, @@ -733,6 +740,7 @@ export async function getUsersBatchCore( limitMonthlyUsd: key.limitMonthlyUsd, limitTotalUsd: key.limitTotalUsd, limitConcurrentSessions: key.limitConcurrentSessions || 0, + limitConcurrentUas: key.limitConcurrentUas || 0, providerGroup: key.providerGroup, })), }; @@ -981,6 +989,7 @@ export async function addUser(data: { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; isEnabled?: boolean; @@ -1006,6 +1015,7 @@ export async function addUser(data: { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number | null; + limitConcurrentUas: number | null; allowedModels: string[]; }; defaultKey: { @@ -1042,6 +1052,7 @@ export async function addUser(data: { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, dailyResetMode: data.dailyResetMode, dailyResetTime: data.dailyResetTime, isEnabled: data.isEnabled, @@ -1102,6 +1113,7 @@ export async function addUser(data: { limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined, limitTotalUsd: validatedData.limitTotalUsd ?? undefined, limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined, + limitConcurrentUas: validatedData.limitConcurrentUas ?? undefined, dailyResetMode: validatedData.dailyResetMode, dailyResetTime: validatedData.dailyResetTime, isEnabled: validatedData.isEnabled, @@ -1142,6 +1154,7 @@ export async function addUser(data: { limitMonthlyUsd: newUser.limitMonthlyUsd ?? null, limitTotalUsd: newUser.limitTotalUsd ?? null, limitConcurrentSessions: newUser.limitConcurrentSessions ?? null, + limitConcurrentUas: newUser.limitConcurrentUas ?? null, allowedModels: newUser.allowedModels ?? [], }, defaultKey: { @@ -1176,6 +1189,7 @@ export async function createUserOnly(data: { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; isEnabled?: boolean; @@ -1201,6 +1215,7 @@ export async function createUserOnly(data: { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number | null; + limitConcurrentUas: number | null; }; }> > { @@ -1230,6 +1245,7 @@ export async function createUserOnly(data: { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, dailyResetMode: data.dailyResetMode, dailyResetTime: data.dailyResetTime, isEnabled: data.isEnabled, @@ -1289,6 +1305,7 @@ export async function createUserOnly(data: { limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined, limitTotalUsd: validatedData.limitTotalUsd ?? undefined, limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined, + limitConcurrentUas: validatedData.limitConcurrentUas ?? undefined, dailyResetMode: validatedData.dailyResetMode, dailyResetTime: validatedData.dailyResetTime, isEnabled: validatedData.isEnabled, @@ -1318,6 +1335,7 @@ export async function createUserOnly(data: { limitMonthlyUsd: newUser.limitMonthlyUsd ?? null, limitTotalUsd: newUser.limitTotalUsd ?? null, limitConcurrentSessions: newUser.limitConcurrentSessions ?? null, + limitConcurrentUas: newUser.limitConcurrentUas ?? null, }, }, }; @@ -1348,6 +1366,7 @@ export async function editUser( limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; isEnabled?: boolean; @@ -1449,6 +1468,7 @@ export async function editUser( limitMonthlyUsd: validatedData.limitMonthlyUsd, limitTotalUsd: validatedData.limitTotalUsd, limitConcurrentSessions: validatedData.limitConcurrentSessions, + limitConcurrentUas: validatedData.limitConcurrentUas, dailyResetMode: validatedData.dailyResetMode, dailyResetTime: validatedData.dailyResetTime, isEnabled: validatedData.isEnabled, diff --git a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx index 93df8eb89..c1d3b1e5d 100644 --- a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx @@ -87,6 +87,7 @@ function buildDefaultValues(): CreateFormValues { limitMonthlyUsd: null, limitTotalUsd: null, limitConcurrentSessions: null, + limitConcurrentUas: null, dailyResetMode: "fixed", dailyResetTime: "00:00", allowedClients: [], @@ -109,6 +110,7 @@ function buildDefaultValues(): CreateFormValues { limitMonthlyUsd: null, limitTotalUsd: null, limitConcurrentSessions: 0, + limitConcurrentUas: 0, }, }; } @@ -154,6 +156,7 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp limitMonthlyUsd: data.user.limitMonthlyUsd, limitTotalUsd: data.user.limitTotalUsd, limitConcurrentSessions: data.user.limitConcurrentSessions, + limitConcurrentUas: data.user.limitConcurrentUas, dailyResetMode: data.user.dailyResetMode, dailyResetTime: data.user.dailyResetTime, allowedClients: data.user.allowedClients, @@ -184,6 +187,7 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp limitMonthlyUsd: data.key.limitMonthlyUsd, limitTotalUsd: data.key.limitTotalUsd, limitConcurrentSessions: data.key.limitConcurrentSessions, + limitConcurrentUas: data.key.limitConcurrentUas, }); if (!keyRes.ok) { @@ -363,6 +367,7 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp limitMonthlyUsd: currentUserDraft.limitMonthlyUsd ?? null, limitTotalUsd: currentUserDraft.limitTotalUsd ?? null, limitConcurrentSessions: currentUserDraft.limitConcurrentSessions ?? null, + limitConcurrentUas: currentUserDraft.limitConcurrentUas ?? null, dailyResetMode: currentUserDraft.dailyResetMode ?? "fixed", dailyResetTime: currentUserDraft.dailyResetTime ?? "00:00", allowedClients: currentUserDraft.allowedClients || [], @@ -395,6 +400,7 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp limitMonthlyUsd: currentKeyDraft.limitMonthlyUsd ?? null, limitTotalUsd: currentKeyDraft.limitTotalUsd ?? null, limitConcurrentSessions: currentKeyDraft.limitConcurrentSessions ?? 0, + limitConcurrentUas: currentKeyDraft.limitConcurrentUas ?? 0, }} isAdmin={true} showLimitRules={false} diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx index 0a9d6827d..fdcd6089c 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx @@ -29,6 +29,7 @@ export interface EditKeyDialogProps { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; }; user?: KeyDialogUserContext; isAdmin?: boolean; diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 5a0fcf9bb..3c9955fa4 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -71,6 +71,7 @@ function buildDefaultValues(user: UserDisplay): EditUserValues { limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + limitConcurrentUas: user.limitConcurrentUas ?? null, dailyResetMode: user.dailyResetMode ?? "fixed", dailyResetTime: user.dailyResetTime ?? "00:00", allowedClients: user.allowedClients || [], @@ -112,6 +113,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, dailyResetMode: data.dailyResetMode, dailyResetTime: data.dailyResetTime, allowedClients: data.allowedClients, @@ -270,6 +272,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr limitMonthlyUsd: currentUserDraft.limitMonthlyUsd ?? null, limitTotalUsd: currentUserDraft.limitTotalUsd ?? null, limitConcurrentSessions: currentUserDraft.limitConcurrentSessions ?? null, + limitConcurrentUas: currentUserDraft.limitConcurrentUas ?? null, dailyResetMode: currentUserDraft.dailyResetMode ?? "fixed", dailyResetTime: currentUserDraft.dailyResetTime ?? "00:00", allowedClients: currentUserDraft.allowedClients || [], diff --git a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx index f0f3bd00a..80cca1a28 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx @@ -67,6 +67,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF limitMonthlyUsd: null, limitTotalUsd: null, limitConcurrentSessions: 0, + limitConcurrentUas: 0, }, onSubmit: async (data) => { if (!userId) { @@ -88,6 +89,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, cacheTtlPreference: data.cacheTtlPreference, providerGroup: data.providerGroup || PROVIDER_GROUP.DEFAULT, }); @@ -364,6 +366,21 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF step={1} {...form.getFieldProps("limitConcurrentSessions")} /> + + ); diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx index ba6bb2ab7..96868c3ef 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx @@ -40,6 +40,7 @@ interface EditKeyFormProps { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; }; user?: KeyDialogUserContext; isAdmin?: boolean; @@ -98,6 +99,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK limitMonthlyUsd: keyData?.limitMonthlyUsd ?? null, limitTotalUsd: keyData?.limitTotalUsd ?? null, limitConcurrentSessions: keyData?.limitConcurrentSessions ?? 0, + limitConcurrentUas: keyData?.limitConcurrentUas ?? 0, }, onSubmit: async (data) => { if (!keyData) { @@ -120,6 +122,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, ...(isAdmin ? { providerGroup: data.providerGroup || PROVIDER_GROUP.DEFAULT } : {}), }); if (!res.ok) { @@ -385,6 +388,21 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK step={1} {...form.getFieldProps("limitConcurrentSessions")} /> + + ); diff --git a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx index 5da461294..3a6003682 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx @@ -42,6 +42,7 @@ export interface KeyEditSectionProps { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; }; /** Admin 可自由编辑 providerGroup */ isAdmin?: boolean; @@ -188,6 +189,10 @@ export function KeyEditSection({ rules.push({ type: "limitSessions", value: keyData.limitConcurrentSessions }); } + if (typeof keyData.limitConcurrentUas === "number" && keyData.limitConcurrentUas > 0) { + rules.push({ type: "limitUas", value: keyData.limitConcurrentUas }); + } + return rules; }, [ keyData.limit5hUsd, @@ -198,6 +203,7 @@ export function KeyEditSection({ keyData.limitMonthlyUsd, keyData.limitTotalUsd, keyData.limitConcurrentSessions, + keyData.limitConcurrentUas, ]); const existingLimitTypes = useMemo(() => limitRules.map((r) => r.type), [limitRules]); @@ -227,6 +233,9 @@ export function KeyEditSection({ case "limitSessions": onChange("limitConcurrentSessions", 0); return; + case "limitUas": + onChange("limitConcurrentUas", 0); + return; default: return; } @@ -272,6 +281,9 @@ export function KeyEditSection({ case "limitSessions": onChange("limitConcurrentSessions", Math.max(0, Math.floor(value))); return; + case "limitUas": + onChange("limitConcurrentUas", Math.max(0, Math.floor(value))); + return; default: return; } diff --git a/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx b/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx index 26951f50f..ace3bcb5b 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx @@ -29,7 +29,8 @@ export type LimitType = | "limitWeekly" | "limitMonthly" | "limitTotal" - | "limitSessions"; + | "limitSessions" + | "limitUas"; export type DailyResetMode = "fixed" | "rolling"; @@ -47,21 +48,22 @@ export interface LimitRulePickerProps { * - fields.value.label, fields.value.placeholder * - daily.mode.label, daily.mode.fixed, daily.mode.rolling * - daily.time.label, daily.time.placeholder - * - limitTypes.{limit5h|limitDaily|limitWeekly|limitMonthly|limitTotal|limitSessions} + * - limitTypes.{limitRpm|limit5h|limitDaily|limitWeekly|limitMonthly|limitTotal|limitSessions|limitUas} * - errors.missingType, errors.invalidValue, errors.invalidTime * - overwriteHint */ translations: Record; } -const LIMIT_TYPE_OPTIONS: Array<{ type: LimitType; fallbackLabel: string }> = [ - { type: "limitRpm", fallbackLabel: "RPM 限额" }, - { type: "limit5h", fallbackLabel: "5小时限额" }, - { type: "limitDaily", fallbackLabel: "每日限额" }, - { type: "limitWeekly", fallbackLabel: "周限额" }, - { type: "limitMonthly", fallbackLabel: "月限额" }, - { type: "limitTotal", fallbackLabel: "总限额" }, - { type: "limitSessions", fallbackLabel: "并发 Session" }, +const LIMIT_TYPE_OPTIONS: LimitType[] = [ + "limitRpm", + "limit5h", + "limitDaily", + "limitWeekly", + "limitMonthly", + "limitTotal", + "limitSessions", + "limitUas", ]; const QUICK_VALUES = [10, 50, 100, 500] as const; @@ -103,13 +105,13 @@ export function LimitRulePicker({ // Reset state when dialog opens useEffect(() => { if (!open) return; - const first = availableTypes[0]?.type ?? ""; + const first = availableTypes[0] ?? ""; setType((prev) => (prev ? prev : first)); setRawValue(""); setDailyMode("fixed"); setDailyTime("00:00"); setError(null); - }, [open, availableTypes]); + }, [open]); const numericValue = useMemo(() => { const trimmed = rawValue.trim(); @@ -117,6 +119,7 @@ export function LimitRulePicker({ return Number(trimmed); }, [rawValue]); + const requiresInteger = type === "limitSessions" || type === "limitUas" || type === "limitRpm"; const isDaily = type === "limitDaily"; const needsTime = isDaily && dailyMode === "fixed"; @@ -124,6 +127,7 @@ export function LimitRulePicker({ type !== "" && Number.isFinite(numericValue) && numericValue >= 0 && + (!requiresInteger || Number.isInteger(numericValue)) && (!needsTime || isValidTime(dailyTime)); const handleCancel = () => onOpenChange(false); @@ -141,6 +145,11 @@ export function LimitRulePicker({ return; } + if (requiresInteger && !Number.isInteger(numericValue)) { + setError(getTranslation(translations, "errors.invalidValue", "请输入有效数值")); + return; + } + if (needsTime && !isValidTime(dailyTime)) { setError(getTranslation(translations, "errors.invalidTime", "请输入有效时间 (HH:mm)")); return; @@ -154,6 +163,8 @@ export function LimitRulePicker({ onOpenChange(false); }; + const typePlaceholder = getTranslation(translations, "fields.type.placeholder", "请选择"); + return ( @@ -177,14 +188,12 @@ export function LimitRulePicker({
- {(type === "limitSessions" + {(type === "limitSessions" || type === "limitUas" ? SESSION_QUICK_VALUES : type === "limitRpm" ? RPM_QUICK_VALUES @@ -233,7 +244,7 @@ export function LimitRulePicker({ > {v === 0 ? getTranslation(translations, "quickValues.unlimited", "无限") - : type === "limitSessions" || type === "limitRpm" + : type === "limitSessions" || type === "limitUas" || type === "limitRpm" ? v : `$${v}`} diff --git a/src/app/[locale]/dashboard/_components/user/forms/limit-rules-display.tsx b/src/app/[locale]/dashboard/_components/user/forms/limit-rules-display.tsx index 8a414a946..566e80edd 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/limit-rules-display.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/limit-rules-display.tsx @@ -19,7 +19,7 @@ export interface LimitRulesDisplayProps { /** * i18n strings passed from parent. * Expected keys (optional): - * - limitTypes.{limit5h|limitDaily|limitWeekly|limitMonthly|limitTotal|limitSessions} + * - limitTypes.{limit5h|limitDaily|limitWeekly|limitMonthly|limitTotal|limitSessions|limitUas} * - daily.mode.fixed, daily.mode.rolling * - actions.remove */ diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx index 20f10ba68..424805f56 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx @@ -41,6 +41,7 @@ export interface UserEditSectionProps { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; // 访问限制字段 @@ -217,6 +218,7 @@ export function UserEditSection({ add("limitMonthly", user.limitMonthlyUsd); add("limitTotal", user.limitTotalUsd); add("limitSessions", user.limitConcurrentSessions); + add("limitUas", user.limitConcurrentUas); return items; }, [ @@ -229,6 +231,7 @@ export function UserEditSection({ user.limitMonthlyUsd, user.limitTotalUsd, user.limitConcurrentSessions, + user.limitConcurrentUas, ]); const existingTypes = useMemo(() => { @@ -276,6 +279,9 @@ export function UserEditSection({ case "limitSessions": emitChange("limitConcurrentSessions", null); return; + case "limitUas": + emitChange("limitConcurrentUas", null); + return; default: return; } @@ -309,6 +315,9 @@ export function UserEditSection({ case "limitSessions": emitChange("limitConcurrentSessions", value); return; + case "limitUas": + emitChange("limitConcurrentUas", value); + return; default: return; } diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index d94c426d8..89a4011bc 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -39,6 +39,7 @@ interface UserFormProps { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; isEnabled?: boolean; expiresAt?: Date | null; allowedClients?: string[]; @@ -92,6 +93,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitMonthlyUsd: user?.limitMonthlyUsd ?? null, limitTotalUsd: user?.limitTotalUsd ?? null, limitConcurrentSessions: user?.limitConcurrentSessions ?? null, + limitConcurrentUas: user?.limitConcurrentUas ?? null, isEnabled: user?.isEnabled ?? true, expiresAt: user?.expiresAt ? formatDateToLocalYmd(user.expiresAt) : "", allowedClients: user?.allowedClients || [], @@ -121,6 +123,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, isEnabled: data.isEnabled, expiresAt, allowedClients: data.allowedClients, @@ -140,6 +143,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + limitConcurrentUas: data.limitConcurrentUas, isEnabled: data.isEnabled, expiresAt, allowedClients: data.allowedClients, @@ -327,6 +331,16 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { placeholder={tForm("limitConcurrentSessions.placeholder")} {...form.getFieldProps("limitConcurrentSessions")} /> + + )} diff --git a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx index f40ffce9e..86cd94cd5 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx @@ -341,6 +341,7 @@ export function KeyListHeader({ limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined, limitTotalUsd: activeUser.limitTotalUsd ?? undefined, limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined, + limitConcurrentUas: activeUser.limitConcurrentUas ?? undefined, } : undefined } diff --git a/src/app/[locale]/dashboard/_components/user/key-list.tsx b/src/app/[locale]/dashboard/_components/user/key-list.tsx index 089acb827..2b773efdb 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list.tsx @@ -92,7 +92,8 @@ export function KeyList({ (record.limitDailyUsd && record.limitDailyUsd > 0) || (record.limitWeeklyUsd && record.limitWeeklyUsd > 0) || (record.limitMonthlyUsd && record.limitMonthlyUsd > 0) || - (record.limitConcurrentSessions && record.limitConcurrentSessions > 0) + (record.limitConcurrentSessions && record.limitConcurrentSessions > 0) || + (record.limitConcurrentUas && record.limitConcurrentUas > 0) ); const hasModelStats = record.modelStats.length > 0; diff --git a/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx b/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx index 742346465..bb74c761f 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx @@ -56,6 +56,7 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined, limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined, limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined, + limitConcurrentUas: activeUser.limitConcurrentUas ?? undefined, dailyResetMode: activeUser.dailyResetMode ?? "fixed", dailyResetTime: activeUser.dailyResetTime ?? "00:00", isEnabled: activeUser.isEnabled, @@ -110,6 +111,7 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined, limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined, limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined, + limitConcurrentUas: activeUser.limitConcurrentUas ?? undefined, dailyResetMode: activeUser.dailyResetMode ?? "fixed", dailyResetTime: activeUser.dailyResetTime ?? "00:00", isEnabled: activeUser.isEnabled, diff --git a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx index 68f92da07..740c51f3c 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx @@ -617,6 +617,7 @@ export function UserKeyTableRow({ limitMonthlyUsd: editingKey.limitMonthlyUsd, limitTotalUsd: editingKey.limitTotalUsd, limitConcurrentSessions: editingKey.limitConcurrentSessions, + limitConcurrentUas: editingKey.limitConcurrentUas, }} user={{ id: user.id, @@ -626,6 +627,7 @@ export function UserKeyTableRow({ limitMonthlyUsd: user.limitMonthlyUsd ?? undefined, limitTotalUsd: user.limitTotalUsd ?? undefined, limitConcurrentSessions: user.limitConcurrentSessions ?? undefined, + limitConcurrentUas: user.limitConcurrentUas ?? undefined, }} isAdmin={isAdmin} onSuccess={() => { diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index 0a27ca05b..5076d53ca 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -21,6 +21,7 @@ import { import { useTranslations } from "next-intl"; import { useState } from "react"; import { getSessionOriginChain } from "@/actions/session-origin-chain"; +import { resolveChainItemErrorMessage } from "@/app/[locale]/dashboard/logs/_components/resolve-chain-item-error-message"; import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; @@ -62,6 +63,7 @@ export function LogicTraceTab({ }: LogicTraceTabProps) { const t = useTranslations("dashboard.logs.details"); const tChain = useTranslations("provider-chain"); + const tErrors = useTranslations("errors"); const [timelineCopied, setTimelineCopied] = useState(false); const [originOpen, setOriginOpen] = useState(false); const [originChain, setOriginChain] = useState(undefined); @@ -708,6 +710,7 @@ export function LogicTraceTab({ const isRetry = item.attemptNumber && item.attemptNumber > 1; const isSessionReuse = item.reason === "session_reuse" || item.selectionMethod === "session_reuse"; + const resolvedErrorMessage = resolveChainItemErrorMessage(item, tErrors); // Determine icon based on type const stepIcon = isSessionReuse @@ -876,14 +879,14 @@ export function LogicTraceTab({ )} {/* Error Message */} - {item.errorMessage && ( + {resolvedErrorMessage && (
{tChain("details.error")}
-                          {item.errorMessage}
+                          {resolvedErrorMessage}
                         
)} diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index 4f27ca0ae..00c95b1e8 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -20,6 +20,7 @@ import { cn } from "@/lib/utils"; import { formatProbabilityCompact } from "@/lib/utils/provider-chain-formatter"; import type { ProviderChainItem } from "@/types/message"; import { getFake200ReasonKey } from "./fake200-reason"; +import { resolveChainItemErrorMessage } from "./resolve-chain-item-error-message"; interface ProviderChainPopoverProps { chain: ProviderChainItem[]; @@ -126,6 +127,7 @@ export function ProviderChainPopover({ }: ProviderChainPopoverProps) { const t = useTranslations("dashboard"); const tChain = useTranslations("provider-chain"); + const tErrors = useTranslations("errors"); // “假 200”识别发生在 SSE 流式结束后:此时响应内容可能已透传给客户端,但内部会按失败统计/熔断。 const hasFake200PostStreamFailure = chain.some( @@ -459,6 +461,7 @@ export function ProviderChainPopover({ const status = getItemStatus(item); const Icon = status.icon; const isLast = index === actualRequests.length - 1; + const resolvedErrorMessage = resolveChainItemErrorMessage(item, tErrors); return (
)}
- {item.errorMessage && ( + {resolvedErrorMessage && ( <>

- {item.errorMessage} + {resolvedErrorMessage}

{typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_") && ( diff --git a/src/app/[locale]/dashboard/logs/_components/resolve-chain-item-error-message.ts b/src/app/[locale]/dashboard/logs/_components/resolve-chain-item-error-message.ts new file mode 100644 index 000000000..621edd821 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/resolve-chain-item-error-message.ts @@ -0,0 +1,21 @@ +import type { ProviderChainItem } from "@/types/message"; + +export function resolveChainItemErrorMessage( + item: ProviderChainItem, + tErrors: (key: string, params?: Record) => string +): string | null { + if (typeof item.errorMessage === "string" && item.errorMessage.trim()) { + return item.errorMessage; + } + + const errorCode = typeof item.errorCode === "string" ? item.errorCode.trim() : ""; + if (!errorCode) { + return null; + } + + try { + return tErrors(errorCode, item.errorParams ?? undefined); + } catch { + return errorCode; + } +} diff --git a/src/app/[locale]/dashboard/quotas/users/_components/types.ts b/src/app/[locale]/dashboard/quotas/users/_components/types.ts index f2d4824d4..15b5a314a 100644 --- a/src/app/[locale]/dashboard/quotas/users/_components/types.ts +++ b/src/app/[locale]/dashboard/quotas/users/_components/types.ts @@ -17,6 +17,7 @@ export interface UserKeyWithUsage { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number; + limitConcurrentUas: number; dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; } @@ -36,6 +37,7 @@ export interface UserQuotaWithUsage { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number | null; + limitConcurrentUas: number | null; totalUsage: number; keys: UserKeyWithUsage[]; } diff --git a/src/app/[locale]/dashboard/quotas/users/_components/user-quota-list-item.tsx b/src/app/[locale]/dashboard/quotas/users/_components/user-quota-list-item.tsx index 6daa899dd..ec5facc8a 100644 --- a/src/app/[locale]/dashboard/quotas/users/_components/user-quota-list-item.tsx +++ b/src/app/[locale]/dashboard/quotas/users/_components/user-quota-list-item.tsx @@ -242,6 +242,14 @@ export function UserQuotaListItem({ user, currencyCode = "USD" }: UserQuotaListI : t("noLimitSet")}

+
+

{t("limitConcurrentUas")}

+

+ {user.limitConcurrentUas && user.limitConcurrentUas > 0 + ? user.limitConcurrentUas + : t("noLimitSet")} +

+
{/* Keys preview + full list */} diff --git a/src/app/[locale]/dashboard/quotas/users/_components/users-quota-client.tsx b/src/app/[locale]/dashboard/quotas/users/_components/users-quota-client.tsx index d47a0aab0..0b80add9f 100644 --- a/src/app/[locale]/dashboard/quotas/users/_components/users-quota-client.tsx +++ b/src/app/[locale]/dashboard/quotas/users/_components/users-quota-client.tsx @@ -35,6 +35,7 @@ function hasQuota(user: UserQuotaWithUsage): boolean { user.limitMonthlyUsd ?? 0, user.limitTotalUsd ?? 0, user.limitConcurrentSessions ?? 0, + user.limitConcurrentUas ?? 0, ]; return limits.some((limit) => (limit ?? 0) > 0); } diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx index 5a85248b1..46ac5ca49 100644 --- a/src/app/[locale]/dashboard/quotas/users/page.tsx +++ b/src/app/[locale]/dashboard/quotas/users/page.tsx @@ -43,6 +43,7 @@ async function getUsersWithQuotas(): Promise { limitMonthlyUsd: key.limitMonthlyUsd, limitTotalUsd: key.limitTotalUsd ?? null, limitConcurrentSessions: key.limitConcurrentSessions, + limitConcurrentUas: key.limitConcurrentUas, dailyResetMode: key.dailyResetMode, dailyResetTime: key.dailyResetTime, })); @@ -62,6 +63,7 @@ async function getUsersWithQuotas(): Promise { limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + limitConcurrentUas: user.limitConcurrentUas ?? null, totalUsage: userCostMap.get(user.id) ?? 0, keys: keysWithUsage, }; diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index e2bfc0a11..fe064f37c 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -806,6 +806,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { limitMonthlyUsd: selfUser.limitMonthlyUsd ?? undefined, limitTotalUsd: selfUser.limitTotalUsd ?? undefined, limitConcurrentSessions: selfUser.limitConcurrentSessions ?? undefined, + limitConcurrentUas: selfUser.limitConcurrentUas ?? undefined, }} isAdmin={false} onSuccess={handleKeyCreated} @@ -826,6 +827,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { limitMonthlyUsd: addKeyUser.limitMonthlyUsd ?? undefined, limitTotalUsd: addKeyUser.limitTotalUsd ?? undefined, limitConcurrentSessions: addKeyUser.limitConcurrentSessions ?? undefined, + limitConcurrentUas: addKeyUser.limitConcurrentUas ?? undefined, }} isAdmin={isAdmin} onSuccess={handleKeyCreated} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts index 967f726ac..99616bde7 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts +++ b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts @@ -231,6 +231,13 @@ export function buildPatchDraftFromFormState( draft.limit_concurrent_sessions = { set: state.rateLimit.limitConcurrentSessions }; } } + if (dirtyFields.has("rateLimit.limitConcurrentUas")) { + if (state.rateLimit.limitConcurrentUas === null) { + draft.limit_concurrent_uas = { set: 0 }; + } else { + draft.limit_concurrent_uas = { set: state.rateLimit.limitConcurrentUas }; + } + } // Circuit breaker fields (minutes -> ms conversion for open duration) if (dirtyFields.has("circuitBreaker.failureThreshold")) { diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx index da36c3773..a5dc7062f 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx @@ -207,6 +207,9 @@ export function ProviderForm({ const [limitConcurrentSessions, setLimitConcurrentSessions] = useState( sourceProvider?.limitConcurrentSessions ?? null ); + const [limitConcurrentUas, setLimitConcurrentUas] = useState( + sourceProvider?.limitConcurrentUas ?? null + ); const [allowedModels, setAllowedModels] = useState(sourceProvider?.allowedModels ?? []); const [cacheTtlPreference, setCacheTtlPreference] = useState<"inherit" | "5m" | "1h">( sourceProvider?.cacheTtlPreference ?? "inherit" @@ -491,6 +494,7 @@ export function ProviderForm({ limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; + limit_concurrent_uas?: number | null; cache_ttl_preference?: "inherit" | "5m" | "1h"; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; @@ -533,6 +537,7 @@ export function ProviderForm({ limit_monthly_usd: limitMonthlyUsd, limit_total_usd: limitTotalUsd, limit_concurrent_sessions: limitConcurrentSessions ?? 0, + limit_concurrent_uas: limitConcurrentUas ?? 0, cache_ttl_preference: cacheTtlPreference, context_1m_preference: context1mPreference, codex_reasoning_effort_preference: codexReasoningEffortPreference, @@ -596,6 +601,7 @@ export function ProviderForm({ limit_monthly_usd: limitMonthlyUsd, limit_total_usd: limitTotalUsd, limit_concurrent_sessions: limitConcurrentSessions ?? 0, + limit_concurrent_uas: limitConcurrentUas ?? 0, cache_ttl_preference: cacheTtlPreference, context_1m_preference: context1mPreference, codex_reasoning_effort_preference: codexReasoningEffortPreference, @@ -657,6 +663,7 @@ export function ProviderForm({ setLimitMonthlyUsd(null); setLimitTotalUsd(null); setLimitConcurrentSessions(null); + setLimitConcurrentUas(null); setMaxRetryAttempts(null); setFailureThreshold(5); setOpenDurationMinutes(30); @@ -1349,6 +1356,12 @@ export function ProviderForm({ count: limitConcurrentSessions, }) ); + if (limitConcurrentUas) + limits.push( + t("sections.rateLimit.summary.concurrentUas", { + count: limitConcurrentUas, + }) + ); return limits.length > 0 ? limits.join(", ") : t("sections.rateLimit.summary.none"); @@ -1511,6 +1524,21 @@ export function ProviderForm({ step="1" /> +
+ + setLimitConcurrentUas(validateNumericField(e.target.value))} + placeholder={t("sections.rateLimit.limitConcurrentUas.placeholder")} + disabled={isPending} + min="0" + step="1" + /> +
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 4d381451c..02ac7d219 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -349,6 +349,7 @@ function ProviderFormContent({ limit_monthly_usd: state.rateLimit.limitMonthlyUsd, limit_total_usd: state.rateLimit.limitTotalUsd, limit_concurrent_sessions: state.rateLimit.limitConcurrentSessions, + limit_concurrent_uas: state.rateLimit.limitConcurrentUas, circuit_breaker_failure_threshold: state.circuitBreaker.failureThreshold, circuit_breaker_open_duration: openDurationMs, circuit_breaker_half_open_success_threshold: @@ -533,7 +534,8 @@ function ProviderFormContent({ state.rateLimit.limitWeeklyUsd || state.rateLimit.limitMonthlyUsd || state.rateLimit.limitTotalUsd || - state.rateLimit.limitConcurrentSessions + state.rateLimit.limitConcurrentSessions || + state.rateLimit.limitConcurrentUas ) { status.limits = "configured"; } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index ecc7dd60c..85fce9dbe 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -55,6 +55,7 @@ const ACTION_TO_FIELD_PATH: Partial> SET_LIMIT_MONTHLY_USD: "rateLimit.limitMonthlyUsd", SET_LIMIT_TOTAL_USD: "rateLimit.limitTotalUsd", SET_LIMIT_CONCURRENT_SESSIONS: "rateLimit.limitConcurrentSessions", + SET_LIMIT_CONCURRENT_UAS: "rateLimit.limitConcurrentUas", SET_FAILURE_THRESHOLD: "circuitBreaker.failureThreshold", SET_OPEN_DURATION_MINUTES: "circuitBreaker.openDurationMinutes", SET_HALF_OPEN_SUCCESS_THRESHOLD: "circuitBreaker.halfOpenSuccessThreshold", @@ -124,6 +125,7 @@ export function createInitialState( limitMonthlyUsd: null, limitTotalUsd: null, limitConcurrentSessions: null, + limitConcurrentUas: null, }, circuitBreaker: { failureThreshold: undefined, @@ -206,6 +208,7 @@ export function createInitialState( limitMonthlyUsd: sourceProvider?.limitMonthlyUsd ?? null, limitTotalUsd: sourceProvider?.limitTotalUsd ?? null, limitConcurrentSessions: sourceProvider?.limitConcurrentSessions ?? null, + limitConcurrentUas: sourceProvider?.limitConcurrentUas ?? null, }, circuitBreaker: { failureThreshold: sourceProvider?.circuitBreakerFailureThreshold, @@ -412,6 +415,11 @@ export function providerFormReducer( ...state, rateLimit: { ...state.rateLimit, limitConcurrentSessions: action.payload }, }; + case "SET_LIMIT_CONCURRENT_UAS": + return { + ...state, + rateLimit: { ...state.rateLimit, limitConcurrentUas: action.payload }, + }; // Circuit breaker case "SET_FAILURE_THRESHOLD": diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index e6acc79bb..c613b6f9c 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -76,6 +76,7 @@ export interface RateLimitState { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number | null; + limitConcurrentUas: number | null; } export interface CircuitBreakerState { @@ -167,6 +168,7 @@ export type ProviderFormAction = | { type: "SET_LIMIT_MONTHLY_USD"; payload: number | null } | { type: "SET_LIMIT_TOTAL_USD"; payload: number | null } | { type: "SET_LIMIT_CONCURRENT_SESSIONS"; payload: number | null } + | { type: "SET_LIMIT_CONCURRENT_UAS"; payload: number | null } // Circuit breaker actions | { type: "SET_FAILURE_THRESHOLD"; payload: number | undefined } | { type: "SET_OPEN_DURATION_MINUTES"; payload: number | undefined } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx index 3b9f8a470..d0cefbe7d 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -273,6 +273,19 @@ export function LimitsSection() { step="1" isDecimal={false} /> + dispatch({ type: "SET_LIMIT_CONCURRENT_UAS", payload: value })} + disabled={state.ui.isPending} + step="1" + isDecimal={false} + /> diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index e6558509e..ce0000be4 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -91,6 +91,7 @@ const { route: getUsersRoute, handler: getUsersHandler } = createActionRoute( limitMonthlyUsd: z.number().nullable().describe("月消费上限"), limitTotalUsd: z.number().nullable().describe("总消费上限"), limitConcurrentSessions: z.number().nullable().describe("并发Session上限"), + limitConcurrentUas: z.number().nullable(), createdAt: z.string().describe("创建时间"), }) ), @@ -125,6 +126,7 @@ const { route: addUserRoute, handler: addUserHandler } = createActionRoute( limitMonthlyUsd: z.number().nullable().describe("月消费上限"), limitTotalUsd: z.number().nullable().describe("总消费上限"), limitConcurrentSessions: z.number().nullable().describe("并发Session上限"), + limitConcurrentUas: z.number().nullable(), }), defaultKey: z.object({ id: z.number().describe("密钥ID"), @@ -271,6 +273,7 @@ const { route: addKeyRoute, handler: addKeyHandler } = createActionRoute( limitMonthlyUsd: z.number().nullable().optional(), limitTotalUsd: z.number().nullable().optional(), limitConcurrentSessions: z.number().optional(), + limitConcurrentUas: z.number().optional(), }), responseSchema: z.object({ generatedKey: z.string(), @@ -299,6 +302,7 @@ const { route: editKeyRoute, handler: editKeyHandler } = createActionRoute( limitMonthlyUsd: z.number().nullable().optional(), limitTotalUsd: z.number().nullable().optional(), limitConcurrentSessions: z.number().optional(), + limitConcurrentUas: z.number().optional(), }), description: "编辑密钥信息", summary: "编辑密钥信息", diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index ddeb9d4dd..28a4ecb52 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -31,7 +31,11 @@ const OVERRIDE_STATUS_CODE_MAX = 599; * - 消费限额用 402 Payment Required(需充值/等待重置) */ function getRateLimitStatusCode(limitType: string): number { - return limitType === "rpm" || limitType === "concurrent_sessions" ? 429 : 402; + return limitType === "rpm" || + limitType === "concurrent_sessions" || + limitType === "concurrent_uas" + ? 429 + : 402; } export class ProxyErrorHandler { @@ -316,7 +320,7 @@ export class ProxyErrorHandler { * - error.type: "rate_limit_error" * - error.message: 人类可读的错误消息 * - error.code: 错误代码(固定为 "rate_limit_exceeded") - * - error.limit_type: 限流类型(rpm/usd_5h/usd_weekly/usd_monthly/concurrent_sessions/daily_quota) + * - error.limit_type: 限流类型(rpm/usd_5h/usd_weekly/usd_monthly/concurrent_uas/concurrent_sessions/daily_quota) * - error.current: 当前使用量 * - error.limit: 限制值 * - error.reset_time: 重置时间(ISO-8601格式,滚动窗口为 null) diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index 5deb4c380..f7fa424ce 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -735,6 +735,7 @@ export class RateLimitError extends Error { | "usd_weekly" | "usd_monthly" | "usd_total" + | "concurrent_uas" | "concurrent_sessions" | "daily_quota", public readonly currentUsage: number, diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 788d010b8..b05771d8d 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -2,7 +2,9 @@ import { getCircuitState, isCircuitOpen } from "@/lib/circuit-breaker"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit"; +import { resolveConcurrentUaIdentity } from "@/lib/rate-limit/concurrent-ua-limit"; import { SessionManager } from "@/lib/session-manager"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; import { isProviderActiveNow } from "@/lib/utils/provider-schedule"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { isVendorTypeCircuitOpen } from "@/lib/vendor-type-circuit-breaker"; @@ -205,6 +207,75 @@ function checkFormatProviderTypeCompatibility( } export class ProxyProviderResolver { + private static async handleConcurrentLimitFallback( + session: ProxySession, + excludedProviders: number[], + attemptCount: number, + options: { + concurrentLimit: number; + currentConcurrent: number; + errorCode?: string; + errorParams?: Record; + errorMessage?: string; + } + ): Promise { + if (!session.provider) { + return false; + } + + const failedProvider = session.provider; + const failedContext = session.getLastSelectionContext(); + + session.addProviderToChain(failedProvider, { + reason: "concurrent_limit_failed", + selectionMethod: failedContext?.groupFilterApplied ? "group_filtered" : "weighted_random", + circuitState: getCircuitState(failedProvider.id), + attemptNumber: attemptCount, + ...(options.errorCode ? { errorCode: options.errorCode } : {}), + ...(options.errorParams ? { errorParams: options.errorParams } : {}), + ...(options.errorMessage ? { errorMessage: options.errorMessage } : {}), + decisionContext: failedContext + ? { + ...failedContext, + concurrentLimit: options.concurrentLimit, + currentConcurrent: options.currentConcurrent, + } + : { + totalProviders: 0, + enabledProviders: 0, + targetType: failedProvider.providerType as NonNullable< + ProviderChainItem["decisionContext"] + >["targetType"], + requestedModel: session.getOriginalModel() || "", + groupFilterApplied: false, + beforeHealthCheck: 0, + afterHealthCheck: 0, + priorityLevels: [], + selectedPriority: 0, + candidatesAtPriority: [], + concurrentLimit: options.concurrentLimit, + currentConcurrent: options.currentConcurrent, + }, + }); + + excludedProviders.push(failedProvider.id); + + const { provider: fallbackProvider, context: retryContext } = + await ProxyProviderResolver.pickRandomProvider(session, excludedProviders); + + if (!fallbackProvider) { + logger.error("ProviderSelector: No fallback providers available", { + excludedCount: excludedProviders.length, + totalAttempts: attemptCount, + }); + return false; + } + + session.setProvider(fallbackProvider); + session.setLastSelectionContext(retryContext); + return true; + } + static async ensure( session: ProxySession, _deprecatedTargetProviderType?: "claude" | "codex" // 废弃参数,保留向后兼容 @@ -265,6 +336,7 @@ export class ProxyProviderResolver { } // === 故障转移循环 === + const { id: uaId } = resolveConcurrentUaIdentity(session.userAgent); let attemptCount = 0; while (true) { attemptCount++; @@ -275,6 +347,43 @@ export class ProxyProviderResolver { // 选定供应商后,进行原子性并发检查并追踪 if (session.sessionId) { + // 0. 供应商并发 UA(User-Agent)限制:优先检查,避免先追踪 session 再因 UA 超限而回退导致 session 计数泄漏 + const uaLimit = session.provider.limitConcurrentUas || 0; + const uaCheckResult = await RateLimitService.checkAndTrackProviderUa( + session.provider.id, + uaId, + uaLimit + ); + const uaTrackedThisAttempt = uaCheckResult.tracked; + const uaTrackedAtMs = uaCheckResult.trackedAtMs; + + if (!uaCheckResult.allowed) { + logger.warn("ProviderSelector: Provider concurrent UA limit exceeded, trying fallback", { + providerName: session.provider.name, + providerId: session.provider.id, + current: uaCheckResult.count, + limit: uaLimit, + attempt: attemptCount, + }); + + const didFallback = await ProxyProviderResolver.handleConcurrentLimitFallback( + session, + excludedProviders, + attemptCount, + { + concurrentLimit: uaLimit, + currentConcurrent: uaCheckResult.count, + errorCode: uaCheckResult.reasonCode, + errorParams: uaCheckResult.reasonParams, + } + ); + + if (!didFallback) { + break; + } + continue; + } + const limit = session.provider.limitConcurrentSessions || 0; // 使用原子性检查并追踪(解决竞态条件) @@ -285,6 +394,11 @@ export class ProxyProviderResolver { ); if (!checkResult.allowed) { + // 避免“UA 已追踪但 Session 未通过”导致的 UA 计数泄漏(仅回滚本次新增 UA) + if (uaTrackedThisAttempt) { + await RateLimitService.untrackProviderUa(session.provider.id, uaId, uaTrackedAtMs); + } + // === 并发限制失败 === logger.warn( "ProviderSelector: Provider concurrent session limit exceeded, trying fallback", @@ -297,58 +411,21 @@ export class ProxyProviderResolver { } ); - const failedContext = session.getLastSelectionContext(); - session.addProviderToChain(session.provider, { - reason: "concurrent_limit_failed", - selectionMethod: failedContext?.groupFilterApplied - ? "group_filtered" - : "weighted_random", - circuitState: getCircuitState(session.provider.id), - attemptNumber: attemptCount, - errorMessage: checkResult.reason || "并发限制已达到", - decisionContext: failedContext - ? { - ...failedContext, - concurrentLimit: limit, - currentConcurrent: checkResult.count, - } - : { - totalProviders: 0, - enabledProviders: 0, - targetType: session.provider.providerType as NonNullable< - ProviderChainItem["decisionContext"] - >["targetType"], - requestedModel: session.getOriginalModel() || "", - groupFilterApplied: false, - beforeHealthCheck: 0, - afterHealthCheck: 0, - priorityLevels: [], - selectedPriority: 0, - candidatesAtPriority: [], - concurrentLimit: limit, - currentConcurrent: checkResult.count, - }, - }); - - // 加入排除列表 - excludedProviders.push(session.provider.id); - - // === 重试选择 === - const { provider: fallbackProvider, context: retryContext } = - await ProxyProviderResolver.pickRandomProvider(session, excludedProviders); + const didFallback = await ProxyProviderResolver.handleConcurrentLimitFallback( + session, + excludedProviders, + attemptCount, + { + concurrentLimit: limit, + currentConcurrent: checkResult.count, + errorCode: ERROR_CODES.RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED, + errorParams: { current: checkResult.count, limit, target: "provider" }, + } + ); - if (!fallbackProvider) { - // 无其他可用供应商,退出循环 - logger.error("ProviderSelector: No fallback providers available", { - excludedCount: excludedProviders.length, - totalAttempts: attemptCount, - }); + if (!didFallback) { break; } - - // 切换到新供应商 - session.setProvider(fallbackProvider); - session.setLastSelectionContext(retryContext); continue; // 继续下一次循环,检查新供应商 } diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 36f54f06a..6f1858956 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/src/app/v1/_lib/proxy/rate-limit-guard.ts @@ -1,6 +1,10 @@ import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit"; import { resolveKeyUserConcurrentSessionLimits } from "@/lib/rate-limit/concurrent-session-limit"; +import { + resolveConcurrentUaIdentity, + resolveKeyUserConcurrentUaLimits, +} from "@/lib/rate-limit/concurrent-ua-limit"; import { getResetInfo, getResetInfoWithMode } from "@/lib/rate-limit/time-utils"; import { SessionManager } from "@/lib/session-manager"; import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages"; @@ -41,9 +45,9 @@ export class ProxyRateLimitGuard { * * 检查顺序(基于 Codex 专业分析): * 1-2. 永久硬限制:Key 总限额 → User 总限额 - * 3-4. 资源/频率保护:Key/User 并发 → User RPM - * 5-8. 短期周期限额:Key 5h → User 5h → Key 每日 → User 每日 - * 9-12. 中长期周期限额:Key 周 → User 周 → Key 月 → User 月 + * 3-5. 资源/频率保护:Key/User 并发 UA → Key/User 并发 Session → User RPM + * 6-9. 短期周期限额:Key 5h → User 5h → Key 每日 → User 每日 + * 10-13. 中长期周期限额:Key 周 → User 周 → Key 月 → User 月 * * 设计原则: * - 硬上限优先于周期上限 @@ -124,7 +128,65 @@ export class ProxyRateLimitGuard { // ========== 第二层:资源/频率保护 ========== - // 3. Key 并发 Session(避免创建上游连接) + // 3. Key/User 并发 UA(用于限制“同时活跃的客户端/UA 数量”) + // Key 未设置时,继承 User 并发上限(避免 UI/心智模型不一致) + const { + effectiveKeyLimit: effectiveKeyUaConcurrentLimit, + normalizedUserLimit: normalizedUserUaConcurrentLimit, + } = resolveKeyUserConcurrentUaLimits(key.limitConcurrentUas ?? 0, user.limitConcurrentUas); + + const { id: uaId } = resolveConcurrentUaIdentity(session.userAgent); + + const uaConcurrentCheck = await RateLimitService.checkAndTrackKeyUserUa( + key.id, + user.id, + uaId, + effectiveKeyUaConcurrentLimit, + normalizedUserUaConcurrentLimit + ); + + if (!uaConcurrentCheck.allowed) { + const rejectedBy = uaConcurrentCheck.rejectedBy ?? "key"; + const fallbackCurrentUsage = + rejectedBy === "user" ? uaConcurrentCheck.userCount : uaConcurrentCheck.keyCount; + const fallbackLimitValue = + rejectedBy === "user" ? normalizedUserUaConcurrentLimit : effectiveKeyUaConcurrentLimit; + const currentUsage = Number(uaConcurrentCheck.reasonParams?.current); + const limitValue = Number(uaConcurrentCheck.reasonParams?.limit); + const resolvedCurrentUsage = Number.isFinite(currentUsage) + ? currentUsage + : fallbackCurrentUsage; + const resolvedLimitValue = Number.isFinite(limitValue) ? limitValue : fallbackLimitValue; + + logger.warn( + `[RateLimit] ${rejectedBy === "user" ? "User" : "Key"} UA limit exceeded: key=${key.id}, user=${user.id}, current=${resolvedCurrentUsage}, limit=${resolvedLimitValue}` + ); + + const resetTime = new Date().toISOString(); + + const { getLocale } = await import("next-intl/server"); + const locale = await getLocale(); + const message = await getErrorMessageServer( + locale, + uaConcurrentCheck.reasonCode ?? ERROR_CODES.RATE_LIMIT_CONCURRENT_UAS_EXCEEDED, + { + current: String(resolvedCurrentUsage), + limit: String(resolvedLimitValue), + } + ); + + throw new RateLimitError( + "rate_limit_error", + message, + "concurrent_uas", + resolvedCurrentUsage, + resolvedLimitValue, + resetTime, + null + ); + } + + // 4. Key 并发 Session(避免创建上游连接) // Key 未设置时,继承 User 并发上限(避免 UI/心智模型不一致:User 设置了并发,但 Key 仍显示“无限制”) const { effectiveKeyLimit: effectiveKeyConcurrentLimit, @@ -193,7 +255,7 @@ export class ProxyRateLimitGuard { ); } - // 4. User RPM(频率闸门,挡住高频噪声)- null/0 表示无限制 + // 5. User RPM(频率闸门,挡住高频噪声)- null/0 表示无限制 if (user.rpm != null && user.rpm > 0) { const rpmCheck = await RateLimitService.checkRpmLimit(user.id, "user", user.rpm); if (!rpmCheck.allowed) { @@ -223,7 +285,7 @@ export class ProxyRateLimitGuard { // ========== 第三层:短期周期限额(混合检查)========== - // 5. Key 5h 限额(最短周期,最易触发) + // 6. Key 5h 限额(最短周期,最易触发) const key5hCheck = await RateLimitService.checkCostLimitsWithLease(key.id, "key", { limit_5h_usd: key.limit5hUsd, limit_daily_usd: null, // 仅检查 5h @@ -259,7 +321,7 @@ export class ProxyRateLimitGuard { ); } - // 6. User 5h 限额(防止多 Key 合力在短窗口打爆用户) + // 7. User 5h 限额(防止多 Key 合力在短窗口打爆用户) const user5hCheck = await RateLimitService.checkCostLimitsWithLease(user.id, "user", { limit_5h_usd: user.limit5hUsd ?? null, limit_daily_usd: null, @@ -295,7 +357,7 @@ export class ProxyRateLimitGuard { ); } - // 7. Key 每日限额(Key 独有的每日预算)- null 表示无限制 + // 8. Key 每日限额(Key 独有的每日预算)- null 表示无限制 const keyDailyCheck = await RateLimitService.checkCostLimitsWithLease(key.id, "key", { limit_5h_usd: null, limit_daily_usd: key.limitDailyUsd, @@ -367,7 +429,7 @@ export class ProxyRateLimitGuard { } } - // 8. User 每日额度(User 独有的常用预算)- null 表示无限制 + // 9. User 每日额度(User 独有的常用预算)- null 表示无限制 // NOTE: 已迁移到 checkCostLimitsWithLease 以保持与其他周期限额的一致性 const userDailyCheck = await RateLimitService.checkCostLimitsWithLease(user.id, "user", { limit_5h_usd: null, // 仅检查 daily @@ -444,7 +506,7 @@ export class ProxyRateLimitGuard { // ========== 第四层:中长期周期限额(混合检查)========== - // 9. Key 周限额 + // 10. Key 周限额 const keyWeeklyCheck = await RateLimitService.checkCostLimitsWithLease(key.id, "key", { limit_5h_usd: null, limit_daily_usd: null, @@ -478,7 +540,7 @@ export class ProxyRateLimitGuard { ); } - // 10. User 周限额 + // 11. User 周限额 const userWeeklyCheck = await RateLimitService.checkCostLimitsWithLease(user.id, "user", { limit_5h_usd: null, limit_daily_usd: null, @@ -514,7 +576,7 @@ export class ProxyRateLimitGuard { ); } - // 11. Key 月限额 + // 12. Key 月限额 const keyMonthlyCheck = await RateLimitService.checkCostLimitsWithLease(key.id, "key", { limit_5h_usd: null, limit_daily_usd: null, @@ -550,7 +612,7 @@ export class ProxyRateLimitGuard { ); } - // 12. User 月限额(最后一道长期预算闸门) + // 13. User 月限额(最后一道长期预算闸门) const userMonthlyCheck = await RateLimitService.checkCostLimitsWithLease(user.id, "user", { limit_5h_usd: null, limit_daily_usd: null, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 74afe9400..4736d473d 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -459,6 +459,8 @@ export class ProxySession { circuitState?: "closed" | "open" | "half-open"; attemptNumber?: number; errorMessage?: string; // 错误信息(失败时记录) + errorCode?: string; // 错误码(用于 i18n 渲染/聚合) + errorParams?: Record; // 错误参数(用于 i18n 渲染) endpointId?: number | null; endpointUrl?: string; // 修复:添加新字段 @@ -490,6 +492,8 @@ export class ProxySession { timestamp: Date.now(), attemptNumber: metadata?.attemptNumber, errorMessage: metadata?.errorMessage, // 记录错误信息 + errorCode: metadata?.errorCode, + errorParams: metadata?.errorParams, // 修复:记录新字段 statusCode: metadata?.statusCode, statusCodeInferred: metadata?.statusCodeInferred, diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 60a1f8e17..636e45cf1 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -52,6 +52,7 @@ export const users = pgTable('users', { limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }), limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), limitConcurrentSessions: integer('limit_concurrent_sessions'), + limitConcurrentUas: integer('limit_concurrent_uas'), // Daily quota reset mode (fixed: reset at specific time, rolling: 24h window) dailyResetMode: dailyResetModeEnum('daily_reset_mode') @@ -121,6 +122,7 @@ export const keys = pgTable('keys', { limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }), limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), limitConcurrentSessions: integer('limit_concurrent_sessions').default(0), + limitConcurrentUas: integer('limit_concurrent_uas').default(0), // Provider group for this key (explicit; defaults to "default") providerGroup: varchar('provider_group', { length: 200 }).default('default'), @@ -244,6 +246,7 @@ export const providers = pgTable('providers', { limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), totalCostResetAt: timestamp('total_cost_reset_at', { withTimezone: true }), limitConcurrentSessions: integer('limit_concurrent_sessions').default(0), + limitConcurrentUas: integer('limit_concurrent_uas').default(0), // 熔断器配置(每个供应商独立配置) // null = 使用全局默认值 (env.MAX_RETRY_ATTEMPTS_DEFAULT 或 2) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4f6749282..e89810533 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -200,6 +200,7 @@ export async function validateKey( limitWeeklyUsd: null, limitMonthlyUsd: null, limitConcurrentSessions: 0, + limitConcurrentUas: 0, cacheTtlPreference: null, createdAt: now, updatedAt: now, diff --git a/src/lib/permissions/user-field-permissions.ts b/src/lib/permissions/user-field-permissions.ts index 2d0d7fb32..e8faefa7c 100644 --- a/src/lib/permissions/user-field-permissions.ts +++ b/src/lib/permissions/user-field-permissions.ts @@ -17,6 +17,7 @@ export const USER_FIELD_PERMISSIONS = { limitMonthlyUsd: { requiredRole: "admin" }, limitTotalUsd: { requiredRole: "admin" }, limitConcurrentSessions: { requiredRole: "admin" }, + limitConcurrentUas: { requiredRole: "admin" }, // Admin-only fields (daily reset configuration) dailyResetMode: { requiredRole: "admin" }, diff --git a/src/lib/provider-patch-contract.ts b/src/lib/provider-patch-contract.ts index e208eee80..2e7282e89 100644 --- a/src/lib/provider-patch-contract.ts +++ b/src/lib/provider-patch-contract.ts @@ -58,6 +58,7 @@ const PATCH_FIELDS: ProviderBatchPatchField[] = [ "limit_monthly_usd", "limit_total_usd", "limit_concurrent_sessions", + "limit_concurrent_uas", // Circuit Breaker "circuit_breaker_failure_threshold", "circuit_breaker_open_duration", @@ -110,6 +111,7 @@ const CLEARABLE_FIELDS: Record = { limit_monthly_usd: true, limit_total_usd: true, limit_concurrent_sessions: false, + limit_concurrent_uas: false, // Circuit Breaker circuit_breaker_failure_threshold: false, circuit_breaker_open_duration: false, @@ -222,7 +224,6 @@ function isValidSetValue(field: ProviderBatchPatchField, value: unknown): boolea case "limit_weekly_usd": case "limit_monthly_usd": case "limit_total_usd": - case "limit_concurrent_sessions": case "circuit_breaker_failure_threshold": case "circuit_breaker_open_duration": case "circuit_breaker_half_open_success_threshold": @@ -231,6 +232,9 @@ function isValidSetValue(field: ProviderBatchPatchField, value: unknown): boolea case "streaming_idle_timeout_ms": case "request_timeout_non_streaming_ms": return typeof value === "number" && Number.isFinite(value); + case "limit_concurrent_sessions": + case "limit_concurrent_uas": + return typeof value === "number" && Number.isInteger(value) && value >= 0; case "group_tag": case "daily_reset_time": case "proxy_url": @@ -519,6 +523,12 @@ export function normalizeProviderBatchPatchDraft( ); if (!limitConcurrentSessions.ok) return limitConcurrentSessions; + const limitConcurrentUas = normalizePatchField( + "limit_concurrent_uas", + typedDraft.limit_concurrent_uas + ); + if (!limitConcurrentUas.ok) return limitConcurrentUas; + // Circuit Breaker const cbFailureThreshold = normalizePatchField( "circuit_breaker_failure_threshold", @@ -619,6 +629,7 @@ export function normalizeProviderBatchPatchDraft( limit_monthly_usd: limitMonthlyUsd.data, limit_total_usd: limitTotalUsd.data, limit_concurrent_sessions: limitConcurrentSessions.data, + limit_concurrent_uas: limitConcurrentUas.data, // Circuit Breaker circuit_breaker_failure_threshold: cbFailureThreshold.data, circuit_breaker_open_duration: cbOpenDuration.data, @@ -761,6 +772,10 @@ function applyPatchField( updates.limit_concurrent_sessions = patch.value as ProviderBatchApplyUpdates["limit_concurrent_sessions"]; return { ok: true, data: undefined }; + case "limit_concurrent_uas": + updates.limit_concurrent_uas = + patch.value as ProviderBatchApplyUpdates["limit_concurrent_uas"]; + return { ok: true, data: undefined }; // Circuit Breaker case "circuit_breaker_failure_threshold": updates.circuit_breaker_failure_threshold = @@ -943,6 +958,7 @@ export function buildProviderBatchApplyUpdates( ["limit_monthly_usd", patch.limit_monthly_usd], ["limit_total_usd", patch.limit_total_usd], ["limit_concurrent_sessions", patch.limit_concurrent_sessions], + ["limit_concurrent_uas", patch.limit_concurrent_uas], // Circuit Breaker ["circuit_breaker_failure_threshold", patch.circuit_breaker_failure_threshold], ["circuit_breaker_open_duration", patch.circuit_breaker_open_duration], @@ -1008,6 +1024,7 @@ export function hasProviderBatchPatchChanges(patch: ProviderBatchPatch): boolean patch.limit_monthly_usd.mode !== "no_change" || patch.limit_total_usd.mode !== "no_change" || patch.limit_concurrent_sessions.mode !== "no_change" || + patch.limit_concurrent_uas.mode !== "no_change" || // Circuit Breaker patch.circuit_breaker_failure_threshold.mode !== "no_change" || patch.circuit_breaker_open_duration.mode !== "no_change" || diff --git a/src/lib/rate-limit/concurrent-ua-limit.ts b/src/lib/rate-limit/concurrent-ua-limit.ts new file mode 100644 index 000000000..9a6bb930c --- /dev/null +++ b/src/lib/rate-limit/concurrent-ua-limit.ts @@ -0,0 +1,78 @@ +import crypto from "node:crypto"; +import { parseUserAgent } from "@/lib/ua-parser"; + +export type ConcurrentUaIdentity = { + bucket: string; + id: string; +}; + +/** + * 将输入归一化为正整数限额。 + * + * - 非数字 / 非有限值 / <= 0 视为 0(无限制) + * - > 0 时向下取整 + */ +export function normalizeConcurrentUaLimit(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return 0; + } + + return Math.floor(value); +} + +/** + * 同时解析 Key/User 的并发 UA 上限(供 proxy guards 统一复用)。 + * + * - `effectiveKeyLimit`:Key 的有效上限(Key>0 优先,否则回退到 User>0;都未设置则为 0) + * - `normalizedUserLimit`:User 上限的归一化结果(<=0 视为 0) + * - `enabled`:任一维度上限 >0 即为 true + */ +export function resolveKeyUserConcurrentUaLimits( + keyLimit: number | null | undefined, + userLimit: number | null | undefined +): { effectiveKeyLimit: number; normalizedUserLimit: number; enabled: boolean } { + const normalizedUserLimit = normalizeConcurrentUaLimit(userLimit); + const effectiveKeyLimit = resolveKeyConcurrentUaLimit(keyLimit, userLimit); + const enabled = effectiveKeyLimit > 0 || normalizedUserLimit > 0; + + return { effectiveKeyLimit, normalizedUserLimit, enabled }; +} + +/** + * 解析 Key 的“有效并发 UA 上限”。 + * + * 规则: + * - Key 自身设置(>0)优先生效 + * - Key 未设置/为 0 时,回退到 User 并发上限(>0) + * - 都未设置/为 0 时,返回 0(表示无限制) + */ +export function resolveKeyConcurrentUaLimit( + keyLimit: number | null | undefined, + userLimit: number | null | undefined +): number { + const normalizedKeyLimit = normalizeConcurrentUaLimit(keyLimit); + if (normalizedKeyLimit > 0) { + return normalizedKeyLimit; + } + + return normalizeConcurrentUaLimit(userLimit); +} + +/** + * 解析并发 UA 标识(用于 Redis 并发追踪/限流)。 + * + * 说明: + * - 优先使用解析出的 `clientType` 作为 bucket(稳定、不随小版本变化) + * - 解析失败时回退到原始 UA 字符串(trim 后) + * - 最终以 sha256(bucket) 作为 Redis member(避免超长 UA 影响内存与性能) + */ +export function resolveConcurrentUaIdentity( + userAgent: string | null | undefined +): ConcurrentUaIdentity { + const raw = typeof userAgent === "string" ? userAgent.trim() : ""; + const parsed = raw ? parseUserAgent(raw) : null; + const bucket = parsed?.clientType ?? (raw || "unknown"); + const id = `ua:${crypto.createHash("sha256").update(bucket, "utf8").digest("hex")}`; + + return { bucket, id }; +} diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 98b597788..a5af1b72e 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -73,12 +73,20 @@ import { getUserActiveSessionsKey, } from "@/lib/redis/active-session-keys"; import { - CHECK_AND_TRACK_KEY_USER_SESSION, + getGlobalActiveUasKey, + getKeyActiveUasKey, + getProviderActiveUasKey, + getUserActiveUasKey, +} from "@/lib/redis/active-ua-keys"; +import { + CHECK_AND_TRACK_KEY_USER_ZSET_MEMBER, CHECK_AND_TRACK_SESSION, + CHECK_AND_TRACK_ZSET_MEMBER, GET_COST_5H_ROLLING_WINDOW, GET_COST_DAILY_ROLLING_WINDOW, TRACK_COST_5H_ROLLING_WINDOW, TRACK_COST_DAILY_ROLLING_WINDOW, + UNTRACK_ZSET_MEMBER_IF_SCORE_MATCH, } from "@/lib/redis/lua-scripts"; import { SessionTracker } from "@/lib/session-tracker"; import { ERROR_CODES } from "@/lib/utils/error-messages"; @@ -600,7 +608,7 @@ export class RateLimitService { const now = Date.now(); const result = (await RateLimitService.redis.eval( - CHECK_AND_TRACK_KEY_USER_SESSION, + CHECK_AND_TRACK_KEY_USER_ZSET_MEMBER, 3, // KEYS count globalKey, // KEYS[1] keyKey, // KEYS[2] @@ -644,6 +652,90 @@ export class RateLimitService { } } + /** + * 原子性检查并追踪 Key/User 并发 UA(解决竞态条件) + * + * 语义与 checkAndTrackKeyUserSession 保持一致: + * - 仅阻止“新 UA”进入;已存在 UA 达到上限后仍允许继续请求 + * - keyLimit/userLimit 均 <=0 时表示无限制,直接放行且不追踪 + * - Redis 不可用时 Fail Open + */ + static async checkAndTrackKeyUserUa( + keyId: number, + userId: number, + uaId: string, + keyLimit: number, + userLimit: number + ): Promise<{ + allowed: boolean; + keyCount: number; + userCount: number; + trackedKey: boolean; + trackedUser: boolean; + rejectedBy?: "key" | "user"; + reasonCode?: string; + reasonParams?: Record; + }> { + if (keyLimit <= 0 && userLimit <= 0) { + return { allowed: true, keyCount: 0, userCount: 0, trackedKey: false, trackedUser: false }; + } + + if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") { + logger.warn("[RateLimit] Redis not ready, Fail Open"); + return { allowed: true, keyCount: 0, userCount: 0, trackedKey: false, trackedUser: false }; + } + + try { + const globalKey = getGlobalActiveUasKey(); + const keyKey = getKeyActiveUasKey(keyId); + const userKey = getUserActiveUasKey(userId); + const now = Date.now(); + + const result = (await RateLimitService.redis.eval( + CHECK_AND_TRACK_KEY_USER_ZSET_MEMBER, + 3, // KEYS count + globalKey, // KEYS[1] + keyKey, // KEYS[2] + userKey, // KEYS[3] + uaId, // ARGV[1] + keyLimit.toString(), // ARGV[2] + userLimit.toString(), // ARGV[3] + now.toString(), // ARGV[4] + SESSION_TTL_MS.toString() // ARGV[5] + )) as [number, number, number, number, number, number]; + + const [allowed, rejectedBy, keyCount, keyTracked, userCount, userTracked] = result; + + if (allowed === 0) { + const rejectTarget: "key" | "user" = rejectedBy === 1 ? "key" : "user"; + const limit = rejectTarget === "key" ? keyLimit : userLimit; + const count = rejectTarget === "key" ? keyCount : userCount; + + return { + allowed: false, + keyCount, + userCount, + trackedKey: false, + trackedUser: false, + rejectedBy: rejectTarget, + reasonCode: ERROR_CODES.RATE_LIMIT_CONCURRENT_UAS_EXCEEDED, + reasonParams: { current: count, limit, target: rejectTarget }, + }; + } + + return { + allowed: true, + keyCount, + userCount, + trackedKey: keyTracked === 1, + trackedUser: userTracked === 1, + }; + } catch (error) { + logger.error("[RateLimit] Key/User UA check+track failed:", error); + return { allowed: true, keyCount: 0, userCount: 0, trackedKey: false, trackedUser: false }; + } + } + /** * 原子性检查并追踪供应商 Session(解决竞态条件) * @@ -704,6 +796,98 @@ export class RateLimitService { } } + /** + * 原子性检查并追踪供应商 UA(解决竞态条件) + * + * 语义与 checkAndTrackProviderSession 保持一致: + * - 仅阻止“新 UA”进入;已存在 UA 达到上限后仍允许继续请求 + * - Redis 不可用时 Fail Open + */ + static async checkAndTrackProviderUa( + providerId: number, + uaId: string, + limit: number + ): Promise<{ + allowed: boolean; + count: number; + tracked: boolean; + trackedAtMs?: number; + reasonCode?: string; + reasonParams?: Record; + }> { + if (limit <= 0) { + return { allowed: true, count: 0, tracked: false }; + } + + if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") { + logger.warn("[RateLimit] Redis not ready, Fail Open"); + return { allowed: true, count: 0, tracked: false }; + } + + try { + const key = getProviderActiveUasKey(providerId); + const now = Date.now(); + + const result = (await RateLimitService.redis.eval( + CHECK_AND_TRACK_ZSET_MEMBER, + 1, // KEYS count + key, // KEYS[1] + uaId, // ARGV[1] + limit.toString(), // ARGV[2] + now.toString(), // ARGV[3] + SESSION_TTL_MS.toString() // ARGV[4] + )) as [number, number, number]; + + const [allowed, count, tracked] = result; + + if (allowed === 0) { + return { + allowed: false, + count, + tracked: false, + reasonCode: ERROR_CODES.RATE_LIMIT_CONCURRENT_UAS_EXCEEDED, + reasonParams: { current: count, limit, target: "provider" }, + }; + } + + return { + allowed: true, + count, + tracked: tracked === 1, + trackedAtMs: tracked === 1 ? now : undefined, + }; + } catch (error) { + logger.error("[RateLimit] Provider UA check+track failed:", error); + return { allowed: true, count: 0, tracked: false }; + } + } + + static async untrackProviderUa( + providerId: number, + uaId: string, + expectedScoreMs?: number + ): Promise { + if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") { + return false; + } + + try { + const key = getProviderActiveUasKey(providerId); + const removed = (await RateLimitService.redis.eval( + UNTRACK_ZSET_MEMBER_IF_SCORE_MATCH, + 1, // KEYS count + key, // KEYS[1] + uaId, // ARGV[1] + expectedScoreMs != null ? String(expectedScoreMs) : "" // ARGV[2] + )) as number; + + return removed === 1; + } catch (error) { + logger.error("[RateLimit] Provider UA untrack failed:", error); + return false; + } + } + /** * 累加消费(请求结束后调用) * 5h 使用滚动窗口(ZSET),daily 根据模式选择滚动/固定窗口,周/月使用固定窗口(STRING) diff --git a/src/lib/redis/active-ua-keys.ts b/src/lib/redis/active-ua-keys.ts new file mode 100644 index 000000000..8f4d79d19 --- /dev/null +++ b/src/lib/redis/active-ua-keys.ts @@ -0,0 +1,45 @@ +/** + * Active UAs 相关 Redis key 生成器。 + * + * 说明: + * - 为兼容 Redis Cluster 下的 Lua 脚本多 key 操作,需要相关 key 共享相同 hash tag,避免 CROSSSLOT。 + * - active_uas 相关 key 统一加 hash tag,避免后续扩展出现 CROSSSLOT 风险。 + */ +const ACTIVE_UAS_HASH_TAG = "{active_uas}"; + +/** + * 全局活跃 UA ZSET(用于观测)。 + */ +export function getGlobalActiveUasKey(): string { + return `${ACTIVE_UAS_HASH_TAG}:global:active_uas`; +} + +/** + * Key 维度活跃 UA ZSET(用于 Key 并发 UA 上限判断)。 + */ +export function getKeyActiveUasKey(keyId: number): string { + if (!Number.isSafeInteger(keyId)) { + throw new TypeError("getKeyActiveUasKey: keyId must be a safe integer"); + } + return `${ACTIVE_UAS_HASH_TAG}:key:${keyId}:active_uas`; +} + +/** + * User 维度活跃 UA ZSET(用于跨多 Key 的 User 并发 UA 上限判断)。 + */ +export function getUserActiveUasKey(userId: number): string { + if (!Number.isSafeInteger(userId)) { + throw new TypeError("getUserActiveUasKey: userId must be a safe integer"); + } + return `${ACTIVE_UAS_HASH_TAG}:user:${userId}:active_uas`; +} + +/** + * Provider 维度活跃 UA ZSET(用于 Provider 并发 UA 上限判断)。 + */ +export function getProviderActiveUasKey(providerId: number): string { + if (!Number.isSafeInteger(providerId)) { + throw new TypeError("getProviderActiveUasKey: providerId must be a safe integer"); + } + return `${ACTIVE_UAS_HASH_TAG}:provider:${providerId}:active_uas`; +} diff --git a/src/lib/redis/lua-scripts.ts b/src/lib/redis/lua-scripts.ts index 402b702d0..6ecd8524e 100644 --- a/src/lib/redis/lua-scripts.ts +++ b/src/lib/redis/lua-scripts.ts @@ -385,3 +385,48 @@ end return tostring(total) `; + +/** + * 通用别名:单 ZSET member 的并发检查 + 追踪(Session/UA 复用)。 + */ +export const CHECK_AND_TRACK_ZSET_MEMBER = CHECK_AND_TRACK_SESSION; + +/** + * 通用别名:global/key/user 三 key 的并发检查 + 追踪(Session/UA 复用)。 + */ +export const CHECK_AND_TRACK_KEY_USER_ZSET_MEMBER = CHECK_AND_TRACK_KEY_USER_SESSION; + +/** + * 从单 ZSET 移除 member(可选:仅当 score 匹配时)。 + * + * KEYS[1]: target ZSET key + * ARGV[1]: member + * ARGV[2]: expectedScore(可选;空字符串表示无条件移除) + * + * Return: + * - 1: removed + * - 0: not removed + */ +export const UNTRACK_ZSET_MEMBER_IF_SCORE_MATCH = ` +local key = KEYS[1] +local member = ARGV[1] +local expected = ARGV[2] or '' + +local score = redis.call('ZSCORE', key, member) +if not score then + return 0 +end + +if expected ~= '' then + local score_num = tonumber(score) + local expected_num = tonumber(expected) + if not expected_num then + return 0 + end + if score_num ~= expected_num then + return 0 + end +end + +return redis.call('ZREM', key, member) +`; diff --git a/src/lib/utils/error-messages.ts b/src/lib/utils/error-messages.ts index 95a40e7f1..d3e5742fa 100644 --- a/src/lib/utils/error-messages.ts +++ b/src/lib/utils/error-messages.ts @@ -102,6 +102,7 @@ export const RATE_LIMIT_ERRORS = { RATE_LIMIT_MONTHLY_EXCEEDED: "RATE_LIMIT_MONTHLY_EXCEEDED", RATE_LIMIT_TOTAL_EXCEEDED: "RATE_LIMIT_TOTAL_EXCEEDED", RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED: "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED", + RATE_LIMIT_CONCURRENT_UAS_EXCEEDED: "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED", RATE_LIMIT_DAILY_QUOTA_EXCEEDED: "RATE_LIMIT_DAILY_QUOTA_EXCEEDED", RATE_LIMIT_DAILY_ROLLING_EXCEEDED: "RATE_LIMIT_DAILY_ROLLING_EXCEEDED", } as const; @@ -218,6 +219,9 @@ export function zodErrorToCode( switch (zodErrorCode) { case "invalid_type": + if (type === "int") { + return { code: ERROR_CODES.MUST_BE_INTEGER, params: { field: field || "field" } }; + } if (type === "string" && params.received === "undefined") { return { code: ERROR_CODES.REQUIRED_FIELD, params: { field: field || "field" } }; } diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index aee7785e4..c7569df0d 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -89,6 +89,14 @@ const OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA = z.preprocess( const OPTIONAL_CLIENT_PATTERN_ARRAY_WITH_DEFAULT_SCHEMA = OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA.default([]); +const CONCURRENT_LIMIT_INT_SCHEMA = z.coerce.number().int().min(0).max(1000); +const CONCURRENT_LIMIT_NULLABLE_OPTIONAL_SCHEMA = z + .preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? null : value), + z.union([z.null(), CONCURRENT_LIMIT_INT_SCHEMA]) + ) + .optional(); + /** * 用户创建数据验证schema */ @@ -143,13 +151,8 @@ export const CreateUserSchema = z.object({ .max(10000000, "总消费上限不能超过10000000美元") .nullable() .optional(), - limitConcurrentSessions: z.coerce - .number() - .int("并发Session上限必须是整数") - .min(0, "并发Session上限不能为负数") - .max(1000, "并发Session上限不能超过1000") - .nullable() - .optional(), + limitConcurrentSessions: CONCURRENT_LIMIT_NULLABLE_OPTIONAL_SCHEMA, + limitConcurrentUas: CONCURRENT_LIMIT_NULLABLE_OPTIONAL_SCHEMA, // User status and expiry management isEnabled: z.boolean().optional().default(true), expiresAt: z.preprocess( @@ -270,13 +273,8 @@ export const UpdateUserSchema = z.object({ .max(10000000, "总消费上限不能超过10000000美元") .nullable() .optional(), - limitConcurrentSessions: z.coerce - .number() - .int("并发Session上限必须是整数") - .min(0, "并发Session上限不能为负数") - .max(1000, "并发Session上限不能超过1000") - .nullable() - .optional(), + limitConcurrentSessions: CONCURRENT_LIMIT_NULLABLE_OPTIONAL_SCHEMA, + limitConcurrentUas: CONCURRENT_LIMIT_NULLABLE_OPTIONAL_SCHEMA, // User status and expiry management isEnabled: z.boolean().optional(), expiresAt: z.preprocess( @@ -394,13 +392,8 @@ export const KeyFormSchema = z.object({ .max(10000000, "总消费上限不能超过10000000美元") .nullable() .optional(), - limitConcurrentSessions: z.coerce - .number() - .int("并发Session上限必须是整数") - .min(0, "并发Session上限不能为负数") - .max(1000, "并发Session上限不能超过1000") - .optional() - .default(0), + limitConcurrentSessions: CONCURRENT_LIMIT_INT_SCHEMA.optional().default(0), + limitConcurrentUas: CONCURRENT_LIMIT_INT_SCHEMA.optional().default(0), providerGroup: z .string() .max(200, "供应商分组不能超过200个字符") @@ -507,13 +500,8 @@ export const CreateProviderSchema = z .max(10000000, "总消费上限不能超过10000000美元") .nullable() .optional(), - limit_concurrent_sessions: z.coerce - .number() - .int("并发Session上限必须是整数") - .min(0, "并发Session上限不能为负数") - .max(1000, "并发Session上限不能超过1000") - .optional() - .default(0), + limit_concurrent_sessions: CONCURRENT_LIMIT_INT_SCHEMA.optional().default(0), + limit_concurrent_uas: CONCURRENT_LIMIT_INT_SCHEMA.optional().default(0), cache_ttl_preference: CACHE_TTL_PREFERENCE.optional().default("inherit"), swap_cache_ttl_billing: z.boolean().optional().default(false), context_1m_preference: CONTEXT_1M_PREFERENCE.nullable().optional(), @@ -741,12 +729,8 @@ export const UpdateProviderSchema = z .max(10000000, "总消费上限不能超过10000000美元") .nullable() .optional(), - limit_concurrent_sessions: z.coerce - .number() - .int("并发Session上限必须是整数") - .min(0, "并发Session上限不能为负数") - .max(1000, "并发Session上限不能超过1000") - .optional(), + limit_concurrent_sessions: CONCURRENT_LIMIT_INT_SCHEMA.optional(), + limit_concurrent_uas: CONCURRENT_LIMIT_INT_SCHEMA.optional(), cache_ttl_preference: CACHE_TTL_PREFERENCE.optional(), swap_cache_ttl_billing: z.boolean().optional(), context_1m_preference: CONTEXT_1M_PREFERENCE.nullable().optional(), diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 562ee92d2..4e2797bbf 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -43,6 +43,7 @@ export function toUser(dbUser: any): User { limitMonthlyUsd: parseOptionalNumber(dbUser?.limitMonthlyUsd), limitTotalUsd: parseOptionalNumber(dbUser?.limitTotalUsd), limitConcurrentSessions: parseOptionalInteger(dbUser?.limitConcurrentSessions), + limitConcurrentUas: parseOptionalInteger(dbUser?.limitConcurrentUas), dailyResetMode: dbUser?.dailyResetMode ?? "fixed", dailyResetTime: dbUser?.dailyResetTime ?? "00:00", isEnabled: dbUser?.isEnabled ?? true, @@ -71,6 +72,7 @@ export function toKey(dbKey: any): Key { ? parseFloat(dbKey.limitTotalUsd) : null, limitConcurrentSessions: dbKey?.limitConcurrentSessions ?? 0, + limitConcurrentUas: dbKey?.limitConcurrentUas ?? 0, providerGroup: dbKey?.providerGroup ?? null, cacheTtlPreference: dbKey?.cacheTtlPreference ?? null, createdAt: dbKey?.createdAt ? new Date(dbKey.createdAt) : new Date(), @@ -107,6 +109,7 @@ export function toProvider(dbProvider: any): Provider { : null, totalCostResetAt: dbProvider?.totalCostResetAt ? new Date(dbProvider.totalCostResetAt) : null, limitConcurrentSessions: dbProvider?.limitConcurrentSessions ?? 0, + limitConcurrentUas: dbProvider?.limitConcurrentUas ?? 0, maxRetryAttempts: dbProvider?.maxRetryAttempts !== undefined && dbProvider?.maxRetryAttempts !== null ? Number(dbProvider.maxRetryAttempts) diff --git a/src/repository/key.ts b/src/repository/key.ts index 15f03abc7..4d5b17919 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -37,6 +37,7 @@ export async function findKeyById(id: number): Promise { limitMonthlyUsd: keys.limitMonthlyUsd, limitTotalUsd: keys.limitTotalUsd, limitConcurrentSessions: keys.limitConcurrentSessions, + limitConcurrentUas: keys.limitConcurrentUas, providerGroup: keys.providerGroup, cacheTtlPreference: keys.cacheTtlPreference, createdAt: keys.createdAt, @@ -68,6 +69,7 @@ export async function findKeyList(userId: number): Promise { limitMonthlyUsd: keys.limitMonthlyUsd, limitTotalUsd: keys.limitTotalUsd, limitConcurrentSessions: keys.limitConcurrentSessions, + limitConcurrentUas: keys.limitConcurrentUas, providerGroup: keys.providerGroup, cacheTtlPreference: keys.cacheTtlPreference, createdAt: keys.createdAt, @@ -107,6 +109,7 @@ export async function findKeyListBatch(userIds: number[]): Promise { keyData.limit_monthly_usd != null ? keyData.limit_monthly_usd.toString() : null, limitTotalUsd: keyData.limit_total_usd != null ? keyData.limit_total_usd.toString() : null, limitConcurrentSessions: keyData.limit_concurrent_sessions, + limitConcurrentUas: keyData.limit_concurrent_uas, providerGroup: keyData.provider_group ?? null, cacheTtlPreference: keyData.cache_ttl_preference ?? null, }; @@ -170,6 +174,7 @@ export async function createKey(keyData: CreateKeyData): Promise { limitMonthlyUsd: keys.limitMonthlyUsd, limitTotalUsd: keys.limitTotalUsd, limitConcurrentSessions: keys.limitConcurrentSessions, + limitConcurrentUas: keys.limitConcurrentUas, providerGroup: keys.providerGroup, cacheTtlPreference: keys.cacheTtlPreference, createdAt: keys.createdAt, @@ -239,6 +244,8 @@ export async function updateKey(id: number, keyData: UpdateKeyData): Promise { limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + limitConcurrentUas: providers.limitConcurrentUas, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, @@ -535,6 +539,7 @@ export async function findProviderById(id: number): Promise { limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + limitConcurrentUas: providers.limitConcurrentUas, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, @@ -635,6 +640,8 @@ export async function updateProvider( 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.limit_concurrent_uas !== undefined) + dbData.limitConcurrentUas = providerData.limit_concurrent_uas; if (providerData.max_retry_attempts !== undefined) dbData.maxRetryAttempts = providerData.max_retry_attempts; if (providerData.circuit_breaker_failure_threshold !== undefined) @@ -770,6 +777,7 @@ export async function updateProvider( limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + limitConcurrentUas: providers.limitConcurrentUas, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, @@ -1035,6 +1043,7 @@ export interface BatchProviderUpdates { limitMonthlyUsd?: string | null; limitTotalUsd?: string | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; // Circuit Breaker circuitBreakerFailureThreshold?: number; circuitBreakerOpenDuration?: number; @@ -1161,6 +1170,9 @@ export async function updateProvidersBatch( if (updates.limitConcurrentSessions !== undefined) { setClauses.limitConcurrentSessions = updates.limitConcurrentSessions; } + if (updates.limitConcurrentUas !== undefined) { + setClauses.limitConcurrentUas = updates.limitConcurrentUas; + } // Circuit Breaker if (updates.circuitBreakerFailureThreshold !== undefined) { setClauses.circuitBreakerFailureThreshold = updates.circuitBreakerFailureThreshold; diff --git a/src/repository/user.ts b/src/repository/user.ts index d031e6d48..a8a9fa089 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -54,6 +54,7 @@ export async function createUser(userData: CreateUserData): Promise { limitMonthlyUsd: userData.limitMonthlyUsd?.toString(), limitTotalUsd: userData.limitTotalUsd?.toString(), limitConcurrentSessions: userData.limitConcurrentSessions, + limitConcurrentUas: userData.limitConcurrentUas, dailyResetMode: userData.dailyResetMode ?? "fixed", dailyResetTime: userData.dailyResetTime ?? "00:00", isEnabled: userData.isEnabled ?? true, @@ -80,6 +81,7 @@ export async function createUser(userData: CreateUserData): Promise { limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + limitConcurrentUas: users.limitConcurrentUas, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, isEnabled: users.isEnabled, @@ -113,6 +115,7 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + limitConcurrentUas: users.limitConcurrentUas, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, isEnabled: users.isEnabled, @@ -359,6 +362,7 @@ export async function findUserListBatch( limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + limitConcurrentUas: users.limitConcurrentUas, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, isEnabled: users.isEnabled, @@ -417,6 +421,7 @@ export async function findUserById(id: number): Promise { limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + limitConcurrentUas: users.limitConcurrentUas, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, isEnabled: users.isEnabled, @@ -451,6 +456,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< limitMonthlyUsd?: string | null; limitTotalUsd?: string | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; isEnabled?: boolean; @@ -483,6 +489,8 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< userData.limitTotalUsd === null ? null : userData.limitTotalUsd.toString(); if (userData.limitConcurrentSessions !== undefined) dbData.limitConcurrentSessions = userData.limitConcurrentSessions; + if (userData.limitConcurrentUas !== undefined) + dbData.limitConcurrentUas = userData.limitConcurrentUas; if (userData.dailyResetMode !== undefined) dbData.dailyResetMode = userData.dailyResetMode; if (userData.dailyResetTime !== undefined) dbData.dailyResetTime = userData.dailyResetTime; if (userData.isEnabled !== undefined) dbData.isEnabled = userData.isEnabled; @@ -512,6 +520,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + limitConcurrentUas: users.limitConcurrentUas, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, isEnabled: users.isEnabled, diff --git a/src/types/key.ts b/src/types/key.ts index 10c824a89..3f2c0050e 100644 --- a/src/types/key.ts +++ b/src/types/key.ts @@ -22,8 +22,25 @@ export interface Key { limitWeeklyUsd: number | null; limitMonthlyUsd: number | null; limitTotalUsd?: number | null; + + /** + * Key 维度并发 Session 上限。 + * + * 语义: + * - `0`:不启用 Key 维度限制(仅受 User 维度限制/或无限制) + * - `>0`:启用 Key 维度限制 + */ limitConcurrentSessions: number; + /** + * Key 维度并发 UA 上限。 + * + * 语义: + * - `0`:不启用 Key 维度限制(仅受 User 维度限制/或无限制) + * - `>0`:启用 Key 维度限制 + */ + limitConcurrentUas: number; + // Provider group override (null = inherit from user) providerGroup: string | null; @@ -55,6 +72,7 @@ export interface CreateKeyData { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; + limit_concurrent_uas?: number; // Provider group override (null = inherit from user) provider_group?: string | null; @@ -80,6 +98,7 @@ export interface UpdateKeyData { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; + limit_concurrent_uas?: number; // Provider group override (null = inherit from user) provider_group?: string | null; diff --git a/src/types/message.ts b/src/types/message.ts index 21a6552a7..6d5469e2e 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -92,6 +92,10 @@ export interface ProviderChainItem { // 错误信息(记录失败时的上游报错) errorMessage?: string; + // 结构化错误码/参数(用于 i18n 渲染) + errorCode?: string; + errorParams?: Record; + // 结构化错误详情(便于格式化显示) errorDetails?: { // 供应商错误(HTTP 4xx/5xx) diff --git a/src/types/provider.ts b/src/types/provider.ts index e251ba1b1..7f7a47f73 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -92,6 +92,7 @@ export type ProviderBatchPatchField = | "limit_monthly_usd" | "limit_total_usd" | "limit_concurrent_sessions" + | "limit_concurrent_uas" // Circuit Breaker | "circuit_breaker_failure_threshold" | "circuit_breaker_open_duration" @@ -143,6 +144,7 @@ export interface ProviderBatchPatchDraft { limit_monthly_usd?: ProviderPatchDraftInput; limit_total_usd?: ProviderPatchDraftInput; limit_concurrent_sessions?: ProviderPatchDraftInput; + limit_concurrent_uas?: ProviderPatchDraftInput; // Circuit Breaker circuit_breaker_failure_threshold?: ProviderPatchDraftInput; circuit_breaker_open_duration?: ProviderPatchDraftInput; @@ -195,6 +197,7 @@ export interface ProviderBatchPatch { limit_monthly_usd: ProviderPatchOperation; limit_total_usd: ProviderPatchOperation; limit_concurrent_sessions: ProviderPatchOperation; + limit_concurrent_uas: ProviderPatchOperation; // Circuit Breaker circuit_breaker_failure_threshold: ProviderPatchOperation; circuit_breaker_open_duration: ProviderPatchOperation; @@ -247,6 +250,7 @@ export interface ProviderBatchApplyUpdates { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; + limit_concurrent_uas?: number; // Circuit Breaker circuit_breaker_failure_threshold?: number; circuit_breaker_open_duration?: number; @@ -332,6 +336,7 @@ export interface Provider { // 总消费重置时间:用于实现“达到总限额后手动重置用量” totalCostResetAt: Date | null; limitConcurrentSessions: number; + limitConcurrentUas: number; // 熔断器配置(每个供应商独立配置) maxRetryAttempts: number | null; @@ -430,6 +435,7 @@ export interface ProviderDisplay { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number; + limitConcurrentUas: number; // 熔断器配置 maxRetryAttempts: number | null; circuitBreakerFailureThreshold: number; @@ -522,6 +528,7 @@ export interface CreateProviderData { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; + limit_concurrent_uas?: number; // 熔断器配置 max_retry_attempts?: number | null; @@ -600,6 +607,7 @@ export interface UpdateProviderData { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; + limit_concurrent_uas?: number; // 熔断器配置 max_retry_attempts?: number | null; diff --git a/src/types/user.ts b/src/types/user.ts index 7a1307c76..4ab519008 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -19,6 +19,7 @@ export interface User { limitMonthlyUsd?: number; // 月消费上限(美元) limitTotalUsd?: number | null; // 总消费上限(美元) limitConcurrentSessions?: number; // 并发 Session 上限 + limitConcurrentUas?: number; // 并发 UA 上限 // Daily quota reset mode dailyResetMode: "fixed" | "rolling"; // 每日限额重置模式 dailyResetTime: string; // 每日重置时间 (HH:mm) @@ -48,6 +49,7 @@ export interface CreateUserData { limitMonthlyUsd?: number; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; // Daily quota reset mode dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; @@ -77,6 +79,7 @@ export interface UpdateUserData { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; // Daily quota reset mode dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; @@ -128,6 +131,7 @@ export interface UserKeyDisplay { limitMonthlyUsd: number | null; // 月消费上限(美元) limitTotalUsd?: number | null; // 总消费上限(美元) limitConcurrentSessions: number; // 并发 Session 上限 + limitConcurrentUas: number; // 并发 UA 上限 // Provider group override (null = inherit from user) providerGroup?: string | null; } @@ -151,6 +155,7 @@ export interface UserDisplay { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + limitConcurrentUas?: number | null; // Daily quota reset mode dailyResetMode?: "fixed" | "rolling"; dailyResetTime?: string; @@ -176,6 +181,7 @@ export interface KeyDialogUserContext { limitMonthlyUsd?: number; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + limitConcurrentUas?: number; allowedClients?: string[]; blockedClients?: string[]; // Blocked client patterns (blacklist, checked before allowedClients) allowedModels?: string[]; diff --git a/tests/unit/lib/rate-limit/concurrent-ua-limit.test.ts b/tests/unit/lib/rate-limit/concurrent-ua-limit.test.ts new file mode 100644 index 000000000..dac712d3d --- /dev/null +++ b/tests/unit/lib/rate-limit/concurrent-ua-limit.test.ts @@ -0,0 +1,130 @@ +import crypto from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + normalizeConcurrentUaLimit, + resolveConcurrentUaIdentity, + resolveKeyConcurrentUaLimit, + resolveKeyUserConcurrentUaLimits, +} from "@/lib/rate-limit/concurrent-ua-limit"; + +describe("resolveKeyConcurrentUaLimit", () => { + const cases: Array<{ + title: string; + keyLimit: number | null | undefined; + userLimit: number | null | undefined; + expected: number; + }> = [ + { title: "Key > 0 时应优先使用 Key", keyLimit: 10, userLimit: 15, expected: 10 }, + { title: "Key 为 0 时应回退到 User", keyLimit: 0, userLimit: 15, expected: 15 }, + { title: "Key 为 null 时应回退到 User", keyLimit: null, userLimit: 15, expected: 15 }, + { title: "Key 为 undefined 时应回退到 User", keyLimit: undefined, userLimit: 15, expected: 15 }, + { + title: "Key 为 NaN 时应回退到 User", + keyLimit: Number.NaN, + userLimit: 15, + expected: 15, + }, + { + title: "Key 为 Infinity 时应回退到 User", + keyLimit: Number.POSITIVE_INFINITY, + userLimit: 15, + expected: 15, + }, + { title: "Key < 0 时应回退到 User", keyLimit: -1, userLimit: 15, expected: 15 }, + { title: "Key 为小数时应向下取整", keyLimit: 5.9, userLimit: 15, expected: 5 }, + { title: "Key 小数 < 1 时应回退到 User", keyLimit: 0.9, userLimit: 15, expected: 15 }, + { title: "User 为小数时应向下取整", keyLimit: 0, userLimit: 7.8, expected: 7 }, + { + title: "Key 与 User 均未设置/无效时应返回 0(无限制)", + keyLimit: undefined, + userLimit: null, + expected: 0, + }, + { + title: "Key 为 0 且 User 为 Infinity 时应返回 0(无限制)", + keyLimit: 0, + userLimit: Number.POSITIVE_INFINITY, + expected: 0, + }, + ]; + + for (const testCase of cases) { + it(testCase.title, () => { + expect(resolveKeyConcurrentUaLimit(testCase.keyLimit, testCase.userLimit)).toBe( + testCase.expected + ); + }); + } +}); + +describe("normalizeConcurrentUaLimit", () => { + const cases: Array<{ title: string; input: number | null | undefined; expected: number }> = [ + { title: "null 应归一化为 0", input: null, expected: 0 }, + { title: "undefined 应归一化为 0", input: undefined, expected: 0 }, + { title: "0 应归一化为 0", input: 0, expected: 0 }, + { title: "负数应归一化为 0", input: -1, expected: 0 }, + { title: "NaN 应归一化为 0", input: Number.NaN, expected: 0 }, + { title: "Infinity 应归一化为 0", input: Number.POSITIVE_INFINITY, expected: 0 }, + { title: "正整数应保持不变", input: 15, expected: 15 }, + { title: "小数应向下取整", input: 7.9, expected: 7 }, + { title: "小数 < 1 应向下取整为 0", input: 0.9, expected: 0 }, + ]; + + for (const testCase of cases) { + it(testCase.title, () => { + expect(normalizeConcurrentUaLimit(testCase.input)).toBe(testCase.expected); + }); + } +}); + +describe("resolveKeyUserConcurrentUaLimits", () => { + it("Key 未设置且 User 已设置时:effectiveKeyLimit 应继承 User,且 enabled=true", () => { + const result = resolveKeyUserConcurrentUaLimits(0, 15); + expect(result).toEqual({ effectiveKeyLimit: 15, normalizedUserLimit: 15, enabled: true }); + }); + + it("Key 已设置且 User 已设置时:Key 优先,User 保留为 normalizedUserLimit", () => { + const result = resolveKeyUserConcurrentUaLimits(10, 15); + expect(result).toEqual({ effectiveKeyLimit: 10, normalizedUserLimit: 15, enabled: true }); + }); + + it("Key/User 均未设置时:enabled=false", () => { + const result = resolveKeyUserConcurrentUaLimits(0, null); + expect(result).toEqual({ effectiveKeyLimit: 0, normalizedUserLimit: 0, enabled: false }); + }); +}); + +describe("resolveConcurrentUaIdentity", () => { + it("应优先使用解析出的 clientType 作为 bucket(且 sha256 稳定)", () => { + const uaV1 = "claude-cli/2.0.31 (external, cli)"; + const uaV2 = "claude-cli/2.0.32 (external, cli)"; + + const idV1 = resolveConcurrentUaIdentity(uaV1); + const idV2 = resolveConcurrentUaIdentity(uaV2); + + expect(idV1.bucket).toBe("claude-cli"); + expect(idV2.bucket).toBe("claude-cli"); + expect(idV1.id).toBe(idV2.id); + }); + + it("应在解析失败时回退到原始 UA(trim 后)", () => { + const raw = " Mozilla/5.0 "; + const result = resolveConcurrentUaIdentity(raw); + expect(result.bucket).toBe("Mozilla/5.0"); + }); + + it("当 UA 为空时 bucket 应为 unknown", () => { + const result = resolveConcurrentUaIdentity(null); + expect(result.bucket).toBe("unknown"); + }); + + it("应返回 ua:sha256(bucket) 的 member 形式", () => { + const ua = "anthropic-sdk-typescript/1.0.0"; + const { bucket, id } = resolveConcurrentUaIdentity(ua); + + const expectedId = `ua:${crypto.createHash("sha256").update(bucket, "utf8").digest("hex")}`; + expect(id).toBe(expectedId); + expect(id.startsWith("ua:")).toBe(true); + expect(id.length).toBe(3 + 64); + }); +}); diff --git a/tests/unit/lib/rate-limit/service-extra.test.ts b/tests/unit/lib/rate-limit/service-extra.test.ts index 2de1b381b..8e36be3fa 100644 --- a/tests/unit/lib/rate-limit/service-extra.test.ts +++ b/tests/unit/lib/rate-limit/service-extra.test.ts @@ -190,6 +190,105 @@ describe("RateLimitService - other quota paths", () => { expect(ttlMsArg).toBe("300000"); }); + it("checkAndTrackProviderUa:limit<=0 时应放行且不追踪", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + const result = await RateLimitService.checkAndTrackProviderUa(9, "ua", 0); + expect(result).toEqual({ allowed: true, count: 0, tracked: false }); + }); + + it("checkAndTrackProviderUa:Redis 非 ready 时应 Fail Open", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.status = "end"; + const result = await RateLimitService.checkAndTrackProviderUa(9, "ua", 2); + expect(result).toEqual({ allowed: true, count: 0, tracked: false }); + }); + + it("checkAndTrackProviderUa:达到上限时应返回 not allowed", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([0, 2, 0]); + const result = await RateLimitService.checkAndTrackProviderUa(9, "ua", 2); + expect(result).toEqual({ + allowed: false, + count: 2, + tracked: false, + reasonCode: "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED", + reasonParams: { current: 2, limit: 2, target: "provider" }, + }); + }); + + it("checkAndTrackProviderUa:未达到上限时应返回 allowed 且可标记 tracked", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([1, 1, 1]); + const result = await RateLimitService.checkAndTrackProviderUa(9, "ua", 2); + expect(result).toEqual({ + allowed: true, + count: 1, + tracked: true, + trackedAtMs: expect.any(Number), + }); + }); + + it("checkAndTrackProviderUa: should pass SESSION_TTL_MS as ARGV[4] to Lua script", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([1, 1, 1]); + await RateLimitService.checkAndTrackProviderUa(9, "ua", 2); + + expect(redisClientRef.eval).toHaveBeenCalledTimes(1); + + const evalCall = redisClientRef.eval.mock.calls[0]; + expect(evalCall.length).toBe(7); + + const ttlMsArg = evalCall[6]; + expect(ttlMsArg).toBe("300000"); + }); + + it("untrackProviderUa: should remove member when score matches", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + const { UNTRACK_ZSET_MEMBER_IF_SCORE_MATCH } = await import("@/lib/redis/lua-scripts"); + + redisClientRef.eval.mockResolvedValueOnce(1); + const ok = await RateLimitService.untrackProviderUa(9, "ua", 123); + expect(ok).toBe(true); + + expect(redisClientRef.eval).toHaveBeenCalledWith( + UNTRACK_ZSET_MEMBER_IF_SCORE_MATCH, + 1, + "{active_uas}:provider:9:active_uas", + "ua", + "123" + ); + }); + + it("untrackProviderUa: should support unconditional removal when expected score omitted", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + const { UNTRACK_ZSET_MEMBER_IF_SCORE_MATCH } = await import("@/lib/redis/lua-scripts"); + + redisClientRef.eval.mockResolvedValueOnce(1); + const ok = await RateLimitService.untrackProviderUa(9, "ua"); + expect(ok).toBe(true); + + expect(redisClientRef.eval).toHaveBeenCalledWith( + UNTRACK_ZSET_MEMBER_IF_SCORE_MATCH, + 1, + "{active_uas}:provider:9:active_uas", + "ua", + "" + ); + }); + + it("untrackProviderUa: should return false when member not removed", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce(0); + const ok = await RateLimitService.untrackProviderUa(9, "ua", 123); + expect(ok).toBe(false); + }); + it("checkAndTrackKeyUserSession:keyLimit/userLimit 均 <=0 时应放行且不追踪", async () => { const { RateLimitService } = await import("@/lib/rate-limit"); @@ -271,6 +370,85 @@ describe("RateLimitService - other quota paths", () => { expect(ttlMsArg).toBe("300000"); }); + it("checkAndTrackKeyUserUa:keyLimit/userLimit 均 <=0 时应放行且不追踪", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + const result = await RateLimitService.checkAndTrackKeyUserUa(2, 1, "ua", 0, 0); + expect(result).toEqual({ + allowed: true, + keyCount: 0, + userCount: 0, + trackedKey: false, + trackedUser: false, + }); + expect(redisClientRef.eval).not.toHaveBeenCalled(); + }); + + it("checkAndTrackKeyUserUa:Redis 非 ready 时应 Fail Open", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.status = "end"; + const result = await RateLimitService.checkAndTrackKeyUserUa(2, 1, "ua", 2, 2); + expect(result).toEqual({ + allowed: true, + keyCount: 0, + userCount: 0, + trackedKey: false, + trackedUser: false, + }); + }); + + it("checkAndTrackKeyUserUa:Key 超限时应返回 not allowed", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([0, 1, 2, 0, 1, 0]); + const result = await RateLimitService.checkAndTrackKeyUserUa(2, 1, "ua", 2, 10); + expect(result.allowed).toBe(false); + expect(result.rejectedBy).toBe("key"); + expect(result.reasonCode).toBe("RATE_LIMIT_CONCURRENT_UAS_EXCEEDED"); + expect(result.reasonParams).toEqual({ current: 2, limit: 2, target: "key" }); + }); + + it("checkAndTrackKeyUserUa:User 超限时应返回 not allowed", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([0, 2, 1, 0, 2, 0]); + const result = await RateLimitService.checkAndTrackKeyUserUa(2, 1, "ua", 10, 2); + expect(result.allowed).toBe(false); + expect(result.rejectedBy).toBe("user"); + expect(result.reasonCode).toBe("RATE_LIMIT_CONCURRENT_UAS_EXCEEDED"); + expect(result.reasonParams).toEqual({ current: 2, limit: 2, target: "user" }); + }); + + it("checkAndTrackKeyUserUa:未超限时应返回 allowed 且可标记 tracked", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([1, 0, 2, 1, 2, 1]); + const result = await RateLimitService.checkAndTrackKeyUserUa(2, 1, "ua", 2, 2); + expect(result).toEqual({ + allowed: true, + keyCount: 2, + userCount: 2, + trackedKey: true, + trackedUser: true, + }); + }); + + it("checkAndTrackKeyUserUa: should pass SESSION_TTL_MS as ARGV[5] to Lua script", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + redisClientRef.eval.mockResolvedValueOnce([1, 0, 1, 1, 1, 1]); + await RateLimitService.checkAndTrackKeyUserUa(2, 1, "ua", 2, 2); + + expect(redisClientRef.eval).toHaveBeenCalledTimes(1); + + const evalCall = redisClientRef.eval.mock.calls[0]; + expect(evalCall.length).toBe(10); + + const ttlMsArg = evalCall[9]; + expect(ttlMsArg).toBe("300000"); + }); + it("trackUserDailyCost:fixed 模式应使用 STRING + TTL", async () => { const { RateLimitService } = await import("@/lib/rate-limit"); diff --git a/tests/unit/proxy/provider-selector-concurrent-ua-fallback.test.ts b/tests/unit/proxy/provider-selector-concurrent-ua-fallback.test.ts new file mode 100644 index 000000000..af5dff469 --- /dev/null +++ b/tests/unit/proxy/provider-selector-concurrent-ua-fallback.test.ts @@ -0,0 +1,248 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Provider } from "@/types/provider"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; + +const circuitBreakerMocks = vi.hoisted(() => ({ + isCircuitOpen: vi.fn(async () => false), + getCircuitState: vi.fn(() => "closed"), +})); + +vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +const rateLimitMocks = vi.hoisted(() => ({ + RateLimitService: { + checkAndTrackProviderUa: vi.fn(async () => ({ allowed: true, count: 0, tracked: false })), + checkAndTrackProviderSession: vi.fn(async () => ({ allowed: true, count: 0, tracked: false })), + untrackProviderUa: vi.fn(async () => true), + checkCostLimitsWithLease: vi.fn(async () => ({ allowed: true })), + checkTotalCostLimit: vi.fn(async () => ({ allowed: true, current: 0 })), + }, +})); + +vi.mock("@/lib/rate-limit", () => rateLimitMocks); + +const sessionManagerMocks = vi.hoisted(() => ({ + SessionManager: { + getSessionProvider: vi.fn(async () => null as number | null), + clearSessionProvider: vi.fn(async () => undefined), + }, +})); + +vi.mock("@/lib/session-manager", () => sessionManagerMocks); + +vi.mock("@/repository/provider", () => ({ + findAllProviders: vi.fn(async () => []), + findProviderById: vi.fn(async () => null), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(async () => ({ verboseProviderError: false })), +})); + +vi.mock("@/lib/utils/provider-schedule", () => ({ + isProviderActiveNow: vi.fn(() => true), +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ + isVendorTypeCircuitOpen: vi.fn(async () => false), +})); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("ProxyProviderResolver.ensure - concurrent UA fallback", () => { + test("供应商并发 UA 超限时应回退到下一个供应商,且不先执行并发 Session 追踪", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + const provider1 = { + id: 1, + name: "p1", + providerType: "claude", + groupTag: null, + weight: 1, + priority: 0, + costMultiplier: 1, + isEnabled: true, + limitConcurrentSessions: 10, + limitConcurrentUas: 1, + } as unknown as Provider; + + const provider2 = { + ...provider1, + id: 2, + name: "p2", + limitConcurrentUas: 1, + } as unknown as Provider; + + const excludeSnapshots: number[][] = []; + const pickRandomProviderMock = vi + .spyOn(ProxyProviderResolver as any, "pickRandomProvider") + .mockImplementationOnce(async (_session: unknown, excludeIds: number[] = []) => { + excludeSnapshots.push([...excludeIds]); + return { provider: provider1, context: { groupFilterApplied: false } }; + }) + .mockImplementationOnce(async (_session: unknown, excludeIds: number[] = []) => { + excludeSnapshots.push([...excludeIds]); + return { provider: provider2, context: { groupFilterApplied: false } }; + }); + + rateLimitMocks.RateLimitService.checkAndTrackProviderUa + .mockResolvedValueOnce({ + allowed: false, + count: 1, + tracked: false, + reasonCode: "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED", + reasonParams: { current: 1, limit: 1 }, + }) + .mockResolvedValueOnce({ allowed: true, count: 1, tracked: true }); + + rateLimitMocks.RateLimitService.checkAndTrackProviderSession.mockResolvedValueOnce({ + allowed: true, + count: 1, + tracked: true, + }); + + const providerChain: Array[1]> = []; + let lastContext: ReturnType; + + const session: Partial = { + sessionId: "s1", + userAgent: "claude-cli/2.0.32 (external, cli)", + authState: null, + provider: null, + shouldReuseProvider: () => false, + getOriginalModel: () => null, + getCurrentModel: () => null, + setProvider: (p: Provider | null) => { + session.provider = p; + }, + setLastSelectionContext: (ctx: Parameters[0]) => { + lastContext = ctx; + }, + getLastSelectionContext: () => lastContext, + addProviderToChain: ( + _provider: Provider, + item?: Parameters[1] + ) => { + providerChain.push(item); + }, + }; + + await expect(ProxyProviderResolver.ensure(session as ProxySession)).resolves.toBeNull(); + expect(session.provider?.id).toBe(2); + + expect(pickRandomProviderMock).toHaveBeenCalledTimes(2); + expect(excludeSnapshots).toEqual([[], [1]]); + + expect(rateLimitMocks.RateLimitService.checkAndTrackProviderUa).toHaveBeenCalledTimes(2); + const uaCall1 = rateLimitMocks.RateLimitService.checkAndTrackProviderUa.mock.calls[0]!; + const uaCall2 = rateLimitMocks.RateLimitService.checkAndTrackProviderUa.mock.calls[1]!; + expect(uaCall1[0]).toBe(1); + expect(uaCall2[0]).toBe(2); + expect(uaCall1[1]).toBe(uaCall2[1]); // UA 标识应稳定 + expect(uaCall1[2]).toBe(1); + + expect(rateLimitMocks.RateLimitService.checkAndTrackProviderSession).toHaveBeenCalledTimes(1); + expect(rateLimitMocks.RateLimitService.checkAndTrackProviderSession).toHaveBeenCalledWith( + 2, + "s1", + 10 + ); + + pickRandomProviderMock.mockRestore(); + expect(providerChain.length).toBeGreaterThan(0); + }); + + test("供应商并发 Session 超限触发回退时,应回滚本次新增的 Provider UA 追踪", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + const provider1 = { + id: 1, + name: "p1", + providerType: "claude", + groupTag: null, + weight: 1, + priority: 0, + costMultiplier: 1, + isEnabled: true, + limitConcurrentSessions: 1, + limitConcurrentUas: 10, + } as unknown as Provider; + + const provider2 = { + ...provider1, + id: 2, + name: "p2", + } as unknown as Provider; + + const excludeSnapshots: number[][] = []; + const pickRandomProviderMock = vi + .spyOn(ProxyProviderResolver as any, "pickRandomProvider") + .mockImplementationOnce(async (_session: unknown, excludeIds: number[] = []) => { + excludeSnapshots.push([...excludeIds]); + return { provider: provider1, context: { groupFilterApplied: false } }; + }) + .mockImplementationOnce(async (_session: unknown, excludeIds: number[] = []) => { + excludeSnapshots.push([...excludeIds]); + return { provider: provider2, context: { groupFilterApplied: false } }; + }); + + rateLimitMocks.RateLimitService.checkAndTrackProviderUa + .mockResolvedValueOnce({ allowed: true, count: 1, tracked: true, trackedAtMs: 123 }) + .mockResolvedValueOnce({ allowed: true, count: 1, tracked: false }); + + rateLimitMocks.RateLimitService.checkAndTrackProviderSession + .mockResolvedValueOnce({ + allowed: false, + count: 1, + tracked: false, + reason: "供应商并发 Session 上限已达到(1/1)", + }) + .mockResolvedValueOnce({ allowed: true, count: 1, tracked: true }); + + const session: Partial = { + sessionId: "s1", + userAgent: "claude-cli/2.0.32 (external, cli)", + authState: null, + provider: null, + shouldReuseProvider: () => false, + getOriginalModel: () => null, + getCurrentModel: () => null, + setProvider: (p: Provider | null) => { + session.provider = p; + }, + setLastSelectionContext: () => undefined, + getLastSelectionContext: () => undefined, + addProviderToChain: () => undefined, + }; + + await expect(ProxyProviderResolver.ensure(session as ProxySession)).resolves.toBeNull(); + expect(session.provider?.id).toBe(2); + + expect(pickRandomProviderMock).toHaveBeenCalledTimes(2); + expect(excludeSnapshots).toEqual([[], [1]]); + + const uaId = rateLimitMocks.RateLimitService.checkAndTrackProviderUa.mock.calls[0]?.[1]; + expect(uaId).toBeTypeOf("string"); + + expect(rateLimitMocks.RateLimitService.untrackProviderUa).toHaveBeenCalledTimes(1); + expect(rateLimitMocks.RateLimitService.untrackProviderUa).toHaveBeenCalledWith(1, uaId, 123); + + pickRandomProviderMock.mockRestore(); + }); +}); diff --git a/tests/unit/proxy/rate-limit-guard.test.ts b/tests/unit/proxy/rate-limit-guard.test.ts index 176c4cc98..bb0f07501 100644 --- a/tests/unit/proxy/rate-limit-guard.test.ts +++ b/tests/unit/proxy/rate-limit-guard.test.ts @@ -4,6 +4,7 @@ const generateSessionIdMock = vi.hoisted(() => vi.fn(() => "sess_generated")); const rateLimitServiceMock = { checkTotalCostLimit: vi.fn(), + checkAndTrackKeyUserUa: vi.fn(), checkAndTrackKeyUserSession: vi.fn(), checkRpmLimit: vi.fn(), checkCostLimitsWithLease: vi.fn(), @@ -38,6 +39,7 @@ const getErrorMessageServerMock = vi.fn(async () => "mock rate limit message"); vi.mock("@/lib/utils/error-messages", () => ({ ERROR_CODES: { RATE_LIMIT_TOTAL_EXCEEDED: "RATE_LIMIT_TOTAL_EXCEEDED", + RATE_LIMIT_CONCURRENT_UAS_EXCEEDED: "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED", RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED: "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED", RATE_LIMIT_RPM_EXCEEDED: "RATE_LIMIT_RPM_EXCEEDED", RATE_LIMIT_DAILY_QUOTA_EXCEEDED: "RATE_LIMIT_DAILY_QUOTA_EXCEEDED", @@ -60,6 +62,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { limitWeeklyUsd: number | null; limitMonthlyUsd: number | null; limitTotalUsd: number | null; + limitConcurrentUas: number | null; limitConcurrentSessions: number | null; }>; key?: Partial<{ @@ -72,11 +75,13 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { limitWeeklyUsd: number | null; limitMonthlyUsd: number | null; limitTotalUsd: number | null; + limitConcurrentUas: number; limitConcurrentSessions: number; }>; }) => { const session = { sessionId: "sess_test", + userAgent: "claude-cli/2.0.31 (external, cli)", authState: { user: { id: 1, @@ -88,6 +93,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { limitWeeklyUsd: null, limitMonthlyUsd: null, limitTotalUsd: null, + limitConcurrentUas: null, limitConcurrentSessions: null, ...overrides?.user, }, @@ -101,6 +107,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { limitWeeklyUsd: null, limitMonthlyUsd: null, limitTotalUsd: null, + limitConcurrentUas: 0, limitConcurrentSessions: 0, ...overrides?.key, }, @@ -119,6 +126,13 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { generateSessionIdMock.mockReturnValue("sess_generated"); rateLimitServiceMock.checkTotalCostLimit.mockResolvedValue({ allowed: true }); + rateLimitServiceMock.checkAndTrackKeyUserUa.mockResolvedValue({ + allowed: true, + keyCount: 0, + userCount: 0, + trackedKey: false, + trackedUser: false, + }); rateLimitServiceMock.checkAndTrackKeyUserSession.mockResolvedValue({ allowed: true, keyCount: 0, @@ -319,6 +333,78 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { }); }); + it("Key 并发 UA 超限应拦截(concurrent_uas)", async () => { + const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard"); + + rateLimitServiceMock.checkAndTrackKeyUserUa.mockResolvedValueOnce({ + allowed: false, + rejectedBy: "key", + reasonCode: "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED", + reasonParams: { current: 2, limit: 1, target: "key" }, + keyCount: 2, + userCount: 0, + trackedKey: false, + trackedUser: false, + }); + + const session = createSession({ + key: { limitConcurrentUas: 1 }, + }); + + await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({ + name: "RateLimitError", + limitType: "concurrent_uas", + currentUsage: 2, + limitValue: 1, + }); + }); + + it("User 并发 UA 超限应拦截(concurrent_uas)", async () => { + const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard"); + + rateLimitServiceMock.checkAndTrackKeyUserUa.mockResolvedValueOnce({ + allowed: false, + rejectedBy: "user", + reasonCode: "RATE_LIMIT_CONCURRENT_UAS_EXCEEDED", + reasonParams: { current: 2, limit: 1, target: "user" }, + keyCount: 0, + userCount: 2, + trackedKey: false, + trackedUser: false, + }); + + const session = createSession({ + user: { limitConcurrentUas: 1 }, + key: { limitConcurrentUas: 10 }, + }); + + await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({ + name: "RateLimitError", + limitType: "concurrent_uas", + currentUsage: 2, + limitValue: 1, + }); + }); + + it("当 Key 并发 UA 未设置(0)且 User 并发 UA 已设置时,Key 并发 UA 检查应继承 User 并发上限", async () => { + const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard"); + + const session = createSession({ + user: { limitConcurrentUas: 15 }, + key: { limitConcurrentUas: 0 }, + }); + + await expect(ProxyRateLimitGuard.ensure(session)).resolves.toBeUndefined(); + + expect(rateLimitServiceMock.checkAndTrackKeyUserUa).toHaveBeenCalledWith( + 2, + 1, + expect.any(String), + 15, + 15 + ); + }); + it("当 Key 并发未设置(0)且 User 并发已设置时,Key 并发检查应继承 User 并发上限", async () => { const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");