diff --git a/drizzle/0055_normal_johnny_storm.sql b/drizzle/0055_normal_johnny_storm.sql new file mode 100644 index 00000000..8b3b7b6c --- /dev/null +++ b/drizzle/0055_normal_johnny_storm.sql @@ -0,0 +1 @@ +ALTER TABLE "providers" ADD COLUMN "session_ttl" integer; \ No newline at end of file diff --git a/drizzle/meta/0055_snapshot.json b/drizzle/meta/0055_snapshot.json new file mode 100644 index 00000000..e9c02900 --- /dev/null +++ b/drizzle/meta/0055_snapshot.json @@ -0,0 +1,2394 @@ +{ + "id": "0b89227c-2d84-4c9e-9969-c30b9fd66de4", + "prevId": "36887729-08df-4af3-98fe-d4fa87c7c5c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(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_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "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_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_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_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "session_ttl": { + "name": "session_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_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_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" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(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 + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 106e4311..d3520f80 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1768240715707, "tag": "0054_tidy_winter_soldier", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1768375356201, + "tag": "0055_normal_johnny_storm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/settings/providers/form/errors.json b/messages/en/settings/providers/form/errors.json index addcd8c2..88879be8 100644 --- a/messages/en/settings/providers/form/errors.json +++ b/messages/en/settings/providers/form/errors.json @@ -4,5 +4,6 @@ "groupTagTooLong": "Provider group tags are too long (max {max} chars total)", "invalidUrl": "Please enter a valid API address", "invalidWebsiteUrl": "Please enter a valid provider website URL", + "sessionTtlOutOfRange": "Session TTL must be between {min} and {max} seconds", "updateFailed": "Failed to update provider" } diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index c3f6ad64..166f230f 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -279,6 +279,11 @@ "placeholder": "1" } }, + "sessionTtl": { + "desc": "Provider-specific session TTL. Overrides global SESSION_TTL. Range: 60-3600 seconds. Leave empty to use global setting.", + "label": "Session TTL (seconds)", + "placeholder": "Leave empty to use global setting" + }, "summary": { "models": "{count} whitelisted models", "none": "Not configured", diff --git a/messages/ja/settings/providers/form/errors.json b/messages/ja/settings/providers/form/errors.json index 50c85bcf..6bd3fea1 100644 --- a/messages/ja/settings/providers/form/errors.json +++ b/messages/ja/settings/providers/form/errors.json @@ -4,5 +4,6 @@ "groupTagTooLong": "プロバイダーグループが長すぎます(合計{max}文字まで)", "invalidUrl": "有効な API アドレスを入力してください", "invalidWebsiteUrl": "有効な公式サイト URL を入力してください", + "sessionTtlOutOfRange": "セッションTTLは{min}〜{max}秒の範囲で入力してください", "updateFailed": "プロバイダーの更新に失敗しました" } diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 40edf78d..2ae3ea0c 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -174,6 +174,11 @@ "inherit": "オーバーライドしない(クライアントに従う)" } }, + "sessionTtl": { + "desc": "プロバイダー単位のセッションTTL(秒)。グローバル SESSION_TTL を上書きします。範囲: 60〜3600 秒。空欄でグローバル設定を使用します。", + "label": "セッションTTL(秒)", + "placeholder": "空欄でグローバル設定" + }, "codexOverrides": { "parallelToolCalls": { "help": "並列の tool calls を許可するかどうかを制御します。「クライアントに従う」は parallel_tool_calls を変更しません。無効化すると並列度が下がる可能性があります。", diff --git a/messages/ru/settings/providers/form/errors.json b/messages/ru/settings/providers/form/errors.json index fb82e35b..beb665ef 100644 --- a/messages/ru/settings/providers/form/errors.json +++ b/messages/ru/settings/providers/form/errors.json @@ -4,5 +4,6 @@ "groupTagTooLong": "Список групп провайдера слишком длинный (макс. {max} символов всего)", "invalidUrl": "Введите корректный адрес API", "invalidWebsiteUrl": "Введите корректный адрес сайта провайдера", + "sessionTtlOutOfRange": "TTL сессии должен быть в диапазоне {min}–{max} секунд", "updateFailed": "Не удалось обновить провайдера" } diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 6da02eba..2e396985 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -174,6 +174,11 @@ "inherit": "Не переопределять (следовать клиенту)" } }, + "sessionTtl": { + "desc": "TTL сессии для конкретного провайдера. Переопределяет глобальный SESSION_TTL. Диапазон: 60–3600 секунд. Оставьте пустым, чтобы использовать глобальную настройку.", + "label": "TTL сессии (секунды)", + "placeholder": "Пусто — глобальная настройка" + }, "codexOverrides": { "parallelToolCalls": { "help": "Управляет тем, разрешены ли параллельные вызовы инструментов. \"inherit\" следует запросу клиента. Отключение может снизить параллельность вызовов инструментов.", diff --git a/messages/zh-CN/settings/providers/form/errors.json b/messages/zh-CN/settings/providers/form/errors.json index 9b38331b..35513a2d 100644 --- a/messages/zh-CN/settings/providers/form/errors.json +++ b/messages/zh-CN/settings/providers/form/errors.json @@ -3,6 +3,7 @@ "invalidWebsiteUrl": "请输入有效的供应商官网地址", "groupTagTooLong": "分组标签总长度不能超过 {max} 个字符", "addFailed": "添加服务商失败", + "sessionTtlOutOfRange": "Session TTL 必须在 {min} 到 {max} 秒之间", "updateFailed": "更新服务商失败", "deleteFailed": "删除服务商失败" } diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index 709ee56e..d8629ada 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -68,6 +68,11 @@ }, "desc": "强制设置 prompt cache TTL;仅影响包含 cache_control 的请求。" }, + "sessionTtl": { + "label": "Session TTL(秒)", + "placeholder": "留空使用全局配置", + "desc": "供应商级 Session TTL(秒)。会覆写全局 SESSION_TTL。范围:60-3600 秒。留空则使用全局配置。" + }, "context1m": { "label": "1M 上下文窗口", "options": { diff --git a/messages/zh-TW/settings/providers/form/errors.json b/messages/zh-TW/settings/providers/form/errors.json index acb3182b..b35dc11b 100644 --- a/messages/zh-TW/settings/providers/form/errors.json +++ b/messages/zh-TW/settings/providers/form/errors.json @@ -1,6 +1,7 @@ { "addFailed": "新增供應商失敗", "deleteFailed": "刪除供應商失敗", + "sessionTtlOutOfRange": "Session TTL 必須介於 {min} 到 {max} 秒之間", "groupTagTooLong": "分組標籤總長度不能超過 {max} 個字元", "invalidUrl": "請輸入有效的 API 位址", "invalidWebsiteUrl": "請輸入有效的供應商官網", diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 8ac597e6..79d31a68 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -174,6 +174,11 @@ "inherit": "不覆寫(跟隨客戶端)" } }, + "sessionTtl": { + "desc": "供應商級 Session TTL(秒)。會覆寫全域 SESSION_TTL。範圍:60-3600 秒。留空則使用全域設定。", + "label": "Session TTL(秒)", + "placeholder": "留空使用全域設定" + }, "codexOverrides": { "parallelToolCalls": { "help": "控制是否允許並行 tool calls。關閉可能降低工具呼叫並發能力;「跟隨客戶端」不改寫 parallel_tool_calls。", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 3a9d7382..1c961299 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -251,6 +251,7 @@ export async function getProviders(): Promise { limitMonthlyUsd: provider.limitMonthlyUsd, limitTotalUsd: provider.limitTotalUsd, limitConcurrentSessions: provider.limitConcurrentSessions, + sessionTtl: provider.sessionTtl, maxRetryAttempts: provider.maxRetryAttempts, circuitBreakerFailureThreshold: provider.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: provider.circuitBreakerOpenDuration, @@ -449,6 +450,7 @@ export async function addProvider(data: { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; + session_ttl?: number | null; cache_ttl_preference?: CacheTtlPreference | null; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; @@ -616,6 +618,7 @@ export async function editProvider( limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; + session_ttl?: number | null; cache_ttl_preference?: "inherit" | "5m" | "1h"; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx index 3deabfac..0758f8a7 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx @@ -172,6 +172,9 @@ export function ProviderForm({ const [cacheTtlPreference, setCacheTtlPreference] = useState<"inherit" | "5m" | "1h">( sourceProvider?.cacheTtlPreference ?? "inherit" ); + const [sessionTtlSeconds, setSessionTtlSeconds] = useState( + sourceProvider?.sessionTtl ?? null + ); // 1M Context Window 偏好配置(仅对 Anthropic 类型供应商有效) const [context1mPreference, setContext1mPreference] = useState< @@ -377,6 +380,11 @@ export function ProviderForm({ return; } + if (sessionTtlSeconds !== null && (sessionTtlSeconds < 60 || sessionTtlSeconds > 3600)) { + toast.error(t("errors.sessionTtlOutOfRange", { min: 60, max: 3600 })); + return; + } + // 正常提交 performSubmit(); }; @@ -418,6 +426,7 @@ export function ProviderForm({ limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; + session_ttl?: number | null; cache_ttl_preference?: "inherit" | "5m" | "1h"; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; @@ -461,6 +470,7 @@ export function ProviderForm({ limit_monthly_usd: limitMonthlyUsd, limit_total_usd: limitTotalUsd, limit_concurrent_sessions: limitConcurrentSessions ?? 0, + session_ttl: sessionTtlSeconds, cache_ttl_preference: cacheTtlPreference, context_1m_preference: context1mPreference, codex_reasoning_effort_preference: codexReasoningEffortPreference, @@ -526,6 +536,7 @@ export function ProviderForm({ limit_monthly_usd: limitMonthlyUsd, limit_total_usd: limitTotalUsd, limit_concurrent_sessions: limitConcurrentSessions ?? 0, + session_ttl: sessionTtlSeconds, cache_ttl_preference: cacheTtlPreference, context_1m_preference: context1mPreference, codex_reasoning_effort_preference: codexReasoningEffortPreference, @@ -588,6 +599,7 @@ export function ProviderForm({ setLimitMonthlyUsd(null); setLimitTotalUsd(null); setLimitConcurrentSessions(null); + setSessionTtlSeconds(null); setMaxRetryAttempts(null); setFailureThreshold(5); setOpenDurationMinutes(30); @@ -1054,6 +1066,34 @@ export function ProviderForm({

+
+ + { + const val = e.target.value; + if (!val) { + setSessionTtlSeconds(null); + } else { + const num = parseInt(val, 10); + setSessionTtlSeconds(Number.isNaN(num) ? null : num); + } + }} + disabled={isPending} + /> +

+ {t("sections.routing.sessionTtl.desc")} +

+
+ {/* 1M Context Window 配置 - 仅 Anthropic 类型供应商显示 */} {(providerType === "claude" || providerType === "claude-auth") && (
diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index fc18c9be..d4c4e9fc 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -317,7 +317,8 @@ export class ProxyForwarder { currentProvider.id, currentProvider.priority || 0, totalProvidersAttempted === 1 && attemptCount === 1, // isFirstAttempt - totalProvidersAttempted > 1 // isFailoverSuccess: 切换过供应商 + totalProvidersAttempted > 1, // isFailoverSuccess: 切换过供应商 + currentProvider.sessionTtl ); if (result.updated) { diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 0af01960..d6c55125 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -323,7 +323,8 @@ export class ProxyResponseHandler { void SessionManager.updateSessionWithCodexCacheKey( session.sessionId, promptCacheKey, - provider.id + provider.id, + provider.sessionTtl ).catch((err) => { logger.error("[ResponseHandler] Failed to update Codex session:", err); }); @@ -902,7 +903,8 @@ export class ProxyResponseHandler { void SessionManager.updateSessionWithCodexCacheKey( session.sessionId, promptCacheKey, - provider.id + provider.id, + provider.sessionTtl ).catch((err) => { logger.error("[ResponseHandler] Failed to update Codex session (stream):", err); }); @@ -1897,6 +1899,8 @@ export async function finalizeRequestStats( async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | null): Promise { if (!usage || !session.sessionId) return; + const sessionId = session.sessionId; + try { const messageContext = session.messageContext; const provider = session.provider; @@ -1951,9 +1955,13 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul ); // 刷新 session 时间戳(滑动窗口) - void SessionTracker.refreshSession(session.sessionId, key.id, provider.id).catch((error) => { - logger.error("[ResponseHandler] Failed to refresh session tracker:", error); - }); + void SessionManager.resolveSessionTtlForSession(sessionId) + .then((ttlSeconds) => + SessionTracker.refreshSession(sessionId, key.id, provider.id, ttlSeconds) + ) + .catch((error) => { + logger.error("[ResponseHandler] Failed to refresh session tracker:", error); + }); } catch (error) { logger.error("[ResponseHandler] Failed to track cost to Redis, skipping", { error: error instanceof Error ? error.message : String(error), diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index aecad190..09b26161 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -204,6 +204,8 @@ export const providers = pgTable('providers', { totalCostResetAt: timestamp('total_cost_reset_at', { withTimezone: true }), limitConcurrentSessions: integer('limit_concurrent_sessions').default(0), + sessionTtl: integer("session_ttl"), + // 熔断器配置(每个供应商独立配置) // null = 使用全局默认值 (env.MAX_RETRY_ATTEMPTS_DEFAULT 或 2) maxRetryAttempts: integer('max_retry_attempts'), diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index 7633e3ac..7d868d11 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -87,6 +87,51 @@ export class SessionManager { private static readonly ENABLE_SHORT_CONTEXT_DETECTION = process.env.ENABLE_SHORT_CONTEXT_DETECTION !== "false"; // 默认启用 + private static getDefaultSessionTtlSeconds(): number { + const parsed = SessionManager.SESSION_TTL; + if (!Number.isFinite(parsed) || parsed <= 0) { + return 300; + } + return parsed; + } + + static resolveSessionTtl(providerTtl: number | null | undefined): number { + const rawValue = providerTtl ?? SessionManager.getDefaultSessionTtlSeconds(); + const normalized = Number.isFinite(rawValue) + ? Math.trunc(rawValue) + : SessionManager.getDefaultSessionTtlSeconds(); + + return Math.max(60, Math.min(normalized, 3600)); + } + + static async resolveSessionTtlForSession(sessionId: string): Promise { + const redis = getRedisClient(); + if (!redis || redis.status !== "ready") { + return SessionManager.resolveSessionTtl(null); + } + + try { + const providerIdRaw = await redis.get(`session:${sessionId}:provider`); + if (!providerIdRaw) { + return SessionManager.resolveSessionTtl(null); + } + + const providerId = Number.parseInt(providerIdRaw, 10); + if (!Number.isFinite(providerId)) { + return SessionManager.resolveSessionTtl(null); + } + + const { findAllProviders } = await import("@/repository/provider"); + const providers = await findAllProviders(); + const provider = providers.find((item) => item.id === providerId); + + return SessionManager.resolveSessionTtl(provider?.sessionTtl); + } catch (error) { + logger.warn("SessionManager: Failed to resolve session TTL", { sessionId, error }); + return SessionManager.resolveSessionTtl(null); + } + } + /** * 从客户端请求中提取 session_id(支持 metadata 或 header) * @@ -469,16 +514,13 @@ export class SessionManager { if (!redis || redis.status !== "ready") return; try { + const ttlSeconds = await SessionManager.resolveSessionTtlForSession(sessionId); const pipeline = redis.pipeline(); // 刷新所有 session 相关 key 的 TTL - pipeline.expire(`session:${sessionId}:key`, SessionManager.SESSION_TTL); - pipeline.expire(`session:${sessionId}:provider`, SessionManager.SESSION_TTL); - pipeline.setex( - `session:${sessionId}:last_seen`, - SessionManager.SESSION_TTL, - Date.now().toString() - ); + pipeline.expire(`session:${sessionId}:key`, ttlSeconds); + pipeline.expire(`session:${sessionId}:provider`, ttlSeconds); + pipeline.setex(`session:${sessionId}:last_seen`, ttlSeconds, Date.now().toString()); await pipeline.exec(); } catch (error) { @@ -489,7 +531,11 @@ export class SessionManager { /** * 绑定 session 到 provider(TC-009 修复:使用 SET NX 避免竞态条件) */ - static async bindSessionToProvider(sessionId: string, providerId: number): Promise { + static async bindSessionToProvider( + sessionId: string, + providerId: number, + providerSessionTtl?: number | null + ): Promise { const redis = getRedisClient(); if (!redis || redis.status !== "ready") return; @@ -500,7 +546,7 @@ export class SessionManager { key, providerId.toString(), "EX", - SessionManager.SESSION_TTL, + SessionManager.resolveSessionTtl(providerSessionTtl), "NX" // Only set if not exists ); @@ -596,7 +642,8 @@ export class SessionManager { newProviderId: number, newProviderPriority: number, isFirstAttempt: boolean = false, - isFailoverSuccess: boolean = false + isFailoverSuccess: boolean = false, + providerSessionTtl?: number | null ): Promise<{ updated: boolean; reason: string; details?: string }> { const redis = getRedisClient(); if (!redis || redis.status !== "ready") { @@ -604,17 +651,12 @@ export class SessionManager { } try { + const ttlSeconds = SessionManager.resolveSessionTtl(providerSessionTtl); // ========== 情况 1:首次尝试成功 ========== if (isFirstAttempt) { const key = `session:${sessionId}:provider`; // 使用 SET NX 绑定(避免覆盖并发请求) - const result = await redis.set( - key, - newProviderId.toString(), - "EX", - SessionManager.SESSION_TTL, - "NX" - ); + const result = await redis.set(key, newProviderId.toString(), "EX", ttlSeconds, "NX"); if (result === "OK") { logger.info("SessionManager: Bound session to provider (first success)", { @@ -642,7 +684,7 @@ export class SessionManager { // 2.0 故障转移成功:无条件更新绑定(减少缓存切换) if (isFailoverSuccess) { const key = `session:${sessionId}:provider`; - await redis.setex(key, SessionManager.SESSION_TTL, newProviderId.toString()); + await redis.setex(key, ttlSeconds, newProviderId.toString()); logger.info("SessionManager: Updated binding after failover", { sessionId, @@ -662,13 +704,7 @@ export class SessionManager { if (!currentProviderIdStr) { // 没有绑定,使用 SET NX 绑定 const key = `session:${sessionId}:provider`; - const result = await redis.set( - key, - newProviderId.toString(), - "EX", - SessionManager.SESSION_TTL, - "NX" - ); + const result = await redis.set(key, newProviderId.toString(), "EX", ttlSeconds, "NX"); if (result === "OK") { logger.info("SessionManager: Bound session (no previous binding)", { @@ -705,7 +741,7 @@ export class SessionManager { if (!currentProvider) { // 当前供应商不存在(可能被删除),直接更新 const key = `session:${sessionId}:provider`; - await redis.setex(key, SessionManager.SESSION_TTL, newProviderId.toString()); + await redis.setex(key, ttlSeconds, newProviderId.toString()); logger.info("SessionManager: Updated binding (current provider not found)", { sessionId, @@ -728,7 +764,7 @@ export class SessionManager { // ========== 规则 A:新供应商优先级更高(数字更小)→ 直接迁移 ========== if (newProviderPriority < currentPriority) { const key = `session:${sessionId}:provider`; - await redis.setex(key, SessionManager.SESSION_TTL, newProviderId.toString()); + await redis.setex(key, ttlSeconds, newProviderId.toString()); logger.info("SessionManager: Migrated to higher priority provider", { sessionId, @@ -753,7 +789,7 @@ export class SessionManager { if (isCurrentCircuitOpen) { // 原供应商已熔断 → 更新到新供应商(备用供应商接管) const key = `session:${sessionId}:provider`; - await redis.setex(key, SessionManager.SESSION_TTL, newProviderId.toString()); + await redis.setex(key, ttlSeconds, newProviderId.toString()); logger.info("SessionManager: Migrated to backup provider (circuit open)", { sessionId, @@ -1812,7 +1848,8 @@ export class SessionManager { static async updateSessionWithCodexCacheKey( currentSessionId: string, promptCacheKey: string, - providerId: number + providerId: number, + providerSessionTtl?: number | null ): Promise<{ sessionId: string; updated: boolean }> { const redis = getRedisClient(); if (!redis || redis.status !== "ready") { @@ -1821,6 +1858,7 @@ export class SessionManager { } try { + const ttlSeconds = SessionManager.resolveSessionTtl(providerSessionTtl); // 使用 prompt_cache_key 作为新的 Session ID(添加前缀以区分) const codexSessionId = `codex_${promptCacheKey}`; @@ -1829,7 +1867,7 @@ export class SessionManager { if (existingProvider) { // 已存在绑定,刷新 TTL - await redis.expire(`session:${codexSessionId}:provider`, SessionManager.SESSION_TTL); + await redis.expire(`session:${codexSessionId}:provider`, ttlSeconds); logger.debug("SessionManager: Refreshed Codex session TTL", { sessionId: codexSessionId, providerId: parseInt(existingProvider, 10), @@ -1842,14 +1880,14 @@ export class SessionManager { `session:${codexSessionId}:provider`, providerId.toString(), "EX", - SessionManager.SESSION_TTL + ttlSeconds ); logger.info("SessionManager: Created Codex session from prompt_cache_key", { sessionId: codexSessionId, promptCacheKey, providerId, - ttl: SessionManager.SESSION_TTL, + ttl: ttlSeconds, }); return { sessionId: codexSessionId, updated: true }; diff --git a/src/lib/session-tracker.ts b/src/lib/session-tracker.ts index 72c2d287..bc9d4f0b 100644 --- a/src/lib/session-tracker.ts +++ b/src/lib/session-tracker.ts @@ -154,10 +154,17 @@ export class SessionTracker { * @param keyId - API Key ID * @param providerId - Provider ID */ - static async refreshSession(sessionId: string, keyId: number, providerId: number): Promise { + static async refreshSession( + sessionId: string, + keyId: number, + providerId: number, + ttlSeconds: number + ): Promise { const redis = getRedisClient(); if (!redis || redis.status !== "ready") return; + const normalizedTtlSeconds = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 300; + try { const now = Date.now(); const pipeline = redis.pipeline(); @@ -167,17 +174,9 @@ export class SessionTracker { pipeline.zadd(`key:${keyId}:active_sessions`, now, sessionId); pipeline.zadd(`provider:${providerId}:active_sessions`, now, sessionId); - // 修复 Bug:同步刷新 session 绑定信息的 TTL - // - // 问题:ZSET 条目(上面 zadd)会在每次请求时更新时间戳,但绑定信息 key 的 TTL 不会自动刷新 - // 导致:session 创建 5 分钟后,ZSET 仍有记录(仍被计为活跃),但绑定信息已过期,造成: - // 1. 并发检查被绕过(无法从绑定信息查询 session 所属 provider/key,检查失效) - // 2. Session 复用失败(无法确定 session 绑定关系,被迫创建新 session) - // - // 解决:每次 refreshSession 时同步刷新绑定信息 TTL(与 ZSET 保持 5 分钟生命周期一致) - pipeline.expire(`session:${sessionId}:provider`, 300); // 5 分钟(秒) - pipeline.expire(`session:${sessionId}:key`, 300); - pipeline.setex(`session:${sessionId}:last_seen`, 300, now.toString()); + pipeline.expire(`session:${sessionId}:provider`, ttlSeconds); + pipeline.expire(`session:${sessionId}:key`, ttlSeconds); + pipeline.setex(`session:${sessionId}:last_seen`, ttlSeconds, now.toString()); const results = await pipeline.exec(); diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index b9250137..5b98f882 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -434,6 +434,13 @@ export const CreateProviderSchema = z.object({ .max(1000, "并发Session上限不能超过1000") .optional() .default(0), + session_ttl: z.coerce + .number() + .int("Session TTL必须是整数") + .min(60, "Session TTL不能少于60秒") + .max(3600, "Session TTL不能超过3600秒") + .nullable() + .optional(), cache_ttl_preference: CACHE_TTL_PREFERENCE.optional().default("inherit"), context_1m_preference: CONTEXT_1M_PREFERENCE.nullable().optional(), codex_reasoning_effort_preference: @@ -611,6 +618,13 @@ export const UpdateProviderSchema = z .min(0, "并发Session上限不能为负数") .max(1000, "并发Session上限不能超过1000") .optional(), + session_ttl: z.coerce + .number() + .int("Session TTL必须是整数") + .min(60, "Session TTL不能少于60秒") + .max(3600, "Session TTL不能超过3600秒") + .nullable() + .optional(), cache_ttl_preference: CACHE_TTL_PREFERENCE.optional(), context_1m_preference: CONTEXT_1M_PREFERENCE.nullable().optional(), codex_reasoning_effort_preference: CODEX_REASONING_EFFORT_PREFERENCE.optional(), diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 00eeee4c..35c61886 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -88,6 +88,10 @@ export function toProvider(dbProvider: any): Provider { : null, totalCostResetAt: dbProvider?.totalCostResetAt ? new Date(dbProvider.totalCostResetAt) : null, limitConcurrentSessions: dbProvider?.limitConcurrentSessions ?? 0, + sessionTtl: + dbProvider?.sessionTtl !== null && dbProvider?.sessionTtl !== undefined + ? Number(dbProvider.sessionTtl) + : null, maxRetryAttempts: dbProvider?.maxRetryAttempts !== undefined && dbProvider?.maxRetryAttempts !== null ? Number(dbProvider.maxRetryAttempts) diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 35799833..edb0aac4 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -40,6 +40,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< limitTotalUsd: providerData.limit_total_usd != null ? providerData.limit_total_usd.toString() : null, limitConcurrentSessions: providerData.limit_concurrent_sessions, + sessionTtl: providerData.session_ttl ?? null, maxRetryAttempts: providerData.max_retry_attempts ?? null, circuitBreakerFailureThreshold: providerData.circuit_breaker_failure_threshold ?? 5, circuitBreakerOpenDuration: providerData.circuit_breaker_open_duration ?? 1800000, @@ -91,6 +92,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + sessionTtl: providers.sessionTtl, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, @@ -152,6 +154,7 @@ export async function findProviderList( limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + sessionTtl: providers.sessionTtl, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, @@ -227,6 +230,7 @@ export async function findAllProvidersFresh(): Promise { limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + sessionTtl: providers.sessionTtl, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, @@ -306,6 +310,7 @@ export async function findProviderById(id: number): Promise { limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + sessionTtl: providers.sessionTtl, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, @@ -395,6 +400,7 @@ 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.session_ttl !== undefined) dbData.sessionTtl = providerData.session_ttl; if (providerData.max_retry_attempts !== undefined) dbData.maxRetryAttempts = providerData.max_retry_attempts; if (providerData.circuit_breaker_failure_threshold !== undefined) @@ -465,6 +471,7 @@ export async function updateProvider( limitTotalUsd: providers.limitTotalUsd, totalCostResetAt: providers.totalCostResetAt, limitConcurrentSessions: providers.limitConcurrentSessions, + sessionTtl: providers.sessionTtl, maxRetryAttempts: providers.maxRetryAttempts, circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold, circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration, diff --git a/src/types/provider.ts b/src/types/provider.ts index da1b5db6..f626e638 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -94,6 +94,7 @@ export interface Provider { // 总消费重置时间:用于实现“达到总限额后手动重置用量” totalCostResetAt: Date | null; limitConcurrentSessions: number; + sessionTtl: number | null; // 熔断器配置(每个供应商独立配置) maxRetryAttempts: number | null; @@ -177,6 +178,7 @@ export interface ProviderDisplay { limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number; + sessionTtl: number | null; // 熔断器配置 maxRetryAttempts: number | null; circuitBreakerFailureThreshold: number; @@ -261,6 +263,7 @@ export interface CreateProviderData { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; + session_ttl?: number | null; // 熔断器配置 max_retry_attempts?: number | null; @@ -331,6 +334,7 @@ export interface UpdateProviderData { limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; + session_ttl?: number | null; // 熔断器配置 max_retry_attempts?: number | null; diff --git a/tests/unit/lib/session-manager-helpers.test.ts b/tests/unit/lib/session-manager-helpers.test.ts index bc3fce41..a2340ecd 100644 --- a/tests/unit/lib/session-manager-helpers.test.ts +++ b/tests/unit/lib/session-manager-helpers.test.ts @@ -25,7 +25,7 @@ vi.mock("@/app/v1/_lib/proxy/errors", () => ({ })); async function loadHelpers() { - const mod = await import("@/lib/session-manager"); + const mod = await import("../../../src/lib/session-manager"); return { headersToSanitizedObject: mod.headersToSanitizedObject, parseHeaderRecord: mod.parseHeaderRecord, diff --git a/tests/unit/repository/provider.test.ts b/tests/unit/repository/provider.test.ts index 694c29e9..7fe7be63 100644 --- a/tests/unit/repository/provider.test.ts +++ b/tests/unit/repository/provider.test.ts @@ -47,11 +47,17 @@ function sqlToString(sqlObj: unknown): string { return walk(sqlObj); } +function getSelectFieldKeys(value: unknown): string[] { + if (value === null || value === undefined) return []; + if (typeof value !== "object") return []; + return Object.keys(value as Record); +} + describe("provider repository - updateProviderPrioritiesBatch", () => { test("returns 0 and does not execute SQL when updates is empty", async () => { vi.resetModules(); - const executeMock = vi.fn(async () => ({ rowCount: 0 })); + const executeMock = vi.fn(async (_query: unknown) => ({ rowCount: 0 })); vi.doMock("@/drizzle/db", () => ({ db: { @@ -59,7 +65,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => { }, })); - const { updateProviderPrioritiesBatch } = await import("@/repository/provider"); + const { updateProviderPrioritiesBatch } = await import("../../../src/repository/provider"); const result = await updateProviderPrioritiesBatch([]); expect(result).toBe(0); @@ -69,7 +75,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => { test("generates CASE batch update SQL and returns affected rows", async () => { vi.resetModules(); - const executeMock = vi.fn(async () => ({ rowCount: 2 })); + const executeMock = vi.fn(async (_query: unknown) => ({ rowCount: 2 })); vi.doMock("@/drizzle/db", () => ({ db: { @@ -77,7 +83,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => { }, })); - const { updateProviderPrioritiesBatch } = await import("@/repository/provider"); + const { updateProviderPrioritiesBatch } = await import("../../../src/repository/provider"); const result = await updateProviderPrioritiesBatch([ { id: 1, priority: 0 }, { id: 2, priority: 3 }, @@ -87,7 +93,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => { expect(executeMock).toHaveBeenCalledTimes(1); const queryArg = executeMock.mock.calls[0]?.[0]; - const sqlText = sqlToString(queryArg).replaceAll(/\s+/g, " ").trim(); + const sqlText = sqlToString(queryArg).replace(/\s+/g, " ").trim(); expect(sqlText).toContain("UPDATE providers"); expect(sqlText).toContain("SET"); @@ -101,7 +107,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => { test("deduplicates provider ids (last update wins)", async () => { vi.resetModules(); - const executeMock = vi.fn(async () => ({ rowCount: 1 })); + const executeMock = vi.fn(async (_query: unknown) => ({ rowCount: 1 })); vi.doMock("@/drizzle/db", () => ({ db: { @@ -109,7 +115,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => { }, })); - const { updateProviderPrioritiesBatch } = await import("@/repository/provider"); + const { updateProviderPrioritiesBatch } = await import("../../../src/repository/provider"); const result = await updateProviderPrioritiesBatch([ { id: 1, priority: 0 }, { id: 1, priority: 2 }, @@ -119,9 +125,112 @@ describe("provider repository - updateProviderPrioritiesBatch", () => { expect(executeMock).toHaveBeenCalledTimes(1); const queryArg = executeMock.mock.calls[0]?.[0]; - const sqlText = sqlToString(queryArg).replaceAll(/\s+/g, " ").trim(); + const sqlText = sqlToString(queryArg).replace(/\s+/g, " ").trim(); expect(sqlText).toContain("WHEN 1 THEN 2"); expect(sqlText).toContain("WHERE id IN (1) AND deleted_at IS NULL"); }); }); + +describe("provider repository - sessionTtl projection", () => { + test("findProviderList should select sessionTtl", async () => { + vi.resetModules(); + + let capturedSelectFields: unknown; + + const offsetMock = vi.fn(async () => []); + const limitMock = vi.fn(() => ({ offset: offsetMock })); + const orderByMock = vi.fn(() => ({ limit: limitMock })); + const whereMock = vi.fn(() => ({ orderBy: orderByMock })); + const fromMock = vi.fn(() => ({ where: whereMock })); + const selectMock = vi.fn((fields: unknown) => { + capturedSelectFields = fields; + return { from: fromMock }; + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, + })); + + const { findProviderList } = await import("../../../src/repository/provider"); + await findProviderList(10, 0); + + expect(getSelectFieldKeys(capturedSelectFields)).toContain("sessionTtl"); + }); + + test("findAllProvidersFresh should select sessionTtl", async () => { + vi.resetModules(); + + let capturedSelectFields: unknown; + + const orderByMock = vi.fn(async () => []); + const whereMock = vi.fn(() => ({ orderBy: orderByMock })); + const fromMock = vi.fn(() => ({ where: whereMock })); + const selectMock = vi.fn((fields: unknown) => { + capturedSelectFields = fields; + return { from: fromMock }; + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, + })); + + const { findAllProvidersFresh } = await import("../../../src/repository/provider"); + await findAllProvidersFresh(); + + expect(getSelectFieldKeys(capturedSelectFields)).toContain("sessionTtl"); + }); + + test("findProviderById should select sessionTtl", async () => { + vi.resetModules(); + + let capturedSelectFields: unknown; + + const whereMock = vi.fn(async () => []); + const fromMock = vi.fn(() => ({ where: whereMock })); + const selectMock = vi.fn((fields: unknown) => { + capturedSelectFields = fields; + return { from: fromMock }; + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, + })); + + const { findProviderById } = await import("../../../src/repository/provider"); + await findProviderById(1); + + expect(getSelectFieldKeys(capturedSelectFields)).toContain("sessionTtl"); + }); + + test("updateProvider should return sessionTtl in returning projection", async () => { + vi.resetModules(); + + let capturedReturningFields: unknown; + + const returningMock = vi.fn(async (fields: unknown) => { + capturedReturningFields = fields; + return []; + }); + const whereMock = vi.fn(() => ({ returning: returningMock })); + const setMock = vi.fn(() => ({ where: whereMock })); + const updateMock = vi.fn(() => ({ set: setMock })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + update: updateMock, + }, + })); + + const { updateProvider } = await import("../../../src/repository/provider"); + await updateProvider(1, { name: "provider" }); + + expect(getSelectFieldKeys(capturedReturningFields)).toContain("sessionTtl"); + }); +}); diff --git a/tests/unit/session-manager.test.ts b/tests/unit/session-manager.test.ts new file mode 100644 index 00000000..790a76f6 --- /dev/null +++ b/tests/unit/session-manager.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +const ORIGINAL_SESSION_TTL = process.env.SESSION_TTL; + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("SessionManager.resolveSessionTtl", () => { + afterEach(() => { + if (ORIGINAL_SESSION_TTL === undefined) { + delete process.env.SESSION_TTL; + } else { + process.env.SESSION_TTL = ORIGINAL_SESSION_TTL; + } + }); + + async function importSessionManager() { + vi.resetModules(); + const { SessionManager } = await import("../../src/lib/session-manager"); + return SessionManager; + } + + test("falls back to global SESSION_TTL when provider ttl is null", async () => { + process.env.SESSION_TTL = "600"; + const SessionManager = await importSessionManager(); + + expect(SessionManager.resolveSessionTtl(null)).toBe(600); + expect(SessionManager.resolveSessionTtl(undefined)).toBe(600); + }); + + test("clamps ttl to supported range", async () => { + process.env.SESSION_TTL = "300"; + const SessionManager = await importSessionManager(); + + expect(SessionManager.resolveSessionTtl(10)).toBe(60); + expect(SessionManager.resolveSessionTtl(60)).toBe(60); + expect(SessionManager.resolveSessionTtl(3600)).toBe(3600); + expect(SessionManager.resolveSessionTtl(9999)).toBe(3600); + }); + + test("uses 300 when global SESSION_TTL is invalid", async () => { + process.env.SESSION_TTL = "not-a-number"; + const SessionManager = await importSessionManager(); + + expect(SessionManager.resolveSessionTtl(null)).toBe(300); + }); +}); diff --git a/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx b/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx index 0bdcb80c..d6aacfed 100644 --- a/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx +++ b/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx @@ -66,7 +66,7 @@ describe("ModelMultiSelect: 自定义白名单模型应可在列表中取消选 }); test("已选中但不在 availableModels 的模型应出现在列表中,并可取消选中删除", async () => { - const messages = loadTestMessages("en"); + const messages = loadMessages(); const onChange = vi.fn(); const { unmount } = render(