diff --git a/.env.example b/.env.example index 9cae3068e..14193bd05 100644 --- a/.env.example +++ b/.env.example @@ -122,9 +122,16 @@ PROBE_INTERVAL_MS=30000 PROBE_TIMEOUT_MS=5000 # Provider Endpoint Probing (always enabled) -# 功能说明:每 10 秒探测所有启用端点的速度与连通性,并刷新端点选择排序。 -# 注意:没有 ENABLE 开关,默认启用;可通过下列参数调优。 -ENDPOINT_PROBE_INTERVAL_MS=10000 +# Probes all enabled endpoints based on dynamic intervals and refreshes endpoint selection ranking. +# Note: No ENABLE switch, enabled by default; tune via parameters below. +# +# Dynamic Interval Rules (in priority order): +# 1. Timeout Override (10s): If endpoint's lastProbeErrorType === "timeout" and not recovered (lastProbeOk !== true) +# 2. Single-Vendor (10min): If vendor has only 1 enabled endpoint +# 3. Base Interval (default): All other endpoints +# +# ENDPOINT_PROBE_INTERVAL_MS controls the base interval. Single-vendor and timeout intervals are fixed. +ENDPOINT_PROBE_INTERVAL_MS=60000 ENDPOINT_PROBE_TIMEOUT_MS=5000 ENDPOINT_PROBE_CONCURRENCY=10 ENDPOINT_PROBE_CYCLE_JITTER_MS=1000 diff --git a/drizzle/0048_add_system_timezone.sql b/drizzle/0048_add_system_timezone.sql new file mode 100644 index 000000000..d0b72b4bf --- /dev/null +++ b/drizzle/0048_add_system_timezone.sql @@ -0,0 +1,6 @@ +-- Add timezone column to system_settings table +-- Stores IANA timezone identifier (e.g., 'Asia/Shanghai', 'America/New_York') +-- NULL means: use TZ environment variable or fallback to UTC + +ALTER TABLE "system_settings" +ADD COLUMN IF NOT EXISTS "timezone" varchar(64); diff --git a/drizzle/0057_conscious_quicksilver.sql b/drizzle/0057_conscious_quicksilver.sql new file mode 100644 index 000000000..3f268b60e --- /dev/null +++ b/drizzle/0057_conscious_quicksilver.sql @@ -0,0 +1,6 @@ +ALTER TABLE "message_request" ALTER COLUMN "input_tokens" SET DATA TYPE bigint;--> statement-breakpoint +ALTER TABLE "message_request" ALTER COLUMN "output_tokens" SET DATA TYPE bigint;--> statement-breakpoint +ALTER TABLE "message_request" ALTER COLUMN "cache_creation_input_tokens" SET DATA TYPE bigint;--> statement-breakpoint +ALTER TABLE "message_request" ALTER COLUMN "cache_read_input_tokens" SET DATA TYPE bigint;--> statement-breakpoint +ALTER TABLE "message_request" ALTER COLUMN "cache_creation_5m_input_tokens" SET DATA TYPE bigint;--> statement-breakpoint +ALTER TABLE "message_request" ALTER COLUMN "cache_creation_1h_input_tokens" SET DATA TYPE bigint; \ No newline at end of file diff --git a/drizzle/0058_silly_sleepwalker.sql b/drizzle/0058_silly_sleepwalker.sql new file mode 100644 index 000000000..cbb951797 --- /dev/null +++ b/drizzle/0058_silly_sleepwalker.sql @@ -0,0 +1,6 @@ +ALTER TABLE "system_settings" ADD COLUMN "quota_db_refresh_interval_seconds" integer DEFAULT 10;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "quota_lease_percent_5h" numeric(5, 4) DEFAULT '0.05';--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "quota_lease_percent_daily" numeric(5, 4) DEFAULT '0.05';--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "quota_lease_percent_weekly" numeric(5, 4) DEFAULT '0.05';--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "quota_lease_percent_monthly" numeric(5, 4) DEFAULT '0.05';--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "quota_lease_cap_usd" numeric(10, 2); \ No newline at end of file diff --git a/drizzle/0059_safe_xorn.sql b/drizzle/0059_safe_xorn.sql new file mode 100644 index 000000000..e598d4c65 --- /dev/null +++ b/drizzle/0059_safe_xorn.sql @@ -0,0 +1,2 @@ +ALTER TABLE "keys" ALTER COLUMN "expires_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "timezone" varchar(64); \ No newline at end of file diff --git a/drizzle/meta/0057_snapshot.json b/drizzle/meta/0057_snapshot.json new file mode 100644 index 000000000..be58a9ea9 --- /dev/null +++ b/drizzle/meta/0057_snapshot.json @@ -0,0 +1,2890 @@ +{ + "id": "734153dd-5481-44cd-a7c6-7adfbc027232", + "prevId": "75eef188-0cac-4ae8-9deb-9b0db4f046c2", + "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": "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 + }, + "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_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_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.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, + "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_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 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "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": {} + } + }, + "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'" + }, + "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/0058_snapshot.json b/drizzle/meta/0058_snapshot.json new file mode 100644 index 000000000..c7a3576fc --- /dev/null +++ b/drizzle/meta/0058_snapshot.json @@ -0,0 +1,2931 @@ +{ + "id": "a0d35aff-e8d6-4b37-8546-f7c5fe30c925", + "prevId": "734153dd-5481-44cd-a7c6-7adfbc027232", + "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": "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 + }, + "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_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_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.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, + "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_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 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "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": {} + } + }, + "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'" + }, + "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" + }, + "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.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/0059_snapshot.json b/drizzle/meta/0059_snapshot.json new file mode 100644 index 000000000..4b3c51ad2 --- /dev/null +++ b/drizzle/meta/0059_snapshot.json @@ -0,0 +1,2937 @@ +{ + "id": "5fd37dcd-8e23-4450-9177-cea694050745", + "prevId": "a0d35aff-e8d6-4b37-8546-f7c5fe30c925", + "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 + }, + "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": "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 + }, + "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_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_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.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, + "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_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 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "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": {} + } + }, + "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_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" + }, + "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.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 87303a2fd..82949325e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -400,6 +400,27 @@ "when": 1769008812140, "tag": "0056_tidy_quasar", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1769446927761, + "tag": "0057_conscious_quicksilver", + "breakpoints": true + }, + { + "idx": 58, + "version": "7", + "when": 1769523406503, + "tag": "0058_silly_sleepwalker", + "breakpoints": true + }, + { + "idx": 59, + "version": "7", + "when": 1769539222210, + "tag": "0059_safe_xorn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/errors.json b/messages/en/errors.json index 7f7695a7e..cf7fcfc16 100644 --- a/messages/en/errors.json +++ b/messages/en/errors.json @@ -52,11 +52,13 @@ "RATE_LIMIT_RPM_EXCEEDED": "Rate limit exceeded: {current} requests per minute (limit: {limit}). Resets at {resetTime}", "RATE_LIMIT_5H_EXCEEDED": "5-hour cost limit exceeded: ${current} USD (limit: ${limit} USD). Resets at {resetTime}", + "RATE_LIMIT_5H_ROLLING_EXCEEDED": "5-hour rolling window cost limit exceeded: ${current} USD (limit: ${limit} USD). Usage gradually expires over the past 5 hours", "RATE_LIMIT_WEEKLY_EXCEEDED": "Weekly cost limit exceeded: ${current} USD (limit: ${limit} USD). Resets at {resetTime}", "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_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", "USER_NOT_FOUND": "User not found", "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "Regular users cannot modify quota limits and provider groups", diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index 9209e6d51..b7ed57bb4 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -54,6 +54,11 @@ "rate_limited": "Rate Limited", "circuit_open": "Circuit Open", "disabled": "Disabled", + "excluded": "Excluded", + "format_type_mismatch": "Format Type Mismatch", + "type_mismatch": "Type Mismatch", + "model_not_allowed": "Model Not Allowed", + "context_1m_disabled": "1M Context Disabled", "model_not_supported": "Model Not Supported", "group_mismatch": "Group Mismatch", "health_check_failed": "Health Check Failed" diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index e1e542082..63c71c240 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -73,7 +73,26 @@ "siteTitlePlaceholder": "e.g. Claude Code Hub", "siteTitleRequired": "Site title cannot be empty", "verboseProviderError": "Verbose Provider Error", - "verboseProviderErrorDesc": "When enabled, return detailed error messages when all providers are unavailable (including provider count, rate limit reasons, etc.); when disabled, only return a simple error code." + "verboseProviderErrorDesc": "When enabled, return detailed error messages when all providers are unavailable (including provider count, rate limit reasons, etc.); when disabled, only return a simple error code.", + "timezoneLabel": "System Timezone", + "timezoneDescription": "Set the system timezone for unified backend time boundary calculations and frontend date/time display. Leave empty to use the TZ environment variable or default to UTC.", + "timezoneAuto": "Auto (use TZ env variable)", + "quotaLease": { + "title": "Quota Lease Settings", + "description": "Configure lease refresh interval and slice percentages for rate limit checks. The lease mechanism reduces DB query pressure while maintaining rate limit accuracy.", + "dbRefreshInterval": "DB Refresh Interval (seconds)", + "dbRefreshIntervalDesc": "Interval for refreshing quota usage from the database (1-300 seconds)", + "leasePercent5h": "5-Hour Window Lease Percentage", + "leasePercent5hDesc": "Percentage of 5-hour limit for each lease slice (0-1)", + "leasePercentDaily": "Daily Window Lease Percentage", + "leasePercentDailyDesc": "Percentage of daily limit for each lease slice (0-1)", + "leasePercentWeekly": "Weekly Window Lease Percentage", + "leasePercentWeeklyDesc": "Percentage of weekly limit for each lease slice (0-1)", + "leasePercentMonthly": "Monthly Window Lease Percentage", + "leasePercentMonthlyDesc": "Percentage of monthly limit for each lease slice (0-1)", + "leaseCapUsd": "Lease Cap (USD)", + "leaseCapUsdDesc": "Maximum absolute cap per lease slice in USD, leave empty for no limit" + } }, "section": { "autoCleanup": { diff --git a/messages/en/settings/index.ts b/messages/en/settings/index.ts index 47a6b5424..d014d0dba 100644 --- a/messages/en/settings/index.ts +++ b/messages/en/settings/index.ts @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; +import providersRecluster from "./providers/recluster.json"; import providersSchedulingDialog from "./providers/schedulingDialog.json"; import providersSearch from "./providers/search.json"; import providersSection from "./providers/section.json"; @@ -81,6 +82,7 @@ const providers = { guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, + recluster: providersRecluster, schedulingDialog: providersSchedulingDialog, search: providersSearch, section: providersSection, diff --git a/messages/en/settings/providers/recluster.json b/messages/en/settings/providers/recluster.json new file mode 100644 index 000000000..ef137acb7 --- /dev/null +++ b/messages/en/settings/providers/recluster.json @@ -0,0 +1,16 @@ +{ + "button": "Recluster", + "dialogTitle": "Recluster Providers", + "dialogDescription": "Reorganize providers based on updated clustering rules. For providers without a website URL, host:port will be used as the clustering key.", + "providersMoved": "Providers Moved", + "vendorsCreated": "Vendors Created", + "vendorsToDelete": "Vendors to Delete", + "skipped": "Skipped (Invalid URL)", + "providerHeader": "Provider", + "vendorChangeHeader": "Vendor Change", + "noChanges": "No changes needed (already clustered correctly)", + "moreChanges": "{count} more changes...", + "confirm": "Apply Changes", + "success": "Reclustered {count} providers", + "error": "Recluster failed" +} diff --git a/messages/en/settings/providers/strings.json b/messages/en/settings/providers/strings.json index 86e0cf807..c64eddf12 100644 --- a/messages/en/settings/providers/strings.json +++ b/messages/en/settings/providers/strings.json @@ -65,6 +65,7 @@ "circuitStatus": "Circuit Status", "vendorTypeCircuit": "Vendor Type Circuit", "vendorFallbackName": "Vendor #{id}", + "vendorAggregationRule": "Grouped by website domain", "orphanedProviders": "Unknown Vendor", "vendorTypeCircuitUpdated": "Vendor type circuit updated", "noEndpoints": "No endpoints configured", @@ -81,6 +82,8 @@ "probeOk": "OK", "probeError": "Error", "addEndpointDesc": "Add a new {providerType} endpoint for this vendor.", + "addEndpointDescGeneric": "Add a new API endpoint for this vendor.", + "columnType": "Type", "endpointUrlLabel": "URL", "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "Label (optional)", diff --git a/messages/ja/errors.json b/messages/ja/errors.json index 55aa004f4..1bd1d63a6 100644 --- a/messages/ja/errors.json +++ b/messages/ja/errors.json @@ -47,6 +47,14 @@ "BATCH_SIZE_EXCEEDED": "一括操作は {max} 件を超えることはできません", "RATE_LIMIT_EXCEEDED": "リクエストが多すぎます。後でもう一度お試しください", "RATE_LIMIT_RPM_EXCEEDED": "リクエストレート制限を超過しました:現在 {current} 回/分(制限:{limit} 回/分)。{resetTime} にリセットされます", + "RATE_LIMIT_5H_EXCEEDED": "5時間コスト制限を超過しました:${current} USD(制限:${limit} USD)。{resetTime} にリセットされます", + "RATE_LIMIT_5H_ROLLING_EXCEEDED": "5時間ローリングウィンドウのコスト制限を超過しました:${current} USD(制限:${limit} USD)。使用量は過去5時間で徐々に解放されます", + "RATE_LIMIT_DAILY_QUOTA_EXCEEDED": "日次クォータを超過しました:${current} USD(制限:${limit} USD)。{resetTime} にリセットされます", + "RATE_LIMIT_DAILY_ROLLING_EXCEEDED": "24時間ローリングウィンドウのコスト制限を超過しました:${current} USD(制限:${limit} USD)。使用量は過去24時間で徐々に解放されます", + "RATE_LIMIT_WEEKLY_EXCEEDED": "週次コスト制限を超過しました:${current} USD(制限:${limit} USD)。{resetTime} にリセットされます", + "RATE_LIMIT_MONTHLY_EXCEEDED": "月次コスト制限を超過しました:${current} USD(制限:${limit} USD)。{resetTime} にリセットされます", + "RATE_LIMIT_TOTAL_EXCEEDED": "総支出制限を超過しました:${current} / ${limit} USD", + "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED": "同時セッション制限を超過しました:現在 {current} セッション(制限:{limit})。アクティブなセッションが完了するまでお待ちください", "RESOURCE_BUSY": "リソースは現在使用中です", "INVALID_STATE": "現在の状態では操作が許可されていません", "CONFLICT": "操作の競合", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index f2802ae8e..4910f5e0f 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -54,6 +54,11 @@ "rate_limited": "レート制限", "circuit_open": "サーキットオープン", "disabled": "無効", + "excluded": "除外済み", + "format_type_mismatch": "フォーマット不一致", + "type_mismatch": "タイプ不一致", + "model_not_allowed": "モデル不許可", + "context_1m_disabled": "1Mコンテキスト無効", "model_not_supported": "モデル非対応", "group_mismatch": "グループ不一致", "health_check_failed": "ヘルスチェック失敗" diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index f5faa7c68..2133d0072 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -73,7 +73,26 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "サイトタイトルは空にできません", "verboseProviderError": "詳細なプロバイダーエラー", - "verboseProviderErrorDesc": "有効にすると、すべてのプロバイダーが利用不可の場合に詳細なエラーメッセージ(プロバイダー数、レート制限の理由など)を返します。無効の場合は簡潔なエラーコードのみを返します。" + "verboseProviderErrorDesc": "有効にすると、すべてのプロバイダーが利用不可の場合に詳細なエラーメッセージ(プロバイダー数、レート制限の理由など)を返します。無効の場合は簡潔なエラーコードのみを返します。", + "timezoneLabel": "システムタイムゾーン", + "timezoneDescription": "バックエンドの時間境界計算とフロントエンドの日付/時刻表示を統一するためのシステムタイムゾーンを設定します。空のままにすると環境変数 TZ またはデフォルトの UTC が使用されます。", + "timezoneAuto": "自動 (環境変数 TZ を使用)", + "quotaLease": { + "title": "クォータリース設定", + "description": "レート制限チェック時のリース更新間隔とスライス比率を設定します。リース機構はDBクエリの負荷を軽減しながら、レート制限の精度を維持します。", + "dbRefreshInterval": "DB更新間隔(秒)", + "dbRefreshIntervalDesc": "データベースからクォータ使用量を更新する間隔(1-300秒)", + "leasePercent5h": "5時間ウィンドウリース比率", + "leasePercent5hDesc": "各リーススライスの5時間制限に対する比率(0-1)", + "leasePercentDaily": "日次ウィンドウリース比率", + "leasePercentDailyDesc": "各リーススライスの日次制限に対する比率(0-1)", + "leasePercentWeekly": "週次ウィンドウリース比率", + "leasePercentWeeklyDesc": "各リーススライスの週次制限に対する比率(0-1)", + "leasePercentMonthly": "月次ウィンドウリース比率", + "leasePercentMonthlyDesc": "各リーススライスの月次制限に対する比率(0-1)", + "leaseCapUsd": "リース上限(USD)", + "leaseCapUsdDesc": "リーススライスごとの絶対上限(米ドル)、空の場合は無制限" + } }, "section": { "autoCleanup": { diff --git a/messages/ja/settings/index.ts b/messages/ja/settings/index.ts index 47a6b5424..d014d0dba 100644 --- a/messages/ja/settings/index.ts +++ b/messages/ja/settings/index.ts @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; +import providersRecluster from "./providers/recluster.json"; import providersSchedulingDialog from "./providers/schedulingDialog.json"; import providersSearch from "./providers/search.json"; import providersSection from "./providers/section.json"; @@ -81,6 +82,7 @@ const providers = { guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, + recluster: providersRecluster, schedulingDialog: providersSchedulingDialog, search: providersSearch, section: providersSection, diff --git a/messages/ja/settings/providers/recluster.json b/messages/ja/settings/providers/recluster.json new file mode 100644 index 000000000..429550af8 --- /dev/null +++ b/messages/ja/settings/providers/recluster.json @@ -0,0 +1,16 @@ +{ + "button": "再クラスタ", + "dialogTitle": "プロバイダーの再クラスタリング", + "dialogDescription": "更新されたクラスタリングルールに基づいてプロバイダーを再編成します。ウェブサイトURLが設定されていないプロバイダーには、host:port がクラスタリングキーとして使用されます。", + "providersMoved": "移動したプロバイダー", + "vendorsCreated": "作成されるベンダー", + "vendorsToDelete": "削除されるベンダー", + "skipped": "スキップ (無効なURL)", + "providerHeader": "プロバイダー", + "vendorChangeHeader": "ベンダー変更", + "noChanges": "変更不要 (既に正しくクラスタリング済み)", + "moreChanges": "他 {count} 件の変更...", + "confirm": "変更を適用", + "success": "{count} 件のプロバイダーを再クラスタリングしました", + "error": "再クラスタリングに失敗しました" +} diff --git a/messages/ja/settings/providers/strings.json b/messages/ja/settings/providers/strings.json index 971f81fda..b291a5517 100644 --- a/messages/ja/settings/providers/strings.json +++ b/messages/ja/settings/providers/strings.json @@ -65,6 +65,7 @@ "circuitStatus": "回路状態", "vendorTypeCircuit": "ベンダー種別回路", "vendorFallbackName": "ベンダー #{id}", + "vendorAggregationRule": "公式ドメインで集約", "orphanedProviders": "不明なベンダー", "vendorTypeCircuitUpdated": "ベンダータイプサーキットが更新されました", "noEndpoints": "エンドポイントが設定されていません", @@ -81,6 +82,8 @@ "probeOk": "OK", "probeError": "エラー", "addEndpointDesc": "このベンダーに {providerType} エンドポイントを追加します。", + "addEndpointDescGeneric": "このベンダーに新しい API エンドポイントを追加します。", + "columnType": "種類", "endpointUrlLabel": "URL", "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "ラベル (任意)", diff --git a/messages/ru/errors.json b/messages/ru/errors.json index 2f0d37781..e2fec48cf 100644 --- a/messages/ru/errors.json +++ b/messages/ru/errors.json @@ -47,6 +47,14 @@ "BATCH_SIZE_EXCEEDED": "Пакетная операция не может превышать {max} элементов", "RATE_LIMIT_EXCEEDED": "Слишком много запросов, попробуйте позже", "RATE_LIMIT_RPM_EXCEEDED": "Превышен лимит запросов: {current} запросов в минуту (лимит: {limit}). Сброс в {resetTime}", + "RATE_LIMIT_5H_EXCEEDED": "Превышен 5-часовой лимит расходов: ${current} USD (лимит: ${limit} USD). Сброс в {resetTime}", + "RATE_LIMIT_5H_ROLLING_EXCEEDED": "Превышен лимит скользящего окна 5 часов: ${current} USD (лимит: ${limit} USD). Использование постепенно освобождается за последние 5 часов", + "RATE_LIMIT_DAILY_QUOTA_EXCEEDED": "Превышена дневная квота: ${current} USD (лимит: ${limit} USD). Сброс в {resetTime}", + "RATE_LIMIT_DAILY_ROLLING_EXCEEDED": "Превышен лимит скользящего окна 24 часа: ${current} USD (лимит: ${limit} USD). Использование постепенно освобождается за последние 24 часа", + "RATE_LIMIT_WEEKLY_EXCEEDED": "Превышен недельный лимит расходов: ${current} USD (лимит: ${limit} USD). Сброс в {resetTime}", + "RATE_LIMIT_MONTHLY_EXCEEDED": "Превышен месячный лимит расходов: ${current} USD (лимит: ${limit} USD). Сброс в {resetTime}", + "RATE_LIMIT_TOTAL_EXCEEDED": "Превышен общий лимит расходов: ${current} / ${limit} USD", + "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED": "Превышен лимит одновременных сессий: {current} сессий (лимит: {limit}). Пожалуйста, дождитесь завершения активных сессий", "RESOURCE_BUSY": "Ресурс в настоящее время используется", "INVALID_STATE": "Операция не разрешена в текущем состоянии", "CONFLICT": "Конфликт операции", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index 57073284e..1f9e67efa 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -54,6 +54,11 @@ "rate_limited": "Ограничение скорости", "circuit_open": "Автомат открыт", "disabled": "Отключен", + "excluded": "Исключен", + "format_type_mismatch": "Несоответствие формата", + "type_mismatch": "Несоответствие типа", + "model_not_allowed": "Модель не разрешена", + "context_1m_disabled": "1M контекст отключен", "model_not_supported": "Модель не поддерживается", "group_mismatch": "Несоответствие группы", "health_check_failed": "Проверка состояния не пройдена" diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 5fdd3fef9..63f7e37fa 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -73,7 +73,26 @@ "siteTitlePlaceholder": "например: Claude Code Hub", "siteTitleRequired": "Название сайта не может быть пустым", "verboseProviderError": "Подробные ошибки провайдеров", - "verboseProviderErrorDesc": "При включении возвращает подробные сообщения об ошибках при недоступности всех провайдеров (количество провайдеров, причины ограничений и т.д.); при отключении возвращает только простой код ошибки." + "verboseProviderErrorDesc": "При включении возвращает подробные сообщения об ошибках при недоступности всех провайдеров (количество провайдеров, причины ограничений и т.д.); при отключении возвращает только простой код ошибки.", + "timezoneLabel": "Системная Временная Зона", + "timezoneDescription": "Установите системную временную зону для единых вычислений временных границ в бэкенде и отображения даты/времени в интерфейсе. Оставьте пустым для использования переменной окружения TZ или UTC по умолчанию.", + "timezoneAuto": "Авто (использовать переменную окружения TZ)", + "quotaLease": { + "title": "Настройки аренды квоты", + "description": "Настройка интервала обновления аренды и процентов среза для проверки лимитов. Механизм аренды снижает нагрузку на БД, сохраняя точность лимитов.", + "dbRefreshInterval": "Интервал обновления БД (секунды)", + "dbRefreshIntervalDesc": "Интервал обновления использования квоты из базы данных (1-300 секунд)", + "leasePercent5h": "Процент аренды 5-часового окна", + "leasePercent5hDesc": "Процент 5-часового лимита для каждого среза аренды (0-1)", + "leasePercentDaily": "Процент аренды дневного окна", + "leasePercentDailyDesc": "Процент дневного лимита для каждого среза аренды (0-1)", + "leasePercentWeekly": "Процент аренды недельного окна", + "leasePercentWeeklyDesc": "Процент недельного лимита для каждого среза аренды (0-1)", + "leasePercentMonthly": "Процент аренды месячного окна", + "leasePercentMonthlyDesc": "Процент месячного лимита для каждого среза аренды (0-1)", + "leaseCapUsd": "Предел аренды (USD)", + "leaseCapUsdDesc": "Максимальный абсолютный предел на срез аренды в долларах США, оставьте пустым для отсутствия ограничения" + } }, "section": { "autoCleanup": { diff --git a/messages/ru/settings/index.ts b/messages/ru/settings/index.ts index 47a6b5424..d014d0dba 100644 --- a/messages/ru/settings/index.ts +++ b/messages/ru/settings/index.ts @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; +import providersRecluster from "./providers/recluster.json"; import providersSchedulingDialog from "./providers/schedulingDialog.json"; import providersSearch from "./providers/search.json"; import providersSection from "./providers/section.json"; @@ -81,6 +82,7 @@ const providers = { guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, + recluster: providersRecluster, schedulingDialog: providersSchedulingDialog, search: providersSearch, section: providersSection, diff --git a/messages/ru/settings/providers/recluster.json b/messages/ru/settings/providers/recluster.json new file mode 100644 index 000000000..56e692266 --- /dev/null +++ b/messages/ru/settings/providers/recluster.json @@ -0,0 +1,16 @@ +{ + "button": "Перегруппировать", + "dialogTitle": "Перегруппировка поставщиков", + "dialogDescription": "Реорганизация поставщиков по обновленным правилам группировки. Для поставщиков без URL сайта в качестве ключа группировки используется host:port.", + "providersMoved": "Перемещено поставщиков", + "vendorsCreated": "Создано групп", + "vendorsToDelete": "Групп к удалению", + "skipped": "Пропущено (неверный URL)", + "providerHeader": "Поставщик", + "vendorChangeHeader": "Изменение группы", + "noChanges": "Изменения не требуются (уже правильно сгруппировано)", + "moreChanges": "Еще {count} изменений...", + "confirm": "Применить изменения", + "success": "Перегруппировано {count} поставщиков", + "error": "Ошибка перегруппировки" +} diff --git a/messages/ru/settings/providers/strings.json b/messages/ru/settings/providers/strings.json index 090c117c6..78c41d67f 100644 --- a/messages/ru/settings/providers/strings.json +++ b/messages/ru/settings/providers/strings.json @@ -65,6 +65,7 @@ "circuitStatus": "Состояние цепи", "vendorTypeCircuit": "Цепь по типу провайдера", "vendorFallbackName": "Поставщик #{id}", + "vendorAggregationRule": "Группировка по домену сайта", "orphanedProviders": "Неизвестный поставщик", "vendorTypeCircuitUpdated": "Цепь типа поставщика обновлена", "noEndpoints": "Эндпоинты не настроены", @@ -81,6 +82,8 @@ "probeOk": "OK", "probeError": "Ошибка", "addEndpointDesc": "Добавьте новый эндпоинт {providerType} для этого вендора.", + "addEndpointDescGeneric": "Добавьте новый API эндпоинт для этого вендора.", + "columnType": "Тип", "endpointUrlLabel": "URL", "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "Метка (необязательно)", diff --git a/messages/zh-CN/errors.json b/messages/zh-CN/errors.json index 35bf44013..de1573a05 100644 --- a/messages/zh-CN/errors.json +++ b/messages/zh-CN/errors.json @@ -48,7 +48,9 @@ "RATE_LIMIT_EXCEEDED": "请求过于频繁,请稍后重试", "RATE_LIMIT_RPM_EXCEEDED": "请求频率超限:当前 {current} 次/分钟(限制:{limit} 次/分钟)。将于 {resetTime} 重置", "RATE_LIMIT_DAILY_QUOTA_EXCEEDED": "每日额度超限:当前 ${current} USD(限制:${limit} USD)。将于 {resetTime} 重置", + "RATE_LIMIT_DAILY_ROLLING_EXCEEDED": "24小时滚动窗口消费超限:当前 ${current} USD(限制:${limit} USD)。消费将在过去24小时内逐渐释放", "RATE_LIMIT_5H_EXCEEDED": "5小时消费超限:当前 ${current} USD(限制:${limit} USD)。将于 {resetTime} 重置", + "RATE_LIMIT_5H_ROLLING_EXCEEDED": "5小时滚动窗口消费超限:当前 ${current} USD(限制:${limit} USD)。消费将在过去5小时内逐渐释放", "RATE_LIMIT_WEEKLY_EXCEEDED": "周消费超限:当前 ${current} USD(限制:${limit} USD)。将于 {resetTime} 重置", "RATE_LIMIT_MONTHLY_EXCEEDED": "月消费超限:当前 ${current} USD(限制:${limit} USD)。将于 {resetTime} 重置", "RATE_LIMIT_TOTAL_EXCEEDED": "总消费上限已达到:${current} / ${limit} USD", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index 7eb3209a1..12f8f1bdf 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -54,6 +54,11 @@ "rate_limited": "速率限制", "circuit_open": "熔断器打开", "disabled": "已禁用", + "excluded": "已排除", + "format_type_mismatch": "请求格式不兼容", + "type_mismatch": "类型不匹配", + "model_not_allowed": "模型不允许", + "context_1m_disabled": "1M上下文已禁用", "model_not_supported": "不支持该模型", "group_mismatch": "分组不匹配", "health_check_failed": "健康检查失败" diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 06ed7b655..d03249bde 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -86,6 +86,25 @@ "TWD": "NT$ 新台币 (TWD)", "KRW": "₩ 韩元 (KRW)", "SGD": "S$ 新加坡元 (SGD)" + }, + "timezoneLabel": "系统时区", + "timezoneDescription": "设置系统时区,用于统一后端时间边界计算和前端日期/时间显示。留空时使用环境变量 TZ 或默认 UTC。", + "timezoneAuto": "自动 (使用环境变量 TZ)", + "quotaLease": { + "title": "配额租约设置", + "description": "配置限额检查时的租约刷新间隔和切片比例。租约机制用于减少 DB 查询压力,同时保持限额精度。", + "dbRefreshInterval": "DB 刷新间隔(秒)", + "dbRefreshIntervalDesc": "从数据库刷新配额使用量的间隔时间(1-300 秒)", + "leasePercent5h": "5 小时窗口租约比例", + "leasePercent5hDesc": "每次租约切片占 5 小时限额的比例(0-1)", + "leasePercentDaily": "每日窗口租约比例", + "leasePercentDailyDesc": "每次租约切片占每日限额的比例(0-1)", + "leasePercentWeekly": "每周窗口租约比例", + "leasePercentWeeklyDesc": "每次租约切片占每周限额的比例(0-1)", + "leasePercentMonthly": "每月窗口租约比例", + "leasePercentMonthlyDesc": "每次租约切片占每月限额的比例(0-1)", + "leaseCapUsd": "租约上限(USD)", + "leaseCapUsdDesc": "单次租约切片的绝对上限(美元),为空则不限制" } } } diff --git a/messages/zh-CN/settings/index.ts b/messages/zh-CN/settings/index.ts index 47a6b5424..d014d0dba 100644 --- a/messages/zh-CN/settings/index.ts +++ b/messages/zh-CN/settings/index.ts @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; +import providersRecluster from "./providers/recluster.json"; import providersSchedulingDialog from "./providers/schedulingDialog.json"; import providersSearch from "./providers/search.json"; import providersSection from "./providers/section.json"; @@ -81,6 +82,7 @@ const providers = { guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, + recluster: providersRecluster, schedulingDialog: providersSchedulingDialog, search: providersSearch, section: providersSection, diff --git a/messages/zh-CN/settings/providers/recluster.json b/messages/zh-CN/settings/providers/recluster.json new file mode 100644 index 000000000..0250bbb7d --- /dev/null +++ b/messages/zh-CN/settings/providers/recluster.json @@ -0,0 +1,16 @@ +{ + "button": "重新分组", + "dialogTitle": "重新分组供应商", + "dialogDescription": "根据更新的分组规则重新组织供应商。对于未设置网站URL的供应商,将使用 host:port 作为分组键。", + "providersMoved": "供应商移动", + "vendorsCreated": "新建分组", + "vendorsToDelete": "待删除分组", + "skipped": "跳过(无效URL)", + "providerHeader": "供应商", + "vendorChangeHeader": "分组变更", + "noChanges": "无需更改(已正确分组)", + "moreChanges": "还有 {count} 条变更...", + "confirm": "应用变更", + "success": "已重新分组 {count} 个供应商", + "error": "重新分组失败" +} diff --git a/messages/zh-CN/settings/providers/strings.json b/messages/zh-CN/settings/providers/strings.json index 56c3b257c..3cebf3338 100644 --- a/messages/zh-CN/settings/providers/strings.json +++ b/messages/zh-CN/settings/providers/strings.json @@ -65,6 +65,7 @@ "circuitStatus": "熔断状态", "vendorTypeCircuit": "服务商类型熔断", "vendorFallbackName": "服务商 #{id}", + "vendorAggregationRule": "按官网域名聚合", "orphanedProviders": "未知服务商", "vendorTypeCircuitUpdated": "已更新服务商类型熔断器", "noEndpoints": "暂无端点配置", @@ -81,6 +82,8 @@ "probeOk": "正常", "probeError": "异常", "addEndpointDesc": "为该服务商添加一个新的 {providerType} 端点。", + "addEndpointDescGeneric": "为该服务商添加一个新的 API 端点。", + "columnType": "类型", "endpointUrlLabel": "URL", "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "标签(可选)", diff --git a/messages/zh-TW/errors.json b/messages/zh-TW/errors.json index dc9697a87..845119ceb 100644 --- a/messages/zh-TW/errors.json +++ b/messages/zh-TW/errors.json @@ -47,6 +47,14 @@ "BATCH_SIZE_EXCEEDED": "批次操作不能超過 {max} 個項目", "RATE_LIMIT_EXCEEDED": "請求過於頻繁,請稍後重試", "RATE_LIMIT_RPM_EXCEEDED": "請求頻率超限:當前 {current} 次/分鐘(限制:{limit} 次/分鐘)。將於 {resetTime} 重置", + "RATE_LIMIT_5H_EXCEEDED": "5小時消費超限:當前 ${current} USD(限制:${limit} USD)。將於 {resetTime} 重置", + "RATE_LIMIT_5H_ROLLING_EXCEEDED": "5小時滾動視窗消費超限:當前 ${current} USD(限制:${limit} USD)。消費將在過去5小時內逐漸釋放", + "RATE_LIMIT_DAILY_QUOTA_EXCEEDED": "每日額度超限:當前 ${current} USD(限制:${limit} USD)。將於 {resetTime} 重置", + "RATE_LIMIT_DAILY_ROLLING_EXCEEDED": "24小時滾動視窗消費超限:當前 ${current} USD(限制:${limit} USD)。消費將在過去24小時內逐漸釋放", + "RATE_LIMIT_WEEKLY_EXCEEDED": "週消費超限:當前 ${current} USD(限制:${limit} USD)。將於 {resetTime} 重置", + "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 完成", "RESOURCE_BUSY": "資源正在使用中", "INVALID_STATE": "當前狀態不允許此操作", "CONFLICT": "操作衝突", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 5ef8130b6..cdd98fc06 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -54,6 +54,11 @@ "rate_limited": "速率限制", "circuit_open": "熔斷器開啟", "disabled": "已停用", + "excluded": "已排除", + "format_type_mismatch": "請求格式不相容", + "type_mismatch": "類型不匹配", + "model_not_allowed": "模型不允許", + "context_1m_disabled": "1M上下文已停用", "model_not_supported": "不支援該模型", "group_mismatch": "分組不匹配", "health_check_failed": "健康檢查失敗" diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index bad284b0c..cd1919eb0 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -73,7 +73,26 @@ "siteTitlePlaceholder": "例:Claude Code Hub", "siteTitleRequired": "站台標題不能為空", "verboseProviderError": "詳細供應商錯誤資訊", - "verboseProviderErrorDesc": "開啟後,當所有供應商不可用時返回詳細錯誤資訊(包含供應商數量、限流原因等);關閉後僅返回簡潔錯誤碼。" + "verboseProviderErrorDesc": "開啟後,當所有供應商不可用時返回詳細錯誤資訊(包含供應商數量、限流原因等);關閉後僅返回簡潔錯誤碼。", + "timezoneLabel": "系統時區", + "timezoneDescription": "設定系統時區,用於統一後端時間邊界計算和前端日期/時間顯示。留空時使用環境變數 TZ 或預設 UTC。", + "timezoneAuto": "自動 (使用環境變數 TZ)", + "quotaLease": { + "title": "配額租約設定", + "description": "設定限額檢查時的租約刷新間隔和切片比例。租約機制用於減少 DB 查詢壓力,同時保持限額精度。", + "dbRefreshInterval": "DB 刷新間隔(秒)", + "dbRefreshIntervalDesc": "從資料庫刷新配額使用量的間隔時間(1-300 秒)", + "leasePercent5h": "5 小時窗口租約比例", + "leasePercent5hDesc": "每次租約切片佔 5 小時限額的比例(0-1)", + "leasePercentDaily": "每日窗口租約比例", + "leasePercentDailyDesc": "每次租約切片佔每日限額的比例(0-1)", + "leasePercentWeekly": "每週窗口租約比例", + "leasePercentWeeklyDesc": "每次租約切片佔每週限額的比例(0-1)", + "leasePercentMonthly": "每月窗口租約比例", + "leasePercentMonthlyDesc": "每次租約切片佔每月限額的比例(0-1)", + "leaseCapUsd": "租約上限(USD)", + "leaseCapUsdDesc": "單次租約切片的絕對上限(美元),為空則不限制" + } }, "section": { "autoCleanup": { diff --git a/messages/zh-TW/settings/index.ts b/messages/zh-TW/settings/index.ts index 47a6b5424..d014d0dba 100644 --- a/messages/zh-TW/settings/index.ts +++ b/messages/zh-TW/settings/index.ts @@ -18,6 +18,7 @@ import providersFilter from "./providers/filter.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; +import providersRecluster from "./providers/recluster.json"; import providersSchedulingDialog from "./providers/schedulingDialog.json"; import providersSearch from "./providers/search.json"; import providersSection from "./providers/section.json"; @@ -81,6 +82,7 @@ const providers = { guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, + recluster: providersRecluster, schedulingDialog: providersSchedulingDialog, search: providersSearch, section: providersSection, diff --git a/messages/zh-TW/settings/providers/recluster.json b/messages/zh-TW/settings/providers/recluster.json new file mode 100644 index 000000000..5aee32ca4 --- /dev/null +++ b/messages/zh-TW/settings/providers/recluster.json @@ -0,0 +1,16 @@ +{ + "button": "重新分組", + "dialogTitle": "重新分組供應商", + "dialogDescription": "根據更新的分組規則重新組織供應商。對於未設定網站URL的供應商,將使用 host:port 作為分組鍵。", + "providersMoved": "供應商移動", + "vendorsCreated": "新建分組", + "vendorsToDelete": "待刪除分組", + "skipped": "跳過(無效URL)", + "providerHeader": "供應商", + "vendorChangeHeader": "分組變更", + "noChanges": "無需更改(已正確分組)", + "moreChanges": "還有 {count} 條變更...", + "confirm": "應用變更", + "success": "已重新分組 {count} 個供應商", + "error": "重新分組失敗" +} diff --git a/messages/zh-TW/settings/providers/strings.json b/messages/zh-TW/settings/providers/strings.json index 3947b1d07..616f812e5 100644 --- a/messages/zh-TW/settings/providers/strings.json +++ b/messages/zh-TW/settings/providers/strings.json @@ -65,6 +65,7 @@ "circuitStatus": "熔斷狀態", "vendorTypeCircuit": "供應商類型熔斷", "vendorFallbackName": "服務商 #{id}", + "vendorAggregationRule": "按官方網站網域聚合", "orphanedProviders": "未知服務商", "vendorTypeCircuitUpdated": "已更新服務商類型熔斷器", "noEndpoints": "尚未設定端點", @@ -81,6 +82,8 @@ "probeOk": "正常", "probeError": "異常", "addEndpointDesc": "為此供應商新增一個 {providerType} 端點。", + "addEndpointDescGeneric": "為此供應商新增一個新的 API 端點。", + "columnType": "類型", "endpointUrlLabel": "URL", "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "標籤(選填)", diff --git a/src/actions/keys.ts b/src/actions/keys.ts index 1c1ac7047..7792e0b8d 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -9,8 +9,10 @@ import { keys as keysTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; +import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { normalizeProviderGroup, parseProviderGroups } from "@/lib/utils/provider-group"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { KeyFormSchema } from "@/lib/validation/schemas"; import type { KeyStatistics } from "@/repository/key"; import { @@ -280,9 +282,12 @@ export async function addKey(data: { const generatedKey = `sk-${randomBytes(16).toString("hex")}`; - // 转换 expiresAt: undefined → null(永不过期),string → Date(设置日期) + // 转换 expiresAt: undefined → null(永不过期),string → Date(按系统时区解析) + const timezone = await resolveSystemTimezone(); const expiresAt = - validatedData.expiresAt === undefined ? null : new Date(validatedData.expiresAt); + validatedData.expiresAt === undefined + ? null + : parseDateInputAsTimezone(validatedData.expiresAt, timezone); await createKey({ user_id: data.userId, @@ -497,22 +502,26 @@ export async function editKey( // 移除 providerGroup 子集校验(用户分组由 Key 分组自动计算) - // 转换 expiresAt: + // 转换 expiresAt(按系统时区解析): // - 未携带 expiresAt:不更新该字段 // - 携带 expiresAt 但为空:清除(永不过期) // - 携带 expiresAt 且为字符串:设置为对应 Date - const expiresAt = hasExpiresAtField - ? validatedData.expiresAt === undefined - ? null - : new Date(validatedData.expiresAt) - : undefined; - - if (expiresAt && Number.isNaN(expiresAt.getTime())) { - return { - ok: false, - error: tError("INVALID_FORMAT"), - errorCode: ERROR_CODES.INVALID_FORMAT, - }; + let expiresAt: Date | null | undefined; + if (hasExpiresAtField) { + if (validatedData.expiresAt === undefined) { + expiresAt = null; + } else { + try { + const timezone = await resolveSystemTimezone(); + expiresAt = parseDateInputAsTimezone(validatedData.expiresAt, timezone); + } catch { + return { + ok: false, + error: tError("INVALID_FORMAT"), + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + } } const isAdmin = session.user.role === "admin"; @@ -721,14 +730,14 @@ export async function getKeyLimitUsage(keyId: number): Promise< ]); // 获取重置时间 - const resetInfo5h = getResetInfo("5h"); - const resetInfoDaily = getResetInfoWithMode( + const resetInfo5h = await getResetInfo("5h"); + const resetInfoDaily = await getResetInfoWithMode( "daily", key.dailyResetTime, key.dailyResetMode ?? "fixed" ); - const resetInfoWeekly = getResetInfo("weekly"); - const resetInfoMonthly = getResetInfo("monthly"); + const resetInfoWeekly = await getResetInfo("weekly"); + const resetInfoMonthly = await getResetInfo("monthly"); return { ok: true, @@ -1058,10 +1067,9 @@ export async function renewKeyExpiresAt( }; } - const expiresAt = new Date(data.expiresAt); - if (Number.isNaN(expiresAt.getTime())) { - return { ok: false, error: tError("INVALID_FORMAT"), errorCode: ERROR_CODES.INVALID_FORMAT }; - } + // 按系统时区解析过期日期 + const timezone = await resolveSystemTimezone(); + const expiresAt = parseDateInputAsTimezone(data.expiresAt, timezone); await updateKey(keyId, { expires_at: expiresAt, diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index b8da2b24d..f5fb7178c 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -5,12 +5,12 @@ import { and, eq, gte, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; -import { getEnvConfig } from "@/lib/config"; import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit/service"; import type { DailyResetMode } from "@/lib/rate-limit/time-utils"; import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions"; import { getSystemSettings } from "@/repository/system-config"; import { @@ -31,9 +31,10 @@ import type { ActionResult } from "./types"; */ function parseDateRangeInServerTimezone( startDate?: string, - endDate?: string + endDate?: string, + timezone?: string ): { startTime?: number; endTime?: number } { - const timezone = getEnvConfig().TZ; + const tz = timezone ?? "UTC"; const toIsoDate = (dateStr: string): { ok: true; value: string } | { ok: false } => { return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) ? { ok: true, value: dateStr } : { ok: false }; @@ -58,12 +59,12 @@ function parseDateRangeInServerTimezone( const endIso = endDate ? toIsoDate(endDate) : { ok: false as const }; const parsedStart = startIso.ok - ? fromZonedTime(`${startIso.value}T00:00:00`, timezone).getTime() + ? fromZonedTime(`${startIso.value}T00:00:00`, tz).getTime() : Number.NaN; const endExclusiveDate = endIso.ok ? addIsoDays(endIso.value, 1) : null; const parsedEndExclusive = endExclusiveDate - ? fromZonedTime(`${endExclusiveDate}T00:00:00`, timezone).getTime() + ? fromZonedTime(`${endExclusiveDate}T00:00:00`, tz).getTime() : Number.NaN; return { @@ -193,7 +194,7 @@ async function sumUserCost(userId: number, period: "5h" | "weekly" | "monthly" | } // 其他周期:使用统一的时间范围计算 - const { startTime, endTime } = getTimeRangeForPeriod(period); + const { startTime, endTime } = await getTimeRangeForPeriod(period); return await sumUserCostInTimeRange(userId, startTime, endTime); } @@ -241,7 +242,7 @@ export async function getMyQuota(): Promise> { const { sumUserCostInTimeRange } = await import("@/repository/statistics"); // 计算用户每日消费的时间范围(使用用户的配置) - const userDailyTimeRange = getTimeRangeForPeriodWithMode( + const userDailyTimeRange = await getTimeRangeForPeriodWithMode( "daily", user.dailyResetTime ?? "00:00", (user.dailyResetMode as DailyResetMode | undefined) ?? "fixed" @@ -345,7 +346,7 @@ export async function getMyTodayStats(): Promise> { // 修复: 使用 Key 的 dailyResetTime 和 dailyResetMode 来计算时间范围 const { getTimeRangeForPeriodWithMode } = await import("@/lib/rate-limit/time-utils"); - const timeRange = getTimeRangeForPeriodWithMode( + const timeRange = await getTimeRangeForPeriodWithMode( "daily", session.key.dailyResetTime ?? "00:00", (session.key.dailyResetMode as DailyResetMode | undefined) ?? "fixed" @@ -354,8 +355,8 @@ export async function getMyTodayStats(): Promise> { const [aggregate] = await db .select({ calls: sql`count(*)::int`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, costUsd: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, }) .from(messageRequest) @@ -375,8 +376,8 @@ export async function getMyTodayStats(): Promise> { originalModel: messageRequest.originalModel, calls: sql`count(*)::int`, costUsd: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, }) .from(messageRequest) .where( @@ -444,9 +445,11 @@ export async function getMyUsageLogs( const pageSize = Math.min(rawPageSize, 100); const page = filters.page && filters.page > 0 ? filters.page : 1; + const timezone = await resolveSystemTimezone(); const { startTime, endTime } = parseDateRangeInServerTimezone( filters.startDate, - filters.endDate + filters.endDate, + timezone ); const usageFilters: UsageLogFilters = { @@ -586,9 +589,11 @@ export async function getMyStatsSummary( const settings = await getSystemSettings(); const currencyCode = settings.currencyDisplay; + const timezone = await resolveSystemTimezone(); const { startTime, endTime } = parseDateRangeInServerTimezone( filters.startDate, - filters.endDate + filters.endDate, + timezone ); // Get aggregated stats using existing repository function @@ -604,10 +609,10 @@ export async function getMyStatsSummary( model: messageRequest.model, requests: sql`count(*)::int`, cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, }) .from(messageRequest) .where( @@ -628,10 +633,10 @@ export async function getMyStatsSummary( model: messageRequest.model, requests: sql`count(*)::int`, cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, }) .from(messageRequest) .where( diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index ef431eb19..4584ba8af 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -3,6 +3,7 @@ import { getSession } from "@/lib/auth"; import type { NotificationJobType } from "@/lib/constants/notification.constants"; import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { WebhookNotifier } from "@/lib/webhook"; import { buildTestMessage } from "@/lib/webhook/templates/test-messages"; import { @@ -80,8 +81,9 @@ export async function testWebhookAction( try { const notifier = new WebhookNotifier(trimmedUrl, { maxRetries: 1 }); - const testMessage = buildTestMessage(type); - return notifier.send(testMessage); + const timezone = await resolveSystemTimezone(); + const testMessage = buildTestMessage(type, timezone); + return notifier.send(testMessage, { timezone }); } catch (error) { return { success: false, diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index be3e8bcda..47c6e7e70 100644 --- a/src/actions/provider-endpoints.ts +++ b/src/actions/provider-endpoints.ts @@ -21,6 +21,7 @@ import { deleteProviderVendor, findProviderEndpointById, findProviderEndpointProbeLogs, + findProviderEndpointsByVendor, findProviderEndpointsByVendorAndType, findProviderVendorById, findProviderVendors, @@ -185,6 +186,30 @@ export async function getProviderEndpoints(input: { } } +export async function getProviderEndpointsByVendor(input: { + vendorId: number; +}): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return []; + } + + const parsed = z.object({ vendorId: VendorIdSchema }).safeParse(input); + if (!parsed.success) { + logger.debug("getProviderEndpointsByVendor:invalid_input", { + error: parsed.error, + }); + return []; + } + + return await findProviderEndpointsByVendor(parsed.data.vendorId); + } catch (error) { + logger.error("getProviderEndpointsByVendor:error", error); + return []; + } +} + export async function addProviderEndpoint( input: unknown ): Promise> { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 7cbd01203..a4779e803 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -1,8 +1,11 @@ "use server"; +import { eq } from "drizzle-orm"; import { GeminiAuth } from "@/app/v1/_lib/gemini/auth"; import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; import { buildProxyUrl } from "@/app/v1/_lib/url"; +import { db } from "@/drizzle/db"; +import { providers as providersTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { publishProviderCacheInvalidation } from "@/lib/cache/provider-cache"; import { @@ -44,7 +47,13 @@ import { updateProvider, updateProviderPrioritiesBatch, } from "@/repository/provider"; -import { tryDeleteProviderVendorIfEmpty } from "@/repository/provider-endpoints"; +import { + backfillProviderEndpointsFromProviders, + computeVendorKey, + findProviderVendorById, + getOrCreateProviderVendorIdFromUrls, + tryDeleteProviderVendorIfEmpty, +} from "@/repository/provider-endpoints"; import type { CacheTtlPreference } from "@/types/cache"; import type { CodexParallelToolCallsPreference, @@ -1198,14 +1207,14 @@ export async function getProviderLimitUsage(providerId: number): Promise< ]); // 获取重置时间信息 - const reset5h = getResetInfo("5h"); - const resetDaily = getResetInfoWithMode( + const reset5h = await getResetInfo("5h"); + const resetDaily = await getResetInfoWithMode( "daily", provider.dailyResetTime, provider.dailyResetMode ?? "fixed" ); - const resetWeekly = getResetInfo("weekly"); - const resetMonthly = getResetInfo("monthly"); + const resetWeekly = await getResetInfo("weekly"); + const resetMonthly = await getResetInfo("monthly"); return { ok: true, @@ -1322,15 +1331,15 @@ export async function getProviderLimitUsageBatch( const sessionCount = sessionCountMap.get(provider.id) || 0; // 获取重置时间信息 - const reset5h = getResetInfo("5h"); + const reset5h = await getResetInfo("5h"); const dailyResetMode = (provider.dailyResetMode ?? "fixed") as "fixed" | "rolling"; - const resetDaily = getResetInfoWithMode( + const resetDaily = await getResetInfoWithMode( "daily", provider.dailyResetTime ?? undefined, dailyResetMode ); - const resetWeekly = getResetInfo("weekly"); - const resetMonthly = getResetInfo("monthly"); + const resetWeekly = await getResetInfo("weekly"); + const resetMonthly = await getResetInfo("monthly"); result.set(provider.id, { cost5h: { @@ -3548,3 +3557,189 @@ export async function getModelSuggestionsByProviderGroup( return { ok: false, error: "获取模型建议列表失败" }; } } + +// ============================================================================ +// Recluster Provider Vendors +// ============================================================================ + +type ReclusterChange = { + providerId: number; + providerName: string; + oldVendorId: number; + oldVendorDomain: string; + newVendorDomain: string; +}; + +type ReclusterResult = { + preview: { + providersMoved: number; + vendorsCreated: number; + vendorsToDelete: number; + skippedInvalidUrl: number; + }; + changes: ReclusterChange[]; + applied: boolean; +}; + +/** + * Recluster provider vendors based on updated clustering rules. + * When websiteUrl is empty, uses host:port as vendor key instead of just hostname. + * + * @param confirm - false=preview mode (calculate changes only), true=apply mode (execute changes) + */ +export async function reclusterProviderVendors(args: { + confirm: boolean; +}): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "NO_PERMISSION" }; + } + + const allProviders = await findAllProvidersFresh(); + + if (allProviders.length === 0) { + return { + ok: true, + data: { + preview: { + providersMoved: 0, + vendorsCreated: 0, + vendorsToDelete: 0, + skippedInvalidUrl: 0, + }, + changes: [], + applied: args.confirm, + }, + }; + } + + const changes: ReclusterChange[] = []; + const newVendorKeys = new Set(); + const oldVendorIds = new Set(); + let skippedInvalidUrl = 0; + + // Batch load all vendor data upfront to avoid N+1 queries + const uniqueVendorIds = [ + ...new Set( + allProviders + .map((p) => p.providerVendorId) + .filter((id): id is number => id !== null && id !== undefined && id > 0) + ), + ]; + const vendors = await Promise.all(uniqueVendorIds.map((id) => findProviderVendorById(id))); + const vendorMap = new Map( + vendors.filter((v): v is NonNullable => v !== null).map((v) => [v.id, v]) + ); + + // Build provider map for quick lookup in transaction + const providerMap = new Map(allProviders.map((p) => [p.id, p])); + + // Calculate new vendor key for each provider + for (const provider of allProviders) { + const newVendorKey = await computeVendorKey({ + providerUrl: provider.url, + websiteUrl: provider.websiteUrl, + }); + + if (!newVendorKey) { + skippedInvalidUrl++; + continue; + } + + // Get current vendor domain from pre-loaded map + const currentVendor = provider.providerVendorId + ? vendorMap.get(provider.providerVendorId) + : null; + const currentDomain = currentVendor?.websiteDomain ?? ""; + + // If key changed, record the change + if (currentDomain !== newVendorKey) { + newVendorKeys.add(newVendorKey); + if (provider.providerVendorId) { + oldVendorIds.add(provider.providerVendorId); + } + changes.push({ + providerId: provider.id, + providerName: provider.name, + oldVendorId: provider.providerVendorId ?? 0, + oldVendorDomain: currentDomain, + newVendorDomain: newVendorKey, + }); + } + } + + const preview = { + providersMoved: changes.length, + vendorsCreated: newVendorKeys.size, + vendorsToDelete: oldVendorIds.size, + skippedInvalidUrl, + }; + + // Preview mode: return without modifying DB + if (!args.confirm) { + return { + ok: true, + data: { + preview, + changes, + applied: false, + }, + }; + } + + // Apply mode: execute changes in transaction + if (changes.length > 0) { + await db.transaction(async (tx) => { + for (const change of changes) { + // Use pre-built map for O(1) lookup instead of O(N) find() + const provider = providerMap.get(change.providerId); + if (!provider) continue; + + // Get or create new vendor + const newVendorId = await getOrCreateProviderVendorIdFromUrls({ + providerUrl: provider.url, + websiteUrl: provider.websiteUrl ?? null, + }); + + // Update provider's vendorId + await tx + .update(providersTable) + .set({ providerVendorId: newVendorId, updatedAt: new Date() }) + .where(eq(providersTable.id, change.providerId)); + } + }); + + // Backfill provider_endpoints + await backfillProviderEndpointsFromProviders(); + + // Cleanup empty vendors + for (const oldVendorId of oldVendorIds) { + await tryDeleteProviderVendorIfEmpty(oldVendorId); + } + + // Publish cache invalidation + try { + await publishProviderCacheInvalidation(); + } catch (error) { + logger.warn("reclusterProviderVendors:cache_invalidation_failed", { + changedCount: changes.length, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + ok: true, + data: { + preview, + changes, + applied: true, + }, + }; + } catch (error) { + logger.error("reclusterProviderVendors:error", error); + const message = error instanceof Error ? error.message : "Recluster failed"; + return { ok: false, error: message }; + } +} diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 02545989f..9d6206701 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -1,9 +1,11 @@ "use server"; import { revalidatePath } from "next/cache"; +import { locales } from "@/i18n/config"; import { getSession } from "@/lib/auth"; import { invalidateSystemSettingsCache } from "@/lib/config"; import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; import { getSystemSettings, updateSystemSettings } from "@/repository/system-config"; import type { ResponseFixerConfig, SystemSettings } from "@/types/system-config"; @@ -24,12 +26,28 @@ export async function fetchSystemSettings(): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未授权" }; + } + + const timeZone = await resolveSystemTimezone(); + return { ok: true, data: { timeZone } }; + } catch (error) { + logger.error("获取时区失败:", error); + return { ok: false, error: "获取时区失败" }; + } +} + export async function saveSystemSettings(formData: { // 所有字段均为可选,支持部分更新 siteTitle?: string; allowGlobalUsageView?: boolean; currencyDisplay?: string; billingModelSource?: string; + timezone?: string | null; enableAutoCleanup?: boolean; cleanupRetentionDays?: number; cleanupSchedule?: string; @@ -42,6 +60,13 @@ export async function saveSystemSettings(formData: { enableCodexSessionIdCompletion?: boolean; enableResponseFixer?: boolean; responseFixerConfig?: Partial; + // Quota lease settings + quotaDbRefreshIntervalSeconds?: number; + quotaLeasePercent5h?: number; + quotaLeasePercentDaily?: number; + quotaLeasePercentWeekly?: number; + quotaLeasePercentMonthly?: number; + quotaLeaseCapUsd?: number | null; }): Promise> { try { const session = await getSession(); @@ -55,6 +80,7 @@ export async function saveSystemSettings(formData: { allowGlobalUsageView: validated.allowGlobalUsageView, currencyDisplay: validated.currencyDisplay, billingModelSource: validated.billingModelSource, + timezone: validated.timezone, enableAutoCleanup: validated.enableAutoCleanup, cleanupRetentionDays: validated.cleanupRetentionDays, cleanupSchedule: validated.cleanupSchedule, @@ -67,13 +93,22 @@ export async function saveSystemSettings(formData: { enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion, enableResponseFixer: validated.enableResponseFixer, responseFixerConfig: validated.responseFixerConfig, + quotaDbRefreshIntervalSeconds: validated.quotaDbRefreshIntervalSeconds, + quotaLeasePercent5h: validated.quotaLeasePercent5h, + quotaLeasePercentDaily: validated.quotaLeasePercentDaily, + quotaLeasePercentWeekly: validated.quotaLeasePercentWeekly, + quotaLeasePercentMonthly: validated.quotaLeasePercentMonthly, + quotaLeaseCapUsd: validated.quotaLeaseCapUsd, }); // Invalidate the system settings cache so proxy requests get fresh settings invalidateSystemSettingsCache(); - revalidatePath("/settings/config"); - revalidatePath("/dashboard"); + // Revalidate paths for all locales to ensure cache invalidation across i18n routes + for (const locale of locales) { + revalidatePath(`/${locale}/settings/config`); + revalidatePath(`/${locale}/dashboard`); + } revalidatePath("/", "layout"); return { ok: true, data: updated }; diff --git a/src/actions/users.ts b/src/actions/users.ts index 988b37c51..0e108e30b 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -10,8 +10,10 @@ import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions"; +import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { normalizeProviderGroup } from "@/lib/utils/provider-group"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { maskKey } from "@/lib/utils/validation"; import { formatZodError } from "@/lib/utils/zod-i18n"; import { CreateUserSchema, UpdateUserSchema } from "@/lib/validation/schemas"; @@ -1285,9 +1287,13 @@ export async function getUserLimitUsage(userId: number): Promise< // 获取每日消费(使用用户的 dailyResetTime 和 dailyResetMode 配置) const resetTime = user.dailyResetTime ?? "00:00"; const resetMode = user.dailyResetMode ?? "fixed"; - const { startTime, endTime } = getTimeRangeForPeriodWithMode("daily", resetTime, resetMode); + const { startTime, endTime } = await getTimeRangeForPeriodWithMode( + "daily", + resetTime, + resetMode + ); const dailyCost = await sumUserCostInTimeRange(userId, startTime, endTime); - const resetInfo = getResetInfoWithMode("daily", resetTime, resetMode); + const resetInfo = await getResetInfoWithMode("daily", resetTime, resetMode); const resetAt = resetInfo.resetAt; return { @@ -1336,8 +1342,9 @@ export async function renewUser( }; } - // Parse and validate expiration date - const expiresAt = new Date(data.expiresAt); + // Parse and validate expiration date (using system timezone) + const timezone = await resolveSystemTimezone(); + const expiresAt = parseDateInputAsTimezone(data.expiresAt, timezone); // 验证过期时间 const validationResult = await validateExpiresAt(expiresAt, tError); @@ -1477,10 +1484,10 @@ export async function getUserAllLimitUsage(userId: number): Promise< const { sumUserCostInTimeRange, sumUserTotalCost } = await import("@/repository/statistics"); // 获取各时间范围 - const range5h = getTimeRangeForPeriod("5h"); - const rangeDaily = getTimeRangeForPeriod("daily", user.dailyResetTime || "00:00"); - const rangeWeekly = getTimeRangeForPeriod("weekly"); - const rangeMonthly = getTimeRangeForPeriod("monthly"); + const range5h = await getTimeRangeForPeriod("5h"); + const rangeDaily = await getTimeRangeForPeriod("daily", user.dailyResetTime || "00:00"); + const rangeWeekly = await getTimeRangeForPeriod("weekly"); + const rangeMonthly = await getTimeRangeForPeriod("monthly"); // 并行查询各时间范围的消费 const [usage5h, usageDaily, usageWeekly, usageMonthly, usageTotal] = await Promise.all([ diff --git a/src/actions/webhook-targets.ts b/src/actions/webhook-targets.ts index 85e31bfc6..ffd44efeb 100644 --- a/src/actions/webhook-targets.ts +++ b/src/actions/webhook-targets.ts @@ -5,6 +5,7 @@ import { getSession } from "@/lib/auth"; import type { NotificationJobType } from "@/lib/constants/notification.constants"; import { logger } from "@/lib/logger"; import { isValidProxyUrl } from "@/lib/proxy-agent"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { WebhookNotifier } from "@/lib/webhook"; import { buildTestMessage } from "@/lib/webhook/templates/test-messages"; import { getNotificationSettings, updateNotificationSettings } from "@/repository/notifications"; @@ -380,12 +381,14 @@ export async function testWebhookTargetAction( } const validatedType = NotificationTypeSchema.parse(notificationType); - const testMessage = buildTestMessage(toJobType(validatedType)); + const timezone = await resolveSystemTimezone(); + const testMessage = buildTestMessage(toJobType(validatedType), timezone); const notifier = new WebhookNotifier(target); const result = await notifier.send(testMessage, { notificationType: validatedType, data: buildTestData(validatedType), + timezone, }); const latencyMs = Date.now() - start; diff --git a/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx index c5a347cc6..a813b725f 100644 --- a/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx +++ b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx @@ -9,6 +9,7 @@ import type { OverviewData } from "@/actions/overview"; import { getOverviewData } from "@/actions/overview"; import { getUserStatistics } from "@/actions/statistics"; import type { CurrencyCode } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/currency"; import type { LeaderboardEntry, @@ -202,10 +203,9 @@ export function DashboardBento({ return (
- {/* Top Section: Metrics + Live Sessions */} + {/* Section 1: Metrics (Admin only) */} {isAdmin && ( - {/* Metric Cards */} )} - {/* Middle Section: Statistics Chart + Live Sessions (Admin) */} - - {/* Statistics Chart - 3 columns for admin, 4 columns for non-admin */} - {statistics && ( - - )} - - {/* Live Sessions Panel - Right sidebar, spans 2 rows */} - {isAdmin && ( - - )} + {/* Section 2: Statistics Chart - Full width */} + {statistics && ( + + )} - {/* Leaderboard Cards - Below chart, 3 columns */} - {canViewLeaderboard && ( + {/* Section 3: Leaderboards + Live Sessions */} + {canViewLeaderboard && ( +
- )} - {canViewLeaderboard && ( - )} - {canViewLeaderboard && ( - )} - + + {isAdmin && ( + + )} +
+ )}
); } diff --git a/src/app/[locale]/dashboard/_components/bento/leaderboard-card.tsx b/src/app/[locale]/dashboard/_components/bento/leaderboard-card.tsx index 72b027c8d..87fe19731 100644 --- a/src/app/[locale]/dashboard/_components/bento/leaderboard-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/leaderboard-card.tsx @@ -166,7 +166,7 @@ export function LeaderboardCard({ const maxCost = Math.max(...entries.map((e) => e.totalCost), 0); return ( - + {/* Header */}

{title}

diff --git a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx index 3f9eecc78..756a6f45c 100644 --- a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -1,11 +1,13 @@ "use client"; -import { useLocale, useTranslations } from "next-intl"; +import { formatInTimeZone } from "date-fns-tz"; +import { useLocale, useTimeZone, useTranslations } from "next-intl"; import * as React from "react"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; import type { CurrencyCode } from "@/lib/utils"; import { cn, Decimal, formatCurrency, toDecimal } from "@/lib/utils"; +import { getDateFnsLocale } from "@/lib/utils/date-format"; import type { TimeRange, UserStatisticsData } from "@/types/statistics"; import { TIME_RANGE_OPTIONS } from "@/types/statistics"; import { BentoCard } from "./bento-grid"; @@ -29,7 +31,6 @@ export interface StatisticsChartCardProps { data: UserStatisticsData; onTimeRangeChange?: (timeRange: TimeRange) => void; currencyCode?: CurrencyCode; - colSpan?: 3 | 4; className?: string; } @@ -37,11 +38,12 @@ export function StatisticsChartCard({ data, onTimeRangeChange, currencyCode = "USD", - colSpan = 4, className, }: StatisticsChartCardProps) { const t = useTranslations("dashboard.statistics"); const locale = useLocale(); + const timeZone = useTimeZone() ?? "UTC"; + const dateFnsLocale = getDateFnsLocale(locale); const [activeChart, setActiveChart] = React.useState<"cost" | "calls">("cost"); const [chartMode, setChartMode] = React.useState<"stacked" | "overlay">("overlay"); @@ -152,34 +154,21 @@ export function StatisticsChartCard({ const formatDate = (dateStr: string) => { const date = new Date(dateStr); if (data.resolution === "hour") { - return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); + return formatInTimeZone(date, timeZone, "HH:mm", { locale: dateFnsLocale }); } - return date.toLocaleDateString(locale, { month: "numeric", day: "numeric" }); + return formatInTimeZone(date, timeZone, "M/d", { locale: dateFnsLocale }); }; const formatTooltipDate = (dateStr: string) => { const date = new Date(dateStr); if (data.resolution === "hour") { - return date.toLocaleString(locale, { - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + return formatInTimeZone(date, timeZone, "MMMM d HH:mm", { locale: dateFnsLocale }); } - return date.toLocaleDateString(locale, { - year: "numeric", - month: "long", - day: "numeric", - }); + return formatInTimeZone(date, timeZone, "yyyy MMMM d", { locale: dateFnsLocale }); }; return ( - + {/* Header */}
diff --git a/src/app/[locale]/dashboard/_components/dashboard-main.tsx b/src/app/[locale]/dashboard/_components/dashboard-main.tsx index 61647c9fa..43de60b5e 100644 --- a/src/app/[locale]/dashboard/_components/dashboard-main.tsx +++ b/src/app/[locale]/dashboard/_components/dashboard-main.tsx @@ -10,12 +10,14 @@ interface DashboardMainProps { export function DashboardMain({ children }: DashboardMainProps) { const pathname = usePathname(); + const normalizedPathname = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; + // Pattern to match /dashboard/sessions/[id]/messages // The usePathname hook from next-intl/routing might return the path without locale prefix if configured that way, // or we just check for the suffix. // Let's be safe and check if it includes "/dashboard/sessions/" and ends with "/messages" const isSessionMessagesPage = - pathname.includes("/dashboard/sessions/") && pathname.endsWith("/messages"); + normalizedPathname.includes("/dashboard/sessions/") && normalizedPathname.endsWith("/messages"); if (isSessionMessagesPage) { return
{children}
; diff --git a/src/app/[locale]/dashboard/_components/rate-limit-events-chart.tsx b/src/app/[locale]/dashboard/_components/rate-limit-events-chart.tsx index 485458fd8..a38594afa 100644 --- a/src/app/[locale]/dashboard/_components/rate-limit-events-chart.tsx +++ b/src/app/[locale]/dashboard/_components/rate-limit-events-chart.tsx @@ -1,10 +1,12 @@ "use client"; -import { useLocale, useTranslations } from "next-intl"; +import { formatInTimeZone } from "date-fns-tz"; +import { useLocale, useTimeZone, useTranslations } from "next-intl"; import * as React from "react"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; +import { getDateFnsLocale } from "@/lib/utils/date-format"; import type { EventTimeline } from "@/types/statistics"; export interface RateLimitEventsChartProps { @@ -18,6 +20,8 @@ export interface RateLimitEventsChartProps { export function RateLimitEventsChart({ data }: RateLimitEventsChartProps) { const t = useTranslations("dashboard.rateLimits.chart"); const locale = useLocale(); + const timeZone = useTimeZone() ?? "UTC"; + const dateFnsLocale = getDateFnsLocale(locale); const chartConfig = React.useMemo( () => @@ -30,27 +34,16 @@ export function RateLimitEventsChart({ data }: RateLimitEventsChartProps) { [t] ); - // 格式化小时显示 + // Format hour display with timezone const formatHour = (hourStr: string) => { const date = new Date(hourStr); - return date.toLocaleTimeString(locale, { - month: "numeric", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + return formatInTimeZone(date, timeZone, "M/d HH:mm", { locale: dateFnsLocale }); }; - // 格式化 tooltip 显示 + // Format tooltip display with timezone const formatTooltipHour = (hourStr: string) => { const date = new Date(hourStr); - return date.toLocaleString(locale, { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + return formatInTimeZone(date, timeZone, "yyyy MMMM d HH:mm", { locale: dateFnsLocale }); }; // 计算总事件数 diff --git a/src/app/[locale]/dashboard/_components/statistics/chart.tsx b/src/app/[locale]/dashboard/_components/statistics/chart.tsx index 9e510e5ec..016d6ac58 100644 --- a/src/app/[locale]/dashboard/_components/statistics/chart.tsx +++ b/src/app/[locale]/dashboard/_components/statistics/chart.tsx @@ -1,12 +1,14 @@ "use client"; -import { useLocale, useTranslations } from "next-intl"; +import { formatInTimeZone } from "date-fns-tz"; +import { useLocale, useTimeZone, useTranslations } from "next-intl"; import * as React from "react"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartLegend, ChartTooltip } from "@/components/ui/chart"; import type { CurrencyCode } from "@/lib/utils"; import { cn, Decimal, formatCurrency, toDecimal } from "@/lib/utils"; +import { getDateFnsLocale } from "@/lib/utils/date-format"; import type { TimeRange, UserStatisticsData } from "@/types/statistics"; import { TimeRangeSelector } from "./time-range-selector"; @@ -58,6 +60,8 @@ export function UserStatisticsChart({ }: UserStatisticsChartProps) { const t = useTranslations("dashboard.statistics"); const locale = useLocale(); + const timeZone = useTimeZone() ?? "UTC"; + const dateFnsLocale = getDateFnsLocale(locale); const [activeChart, setActiveChart] = React.useState<"cost" | "calls">("cost"); const [chartMode, setChartMode] = React.useState<"stacked" | "overlay">("overlay"); @@ -230,34 +234,19 @@ export function UserStatisticsChart({ const formatDate = (dateStr: string) => { const date = new Date(dateStr); if (data.resolution === "hour") { - return date.toLocaleTimeString(locale, { - hour: "2-digit", - minute: "2-digit", - }); + return formatInTimeZone(date, timeZone, "HH:mm", { locale: dateFnsLocale }); } else { - return date.toLocaleDateString(locale, { - month: "numeric", - day: "numeric", - }); + return formatInTimeZone(date, timeZone, "M/d", { locale: dateFnsLocale }); } }; - // 格式化tooltip日期 + // Format tooltip date with timezone const formatTooltipDate = (dateStr: string) => { const date = new Date(dateStr); if (data.resolution === "hour") { - return date.toLocaleString(locale, { - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + return formatInTimeZone(date, timeZone, "MMMM d HH:mm", { locale: dateFnsLocale }); } else { - return date.toLocaleDateString(locale, { - year: "numeric", - month: "long", - day: "numeric", - }); + return formatInTimeZone(date, timeZone, "yyyy MMMM d", { locale: dateFnsLocale }); } }; 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 e80a11d94..84efc15b1 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx @@ -1,7 +1,8 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { formatInTimeZone } from "date-fns-tz"; import { CheckCircle, Copy, Eye, EyeOff, ListPlus } from "lucide-react"; -import { useLocale, useTranslations } from "next-intl"; +import { useLocale, useTimeZone, useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; import { getProxyStatus } from "@/actions/proxy-status"; import { FormErrorBoundary } from "@/components/form-error-boundary"; @@ -37,7 +38,10 @@ async function fetchProxyStatus(): Promise { throw new Error(result.error || "Failed to fetch proxy status"); } -function createFormatRelativeTime(t: (key: string, params?: Record) => string) { +function createFormatRelativeTime( + t: (key: string, params?: Record) => string, + timeZone: string +) { return (timestamp: number): string => { const diff = Date.now() - timestamp; if (diff <= 0) { @@ -67,7 +71,7 @@ function createFormatRelativeTime(t: (key: string, params?: Record { @@ -108,7 +113,7 @@ export function KeyListHeader({ const totalTodayUsage = activeUser?.keys.reduce((sum, key) => sum + (key.todayUsage ?? 0), 0) ?? 0; - const formatRelativeTime = useMemo(() => createFormatRelativeTime(t), [t]); + const formatRelativeTime = useMemo(() => createFormatRelativeTime(t, timeZone), [t, timeZone]); // 获取用户状态和过期信息 const userStatusInfo = useMemo(() => { diff --git a/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx b/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx index 6a552a79d..99833956b 100644 --- a/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx +++ b/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx @@ -8,7 +8,6 @@ import { cn } from "@/lib/utils"; import { EndpointTab } from "./endpoint/endpoint-tab"; import { OverviewSection } from "./overview/overview-section"; import { ProviderTab } from "./provider/provider-tab"; -import { FloatingProbeButton } from "./shared/floating-probe-button"; export type TimeRangeOption = "15min" | "1h" | "6h" | "24h" | "7d"; @@ -166,9 +165,6 @@ export function AvailabilityDashboard() { - - {/* Floating Probe Button */} -
); } diff --git a/src/app/[locale]/dashboard/availability/_components/availability-view.tsx b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx index 5aad4c0e5..503d1aea7 100644 --- a/src/app/[locale]/dashboard/availability/_components/availability-view.tsx +++ b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx @@ -1,7 +1,7 @@ "use client"; import { Activity, CheckCircle2, HelpCircle, RefreshCw, XCircle } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTimeZone, useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -21,6 +21,7 @@ import type { TimeBucketMetrics, } from "@/lib/availability"; import { cn } from "@/lib/utils"; +import { formatDate } from "@/lib/utils/date-format"; import { EndpointProbeHistory } from "./endpoint-probe-history"; type TimeRangeOption = "15min" | "1h" | "6h" | "24h" | "7d"; @@ -63,31 +64,24 @@ function getAvailabilityColor(score: number, hasData: boolean): string { /** * Format bucket time for display in tooltip */ -function formatBucketTime(isoString: string, bucketSizeMinutes: number): string { +function formatBucketTime( + isoString: string, + bucketSizeMinutes: number, + locale: string, + timeZone?: string +): string { const date = new Date(isoString); + const tz = timeZone ?? "UTC"; if (bucketSizeMinutes >= 1440) { - // Daily buckets: show date - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return formatDate(date, "MMM d", locale, tz); } if (bucketSizeMinutes >= 60) { - // Hourly buckets: show date + hour - return date.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + return formatDate(date, "MMM d HH:mm", locale, tz); } - // Sub-hour buckets: show full time with seconds for precision if (bucketSizeMinutes < 1) { - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + return formatDate(date, "HH:mm:ss", locale, tz); } - // Minute buckets: show time - return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return formatDate(date, "HH:mm", locale, tz); } /** @@ -107,6 +101,8 @@ function _formatBucketSizeDisplay(minutes: number): string { export function AvailabilityView() { const t = useTranslations("dashboard.availability"); + const timeZone = useTimeZone() ?? "UTC"; + const locale = useLocale(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -455,7 +451,12 @@ export function AvailabilityView() {
- {formatBucketTime(bucketStart, data?.bucketSizeMinutes ?? 5)} + {formatBucketTime( + bucketStart, + data?.bucketSizeMinutes ?? 5, + locale, + timeZone + )}
{hasData && bucket ? ( <> diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx index 26ad54dc3..3f48d9a03 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx @@ -1,7 +1,8 @@ "use client"; +import { formatInTimeZone } from "date-fns-tz"; import { Activity, CheckCircle2, Play, RefreshCw, XCircle } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { getProviderVendors, probeProviderEndpoint } from "@/actions/provider-endpoints"; @@ -44,6 +45,7 @@ const PROVIDER_TYPES: ProviderType[] = [ export function EndpointProbeHistory() { const t = useTranslations("dashboard.availability"); const tErrors = useTranslations("errors"); + const timeZone = useTimeZone() ?? "UTC"; const [vendors, setVendors] = useState([]); const [selectedVendorId, setSelectedVendorId] = useState(""); @@ -259,7 +261,7 @@ export function EndpointProbeHistory() { logs.map((log) => ( - {new Date(log.createdAt).toLocaleString()} + {formatInTimeZone(new Date(log.createdAt), timeZone, "yyyy-MM-dd HH:mm:ss")}
{t(`probeHistory.${log.source === "manual" ? "manual" : "auto"}`)}
diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx index c773a0d80..ac4794a08 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx @@ -1,6 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; +import { formatInTimeZone } from "date-fns-tz"; +import { useTimeZone, useTranslations } from "next-intl"; import { useMemo } from "react"; import { CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; import { @@ -26,6 +27,7 @@ const chartConfig = { export function LatencyCurve({ logs, className }: LatencyCurveProps) { const t = useTranslations("dashboard.availability.latencyCurve"); + const timeZone = useTimeZone() ?? "UTC"; // Transform logs to chart data const chartData = useMemo(() => { @@ -56,11 +58,7 @@ export function LatencyCurve({ logs, className }: LatencyCurveProps) { const formatTime = (time: string) => { const date = new Date(time); - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + return formatInTimeZone(date, timeZone, "HH:mm:ss"); }; const formatLatency = (value: number) => { diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx index a3de6d58e..03d05bf91 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx @@ -1,7 +1,8 @@ "use client"; +import { formatInTimeZone } from "date-fns-tz"; import { CheckCircle2, HelpCircle, XCircle } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ProviderEndpoint } from "@/types/provider"; @@ -47,9 +48,12 @@ function formatLatency(ms: number | null): string { return `${(ms / 1000).toFixed(2)}s`; } -function formatTime(date: Date | string | null): string { +function formatTime(date: Date | string | null, timeZone?: string): string { if (!date) return "-"; const d = typeof date === "string" ? new Date(date) : date; + if (timeZone) { + return formatInTimeZone(d, timeZone, "HH:mm:ss"); + } return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", @@ -64,6 +68,7 @@ export function ProbeGrid({ className, }: ProbeGridProps) { const t = useTranslations("dashboard.availability.probeGrid"); + const timeZone = useTimeZone() ?? "UTC"; if (endpoints.length === 0) { return ( @@ -126,7 +131,7 @@ export function ProbeGrid({
{t("lastProbe")} - {formatTime(endpoint.lastProbedAt)} + {formatTime(endpoint.lastProbedAt, timeZone)}
diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx index b31213083..1c89546fc 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx @@ -1,7 +1,8 @@ "use client"; +import { formatInTimeZone } from "date-fns-tz"; import { AlertCircle, CheckCircle2, Download, Trash2, XCircle } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -15,8 +16,11 @@ interface ProbeTerminalProps { className?: string; } -function formatTime(date: Date | string): string { +function formatTime(date: Date | string, timeZone?: string): string { const d = typeof date === "string" ? new Date(date) : date; + if (timeZone) { + return formatInTimeZone(d, timeZone, "HH:mm:ss"); + } return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", @@ -68,6 +72,7 @@ export function ProbeTerminal({ className, }: ProbeTerminalProps) { const t = useTranslations("dashboard.availability.terminal"); + const timeZone = useTimeZone() ?? "UTC"; const containerRef = useRef(null); const [userScrolled, setUserScrolled] = useState(false); const [filter, setFilter] = useState(""); @@ -103,7 +108,7 @@ export function ProbeTerminal({ const handleDownload = () => { const content = filteredLogs .map((log) => { - const time = formatTime(log.createdAt); + const time = formatTime(log.createdAt, timeZone); const status = log.ok ? "OK" : "FAIL"; const latency = formatLatency(log.latencyMs); const error = log.errorMessage || ""; @@ -199,7 +204,7 @@ export function ProbeTerminal({ > {/* Timestamp */} - [{formatTime(log.createdAt)}] + [{formatTime(log.createdAt, timeZone)}] {/* Status */} @@ -257,7 +262,7 @@ export function ProbeTerminal({ {/* Loading indicator */} {logs.length > 0 && (
- [{formatTime(new Date())}] + [{formatTime(new Date(), timeZone)}] ...
)} diff --git a/src/app/[locale]/dashboard/availability/_components/provider/lane-chart.tsx b/src/app/[locale]/dashboard/availability/_components/provider/lane-chart.tsx index 59966d575..5982b33ce 100644 --- a/src/app/[locale]/dashboard/availability/_components/provider/lane-chart.tsx +++ b/src/app/[locale]/dashboard/availability/_components/provider/lane-chart.tsx @@ -1,6 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; +import { formatInTimeZone } from "date-fns-tz"; +import { useTimeZone, useTranslations } from "next-intl"; import { useMemo } from "react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import type { ProviderAvailabilitySummary, TimeBucketMetrics } from "@/lib/availability"; @@ -38,27 +39,19 @@ function getStatusColor(status: string): string { } } -function formatBucketTime(isoString: string, bucketSizeMinutes: number): string { +function formatBucketTime(isoString: string, bucketSizeMinutes: number, timeZone?: string): string { const date = new Date(isoString); + const tz = timeZone ?? "UTC"; if (bucketSizeMinutes >= 1440) { - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return formatInTimeZone(date, tz, "MMM d"); } if (bucketSizeMinutes >= 60) { - return date.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + return formatInTimeZone(date, tz, "MMM d HH:mm"); } if (bucketSizeMinutes < 1) { - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + return formatInTimeZone(date, tz, "HH:mm:ss"); } - return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return formatInTimeZone(date, tz, "HH:mm"); } function formatLatency(ms: number): string { @@ -79,6 +72,7 @@ export function LaneChart({ className, }: LaneChartProps) { const t = useTranslations("dashboard.availability.laneChart"); + const timeZone = useTimeZone() ?? "UTC"; // Generate unified time buckets const unifiedBuckets = useMemo(() => { @@ -106,12 +100,12 @@ export function LaneChart({ for (let i = 0; i < unifiedBuckets.length; i += step) { labels.push({ position: (i / unifiedBuckets.length) * 100, - label: formatBucketTime(unifiedBuckets[i], bucketSizeMinutes), + label: formatBucketTime(unifiedBuckets[i], bucketSizeMinutes, timeZone), }); } return labels; - }, [unifiedBuckets, bucketSizeMinutes]); + }, [unifiedBuckets, bucketSizeMinutes, timeZone]); const getBucketData = ( provider: ProviderAvailabilitySummary, @@ -215,6 +209,7 @@ export function LaneChart({ bucketStart={bucketStart} bucket={bucket} bucketSizeMinutes={bucketSizeMinutes} + timeZone={timeZone} /> @@ -255,6 +250,7 @@ export function LaneChart({ bucketStart={bucketStart} bucket={bucket} bucketSizeMinutes={bucketSizeMinutes} + timeZone={timeZone} /> @@ -301,17 +297,21 @@ function BucketTooltip({ bucketStart, bucket, bucketSizeMinutes, + timeZone, }: { bucketStart: string; bucket: TimeBucketMetrics | null; bucketSizeMinutes: number; + timeZone: string; }) { const t = useTranslations("dashboard.availability.laneChart"); const hasData = bucket !== null && bucket.totalRequests > 0; return (
-
{formatBucketTime(bucketStart, bucketSizeMinutes)}
+
+ {formatBucketTime(bucketStart, bucketSizeMinutes, timeZone)} +
{hasData && bucket ? ( <>
{t("requests", { count: bucket.totalRequests })}
diff --git a/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx b/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx index 0962fb007..0c496fac1 100644 --- a/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx +++ b/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx @@ -1,6 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; +import { formatInTimeZone } from "date-fns-tz"; +import { useTimeZone, useTranslations } from "next-intl"; import { useMemo } from "react"; import { Area, AreaChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from "recharts"; import { @@ -34,6 +35,7 @@ const chartConfig = { export function LatencyChart({ providers, className }: LatencyChartProps) { const t = useTranslations("dashboard.availability.latencyChart"); + const timeZone = useTimeZone() ?? "UTC"; // Aggregate latency data across all providers const chartData = useMemo(() => { @@ -85,10 +87,7 @@ export function LatencyChart({ providers, className }: LatencyChartProps) { const formatTime = (time: string) => { const date = new Date(time); - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - }); + return formatInTimeZone(date, timeZone, "HH:mm"); }; const formatLatency = (value: number) => { diff --git a/src/app/[locale]/dashboard/logs/_components/active-sessions-skeleton.tsx b/src/app/[locale]/dashboard/logs/_components/active-sessions-skeleton.tsx index 3e491d6e8..795e6ce33 100644 --- a/src/app/[locale]/dashboard/logs/_components/active-sessions-skeleton.tsx +++ b/src/app/[locale]/dashboard/logs/_components/active-sessions-skeleton.tsx @@ -1,47 +1,32 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -function CardSkeleton() { +export function ActiveSessionsSkeleton() { return ( - - -
- - -
- - -
- - +
+
+
+ + +
- - - ); -} + +
-export function ActiveSessionsSkeleton() { - return ( - - -
-
- -
- - +
+
+ {Array.from({ length: 5 }).map((_, idx) => ( +
+
+ + + + + +
-
- -
- - -
- {[1, 2, 3].map((i) => ( - ))}
-
- +
+
); } 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 9bc0bf5b4..ecdcd3e6b 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 @@ -216,7 +216,7 @@ export function LogicTraceTab({ {t("logicTrace.sessionInfo")}
-
+
{sessionReuseContext?.sessionId && (
@@ -252,14 +252,14 @@ export function LogicTraceTab({ {t("logicTrace.reusedProvider")}
-
-
+
+
Provider:{" "} - {sessionReuseProvider.name} + {sessionReuseProvider.name}
-
+
ID:{" "} - {sessionReuseProvider.id} + {sessionReuseProvider.id}
{sessionReuseProvider.priority !== undefined && (
@@ -301,23 +301,23 @@ export function LogicTraceTab({ subtitle={`${decisionContext.totalProviders} -> ${decisionContext.afterModelFilter || decisionContext.afterHealthCheck}`} status="success" details={ -
-
+
+
Total:{" "} {decisionContext.totalProviders}
-
+
Enabled:{" "} {decisionContext.enabledProviders}
{decisionContext.afterGroupFilter !== undefined && ( -
+
After Group:{" "} {decisionContext.afterGroupFilter}
)} {decisionContext.afterModelFilter !== undefined && ( -
+
After Model:{" "} {decisionContext.afterModelFilter}
@@ -336,14 +336,24 @@ export function LogicTraceTab({ subtitle={`${filteredProviders.length} providers filtered`} status="warning" details={ -
+
{filteredProviders.map((p, idx) => ( -
- +
+ {p.name} - {tChain(`filterReasons.${p.reason}`)} - {p.details && ({p.details})} + + {tChain(`filterReasons.${p.reason}`)} + + {p.details && ( + ({p.details}) + )}
))}
@@ -468,13 +478,13 @@ export function LogicTraceTab({ {t("logicTrace.sessionReuseTitle")}
-
+
{item.decisionContext.sessionId && ( -
- +
+ {tChain("timeline.sessionId", { id: "" }).replace(": ", ":")} - + {item.decisionContext.sessionId}
@@ -487,23 +497,23 @@ export function LogicTraceTab({ )} {/* Basic Info */} -
-
+
+
Provider ID:{" "} - {item.id} + {item.id}
{item.selectionMethod && !isSessionReuse && ( -
+
{tChain("details.selectionMethod")}: {" "} - {item.selectionMethod} + {item.selectionMethod}
)} {isSessionReuse && ( -
+
Provider:{" "} - {item.name} + {item.name}
)}
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/StepCard.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/StepCard.tsx index 324b4582e..05e9af936 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/StepCard.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/StepCard.tsx @@ -173,7 +173,9 @@ export function StepCard({ {/* Expandable details */} {hasDetails && isExpanded && ( -
{details}
+
+ {details} +
)}
diff --git a/src/app/[locale]/dashboard/logs/_components/filters/time-filters.tsx b/src/app/[locale]/dashboard/logs/_components/filters/time-filters.tsx index b9cee3006..900f4191d 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/time-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/time-filters.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; import { useTranslations } from "next-intl"; import { useCallback, useMemo } from "react"; import { Input } from "@/components/ui/input"; @@ -16,16 +17,23 @@ import type { UsageLogFilters } from "./types"; interface TimeFiltersProps { filters: UsageLogFilters; onFiltersChange: (filters: UsageLogFilters) => void; + serverTimeZone?: string; } -export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { +export function TimeFilters({ filters, onFiltersChange, serverTimeZone }: TimeFiltersProps) { const t = useTranslations("dashboard.logs.filters"); // Helper: convert timestamp to display date string (YYYY-MM-DD) - const timestampToDateString = useCallback((timestamp: number): string => { - const date = new Date(timestamp); - return format(date, "yyyy-MM-dd"); - }, []); + const timestampToDateString = useCallback( + (timestamp: number): string => { + const date = new Date(timestamp); + if (serverTimeZone) { + return formatInTimeZone(date, serverTimeZone, "yyyy-MM-dd"); + } + return format(date, "yyyy-MM-dd"); + }, + [serverTimeZone] + ); // Memoized startDate for display (from timestamp) const displayStartDate = useMemo(() => { @@ -35,21 +43,25 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { const displayStartClock = useMemo(() => { if (!filters.startTime) return undefined; - return formatClockFromTimestamp(filters.startTime); - }, [filters.startTime]); + return formatClockFromTimestamp(filters.startTime, serverTimeZone); + }, [filters.startTime, serverTimeZone]); // Memoized endDate calculation: endTime is exclusive, use endTime-1s to infer inclusive display end date const displayEndDate = useMemo(() => { if (!filters.endTime) return undefined; const inclusiveEndTime = inclusiveEndTimestampFromExclusive(filters.endTime); - return format(new Date(inclusiveEndTime), "yyyy-MM-dd"); - }, [filters.endTime]); + const date = new Date(inclusiveEndTime); + if (serverTimeZone) { + return formatInTimeZone(date, serverTimeZone, "yyyy-MM-dd"); + } + return format(date, "yyyy-MM-dd"); + }, [filters.endTime, serverTimeZone]); const displayEndClock = useMemo(() => { if (!filters.endTime) return undefined; const inclusiveEndTime = inclusiveEndTimestampFromExclusive(filters.endTime); - return formatClockFromTimestamp(inclusiveEndTime); - }, [filters.endTime]); + return formatClockFromTimestamp(inclusiveEndTime, serverTimeZone); + }, [filters.endTime, serverTimeZone]); // Memoized callback for date range changes const handleDateRangeChange = useCallback( @@ -57,8 +69,16 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { if (range.startDate && range.endDate) { const startClock = displayStartClock ?? "00:00:00"; const endClock = displayEndClock ?? "23:59:59"; - const startTimestamp = dateStringWithClockToTimestamp(range.startDate, startClock); - const endInclusiveTimestamp = dateStringWithClockToTimestamp(range.endDate, endClock); + const startTimestamp = dateStringWithClockToTimestamp( + range.startDate, + startClock, + serverTimeZone + ); + const endInclusiveTimestamp = dateStringWithClockToTimestamp( + range.endDate, + endClock, + serverTimeZone + ); if (startTimestamp === undefined || endInclusiveTimestamp === undefined) { onFiltersChange({ ...filters, @@ -81,7 +101,7 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { }); } }, - [displayEndClock, displayStartClock, filters, onFiltersChange] + [displayEndClock, displayStartClock, filters, onFiltersChange, serverTimeZone] ); const handleStartTimeChange = useCallback( @@ -89,14 +109,14 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { const nextClock = e.target.value || "00:00:00"; if (!filters.startTime) return; const dateStr = timestampToDateString(filters.startTime); - const startTime = dateStringWithClockToTimestamp(dateStr, nextClock); + const startTime = dateStringWithClockToTimestamp(dateStr, nextClock, serverTimeZone); if (startTime === undefined) return; onFiltersChange({ ...filters, startTime, }); }, - [filters, onFiltersChange, timestampToDateString] + [filters, onFiltersChange, timestampToDateString, serverTimeZone] ); const handleEndTimeChange = useCallback( @@ -105,14 +125,18 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { if (!filters.endTime) return; const inclusiveEndTime = inclusiveEndTimestampFromExclusive(filters.endTime); const endDateStr = timestampToDateString(inclusiveEndTime); - const endInclusiveTimestamp = dateStringWithClockToTimestamp(endDateStr, nextClock); + const endInclusiveTimestamp = dateStringWithClockToTimestamp( + endDateStr, + nextClock, + serverTimeZone + ); if (endInclusiveTimestamp === undefined) return; onFiltersChange({ ...filters, endTime: endInclusiveTimestamp + 1000, }); }, - [filters, onFiltersChange, timestampToDateString] + [filters, onFiltersChange, timestampToDateString, serverTimeZone] ); return ( @@ -123,6 +147,7 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { startDate={displayStartDate} endDate={displayEndDate} onDateRangeChange={handleDateRangeChange} + serverTimeZone={serverTimeZone} />
diff --git a/src/app/[locale]/dashboard/logs/_components/filters/types.ts b/src/app/[locale]/dashboard/logs/_components/filters/types.ts index 3f1ca0073..dabef5119 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/filters/types.ts @@ -9,9 +9,9 @@ export interface UsageLogFilters { keyId?: number; providerId?: number; sessionId?: string; - /** Start timestamp (ms, local timezone 00:00:00) */ + /** Start timestamp (ms, system timezone 00:00:00) */ startTime?: number; - /** End timestamp (ms, local timezone next day 00:00:00, for < comparison) */ + /** End timestamp (ms, system timezone next day 00:00:00, for < comparison) */ endTime?: number; statusCode?: number; excludeStatusCode200?: boolean; diff --git a/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx b/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx index e9e9b5d6b..3a18857f0 100644 --- a/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx +++ b/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx @@ -1,6 +1,6 @@ "use client"; -import { addDays, differenceInCalendarDays, format, subDays } from "date-fns"; +import { addDays, differenceInCalendarDays, format } from "date-fns"; import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; @@ -9,16 +9,18 @@ import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; +import { getQuickDateRange, type QuickPeriod } from "../_utils/time-range"; interface LogsDateRangePickerProps { startDate?: string; // "YYYY-MM-DD" endDate?: string; // "YYYY-MM-DD" onDateRangeChange: (range: { startDate?: string; endDate?: string }) => void; + serverTimeZone?: string; } -type QuickPeriod = "today" | "yesterday" | "last7days" | "last30days" | "custom"; +type PickerQuickPeriod = QuickPeriod | "custom"; -const QUICK_PERIODS: Exclude[] = [ +const QUICK_PERIODS: Exclude[] = [ "today", "yesterday", "last7days", @@ -35,29 +37,22 @@ function parseDate(dateStr: string): Date { return new Date(year, month - 1, day); } -function getDateRangeForPeriod(period: QuickPeriod): { startDate: string; endDate: string } { - const today = new Date(); - switch (period) { - case "today": - return { startDate: formatDate(today), endDate: formatDate(today) }; - case "yesterday": { - const yesterday = subDays(today, 1); - return { startDate: formatDate(yesterday), endDate: formatDate(yesterday) }; - } - case "last7days": - return { startDate: formatDate(subDays(today, 6)), endDate: formatDate(today) }; - case "last30days": - return { startDate: formatDate(subDays(today, 29)), endDate: formatDate(today) }; - default: - return { startDate: formatDate(today), endDate: formatDate(today) }; - } +function getDateRangeForPeriod( + period: QuickPeriod, + serverTimeZone?: string +): { startDate: string; endDate: string } { + return getQuickDateRange(period, serverTimeZone); } -function detectQuickPeriod(startDate?: string, endDate?: string): QuickPeriod | null { +function detectQuickPeriod( + startDate?: string, + endDate?: string, + serverTimeZone?: string +): PickerQuickPeriod | null { if (!startDate || !endDate) return null; for (const period of QUICK_PERIODS) { - const range = getDateRangeForPeriod(period); + const range = getDateRangeForPeriod(period, serverTimeZone); if (range.startDate === startDate && range.endDate === endDate) { return period; } @@ -85,6 +80,7 @@ export function LogsDateRangePicker({ startDate, endDate, onDateRangeChange, + serverTimeZone, }: LogsDateRangePickerProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); @@ -93,8 +89,8 @@ export function LogsDateRangePicker({ const hasDateRange = Boolean(startDate && endDate); const activeQuickPeriod = useMemo(() => { - return detectQuickPeriod(startDate, endDate); - }, [startDate, endDate]); + return detectQuickPeriod(startDate, endDate, serverTimeZone); + }, [startDate, endDate, serverTimeZone]); const selectedRange: DateRange | undefined = useMemo(() => { if (!startDate || !endDate) return undefined; @@ -106,10 +102,10 @@ export function LogsDateRangePicker({ const handleQuickPeriodClick = useCallback( (period: QuickPeriod) => { - const range = getDateRangeForPeriod(period); + const range = getDateRangeForPeriod(period, serverTimeZone); onDateRangeChange(range); }, - [onDateRangeChange] + [onDateRangeChange, serverTimeZone] ); const handleNavigate = useCallback( diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx index 46e314f43..7278cfa8c 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx @@ -205,6 +205,56 @@ describe("provider-chain-popover probability formatting", () => { }); }); +describe("provider-chain-popover group badges", () => { + test("renders multiple deduped group badges with tooltip content", () => { + const html = renderWithIntl( + + ); + + const document = parseHtml(html); + const badgeTexts = Array.from(document.querySelectorAll("[data-slot='badge']")).map( + (node) => node.textContent + ); + expect(badgeTexts.filter((text) => text === "alpha").length).toBe(1); + expect(badgeTexts.filter((text) => text === "beta").length).toBe(1); + expect(document.body.textContent).toContain("alpha"); + expect(document.body.textContent).toContain("beta"); + }); +}); + describe("provider-chain-popover layout", () => { test("requestCount<=1 branch keeps truncation container shrinkable", () => { const html = renderWithIntl( 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 5956a3794..b16fa529b 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -38,6 +38,19 @@ function isActualRequest(item: ProviderChainItem): boolean { return false; } +function parseGroupTags(groupTag?: string | null): string[] { + if (!groupTag) return []; + const seen = new Set(); + const groups: string[] = []; + for (const raw of groupTag.split(",")) { + const trimmed = raw.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + groups.push(trimmed); + } + return groups; +} + /** * Get status icon and color for a provider chain item */ @@ -279,6 +292,7 @@ export function ProviderChainPopover({ .find((item) => item.reason === "request_success" || item.reason === "retry_success"); const finalCostMultiplier = successfulProvider?.costMultiplier; const finalGroupTag = successfulProvider?.groupTag; + const finalGroupTags = parseGroupTags(finalGroupTag); const hasFinalCostBadge = finalCostMultiplier !== undefined && finalCostMultiplier !== null && @@ -318,15 +332,22 @@ export function ProviderChainPopover({ x{finalCostMultiplier.toFixed(2)} )} - {/* Group tag badge (if present) */} - {finalGroupTag && ( - - {finalGroupTag} - - )} + {/* Group tag badges (if present) */} + {finalGroupTags.map((group) => ( + + + + + {group} + + + {group} + + + ))} {/* Info icon */}
-
- - -
diff --git a/src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx b/src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx new file mode 100644 index 000000000..89b4a263c --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowRight, FolderGit2, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { reclusterProviderVendors } from "@/actions/providers"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +type ReclusterChange = { + providerId: number; + providerName: string; + oldVendorId: number; + oldVendorDomain: string; + newVendorDomain: string; +}; + +type ReclusterResult = { + preview: { + providersMoved: number; + vendorsCreated: number; + vendorsToDelete: number; + skippedInvalidUrl: number; + }; + changes: ReclusterChange[]; + applied: boolean; +}; + +const MAX_DISPLAY_CHANGES = 10; + +export function ReclusterVendorsDialog() { + const queryClient = useQueryClient(); + const t = useTranslations("settings.providers.recluster"); + const tCommon = useTranslations("settings.common"); + const tErrors = useTranslations("errors"); + + const [open, setOpen] = useState(false); + const [previewData, setPreviewData] = useState(null); + const [isPending, startTransition] = useTransition(); + const [isApplying, setIsApplying] = useState(false); + + const getActionErrorMessage = (result: { + errorCode?: string; + errorParams?: Record; + error?: string | null; + }): string => { + if (result.errorCode) { + try { + return tErrors(result.errorCode, result.errorParams); + } catch { + return t("error"); + } + } + + if (result.error) { + try { + return tErrors(result.error); + } catch { + return t("error"); + } + } + + return t("error"); + }; + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (isOpen) { + // Load preview when dialog opens + startTransition(async () => { + try { + const result = await reclusterProviderVendors({ confirm: false }); + if (result.ok) { + setPreviewData(result.data); + } else { + toast.error(getActionErrorMessage(result)); + setOpen(false); + } + } catch (error) { + console.error("reclusterProviderVendors preview failed", error); + toast.error(t("error")); + setOpen(false); + } + }); + } else { + // Clear preview when dialog closes + setPreviewData(null); + } + }; + + const handleApply = async () => { + setIsApplying(true); + try { + const result = await reclusterProviderVendors({ confirm: true }); + if (result.ok) { + toast.success(t("success", { count: result.data.preview.providersMoved })); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + setOpen(false); + } else { + toast.error(getActionErrorMessage(result)); + } + } catch (error) { + console.error("reclusterProviderVendors apply failed", error); + toast.error(t("error")); + } finally { + setIsApplying(false); + } + }; + + const hasChanges = previewData && previewData.preview.providersMoved > 0; + const displayedChanges = previewData?.changes.slice(0, MAX_DISPLAY_CHANGES) ?? []; + const remainingCount = (previewData?.changes.length ?? 0) - MAX_DISPLAY_CHANGES; + + return ( + + + + + + + {t("dialogTitle")} + {t("dialogDescription")} + + +
+ {isPending ? ( +
+ +
+ ) : previewData ? ( + <> + {/* Statistics Summary */} +
+ 0} + /> + + + +
+ + {/* No Changes Message */} + {!hasChanges && ( +
+ {t("noChanges")} +
+ )} + + {/* Changes Table */} + {hasChanges && ( +
+ + + + {t("providerHeader")} + {t("vendorChangeHeader")} + + + + {displayedChanges.map((change) => ( + + + {change.providerName} + + +
+ + {change.oldVendorDomain || "-"} + + + + {change.newVendorDomain} + +
+
+
+ ))} +
+
+ {remainingCount > 0 && ( +
+ {t("moreChanges", { count: remainingCount })} +
+ )} +
+ )} + + ) : null} +
+ + + + + +
+
+ ); +} + +function StatCard({ + label, + value, + highlight = false, +}: { + label: string; + value: number; + highlight?: boolean; +}) { + return ( +
+
0 ? "text-primary" : ""}`} + > + {value} +
+
{label}
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/page.tsx b/src/app/[locale]/settings/providers/page.tsx index 3db554604..9a561a0e8 100644 --- a/src/app/[locale]/settings/providers/page.tsx +++ b/src/app/[locale]/settings/providers/page.tsx @@ -7,6 +7,7 @@ import { getSession } from "@/lib/auth"; import { SettingsPageHeader } from "../_components/settings-page-header"; import { AutoSortPriorityDialog } from "./_components/auto-sort-priority-dialog"; import { ProviderManagerLoader } from "./_components/provider-manager-loader"; +import { ReclusterVendorsDialog } from "./_components/recluster-vendors-dialog"; import { SchedulingRulesDialog } from "./_components/scheduling-rules-dialog"; export const dynamic = "force-dynamic"; @@ -31,6 +32,7 @@ export default async function SettingsProvidersPage() { + } diff --git a/src/app/[locale]/settings/sensitive-words/_components/word-list-table.tsx b/src/app/[locale]/settings/sensitive-words/_components/word-list-table.tsx index c3dca5d64..b1e4af4b6 100644 --- a/src/app/[locale]/settings/sensitive-words/_components/word-list-table.tsx +++ b/src/app/[locale]/settings/sensitive-words/_components/word-list-table.tsx @@ -1,7 +1,8 @@ "use client"; +import { formatInTimeZone } from "date-fns-tz"; import { Pencil, Trash2 } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; import { deleteSensitiveWordAction, updateSensitiveWordAction } from "@/actions/sensitive-words"; @@ -23,6 +24,7 @@ const matchTypeColors = { export function WordListTable({ words }: WordListTableProps) { const t = useTranslations("settings"); + const timeZone = useTimeZone() ?? "UTC"; const [selectedWord, setSelectedWord] = useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); @@ -122,7 +124,7 @@ export function WordListTable({ words }: WordListTableProps) { /> - {new Date(word.createdAt).toLocaleString("zh-CN")} + {formatInTimeZone(new Date(word.createdAt), timeZone, "yyyy-MM-dd HH:mm:ss")}
diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index b511b9824..977f7f92f 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -256,12 +256,12 @@ export class ProxyErrorHandler { * - error.limit_type: 限流类型(rpm/usd_5h/usd_weekly/usd_monthly/concurrent_sessions/daily_quota) * - error.current: 当前使用量 * - error.limit: 限制值 - * - error.reset_time: 重置时间(ISO-8601格式) + * - error.reset_time: 重置时间(ISO-8601格式,滚动窗口为 null) * * 响应头(3个标准 rate limit 头): * - X-RateLimit-Limit: 限制值 * - X-RateLimit-Remaining: 剩余配额(max(0, limit - current)) - * - X-RateLimit-Reset: Unix 时间戳(秒) + * - X-RateLimit-Reset: Unix 时间戳(秒),滚动窗口不设置此头 */ private static buildRateLimitResponse(error: RateLimitError): Response { // 使用 helper 函数计算状态码 @@ -270,20 +270,22 @@ export class ProxyErrorHandler { // 计算剩余配额(不能为负数) const remaining = Math.max(0, error.limitValue - error.currentUsage); - // 计算 Unix 时间戳(秒) - const resetTimestamp = Math.floor(new Date(error.resetTime).getTime() / 1000); - const headers = new Headers({ "Content-Type": "application/json", - // 标准 rate limit 响应头(3个) + // 标准 rate limit 响应头 "X-RateLimit-Limit": error.limitValue.toString(), "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": resetTimestamp.toString(), // 额外的自定义头(便于客户端快速识别限流类型) "X-RateLimit-Type": error.limitType, - "Retry-After": ProxyErrorHandler.calculateRetryAfter(error.resetTime), }); + // 只有固定窗口才设置重置时间相关头(滚动窗口 resetTime 为 null) + if (error.resetTime !== null) { + const resetTimestamp = Math.floor(new Date(error.resetTime).getTime() / 1000); + headers.set("X-RateLimit-Reset", resetTimestamp.toString()); + headers.set("Retry-After", ProxyErrorHandler.calculateRetryAfter(error.resetTime)); + } + return new Response( JSON.stringify({ error: { @@ -295,7 +297,7 @@ export class ProxyErrorHandler { limit_type: error.limitType, current: error.currentUsage, limit: error.limitValue, - reset_time: error.resetTime, + reset_time: error.resetTime, // 滚动窗口为 null }, }), { @@ -307,6 +309,7 @@ export class ProxyErrorHandler { /** * 计算 Retry-After 头(秒数) + * 仅用于固定窗口(有确定重置时间的场景) */ private static calculateRetryAfter(resetTime: string): string { const resetDate = new Date(resetTime); diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index 0d6d74d64..13d0ad9d0 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -659,7 +659,7 @@ export class RateLimitError extends Error { | "daily_quota", public readonly currentUsage: number, public readonly limitValue: number, - public readonly resetTime: string, // ISO 8601 格式 + public readonly resetTime: string | null, // ISO 8601 格式,滚动窗口为 null public readonly providerId: number | null = null ) { super(message); diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 890dc8f29..19344bb1e 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/src/app/v1/_lib/proxy/rate-limit-guard.ts @@ -224,15 +224,18 @@ export class ProxyRateLimitGuard { logger.warn(`[RateLimit] Key 5h limit exceeded: key=${key.id}, ${key5hCheck.reason}`); const { currentUsage, limitValue } = parseLimitInfo(key5hCheck.reason!); - const resetTime = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString(); const { getLocale } = await import("next-intl/server"); const locale = await getLocale(); - const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_5H_EXCEEDED, { - current: currentUsage.toFixed(4), - limit: limitValue.toFixed(4), - resetTime, - }); + // 5h 是滚动窗口,使用专用的滚动窗口错误消息(无固定重置时间) + const message = await getErrorMessageServer( + locale, + ERROR_CODES.RATE_LIMIT_5H_ROLLING_EXCEEDED, + { + current: currentUsage.toFixed(4), + limit: limitValue.toFixed(4), + } + ); throw new RateLimitError( "rate_limit_error", @@ -240,7 +243,7 @@ export class ProxyRateLimitGuard { "usd_5h", currentUsage, limitValue, - resetTime, + null, // 滚动窗口没有固定重置时间 null ); } @@ -257,15 +260,18 @@ export class ProxyRateLimitGuard { logger.warn(`[RateLimit] User 5h limit exceeded: user=${user.id}, ${user5hCheck.reason}`); const { currentUsage, limitValue } = parseLimitInfo(user5hCheck.reason!); - const resetTime = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString(); const { getLocale } = await import("next-intl/server"); const locale = await getLocale(); - const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_5H_EXCEEDED, { - current: currentUsage.toFixed(4), - limit: limitValue.toFixed(4), - resetTime, - }); + // 5h 是滚动窗口,使用专用的滚动窗口错误消息(无固定重置时间) + const message = await getErrorMessageServer( + locale, + ERROR_CODES.RATE_LIMIT_5H_ROLLING_EXCEEDED, + { + current: currentUsage.toFixed(4), + limit: limitValue.toFixed(4), + } + ); throw new RateLimitError( "rate_limit_error", @@ -273,7 +279,7 @@ export class ProxyRateLimitGuard { "usd_5h", currentUsage, limitValue, - resetTime, + null, // 滚动窗口没有固定重置时间 null ); } @@ -293,62 +299,47 @@ export class ProxyRateLimitGuard { const { currentUsage, limitValue } = parseLimitInfo(keyDailyCheck.reason!); - const resetInfo = getResetInfoWithMode("daily", key.dailyResetTime, key.dailyResetMode); - // rolling 模式没有 resetAt,使用 24 小时后作为 fallback - const resetTime = - resetInfo.resetAt?.toISOString() ?? - new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); - const { getLocale } = await import("next-intl/server"); const locale = await getLocale(); - const message = await getErrorMessageServer( - locale, - ERROR_CODES.RATE_LIMIT_DAILY_QUOTA_EXCEEDED, - { - current: currentUsage.toFixed(4), - limit: limitValue.toFixed(4), - resetTime, - } - ); - - throw new RateLimitError( - "rate_limit_error", - message, - "daily_quota", - currentUsage, - limitValue, - resetTime, - null - ); - } - - // 8. User 每日额度(User 独有的常用预算)- null 表示无限制 - if (user.dailyQuota !== null) { - const dailyCheck = await RateLimitService.checkUserDailyCost( - user.id, - user.dailyQuota, - user.dailyResetTime, - user.dailyResetMode - ); - if (!dailyCheck.allowed) { - logger.warn(`[RateLimit] User daily limit exceeded: user=${user.id}, ${dailyCheck.reason}`); + // 根据模式选择不同的错误消息 + if (key.dailyResetMode === "rolling") { + // rolling 模式:使用滚动窗口专用消息(无固定重置时间) + const message = await getErrorMessageServer( + locale, + ERROR_CODES.RATE_LIMIT_DAILY_ROLLING_EXCEEDED, + { + current: currentUsage.toFixed(4), + limit: limitValue.toFixed(4), + } + ); - // 使用用户配置的重置时间和模式计算正确的 resetTime - const resetInfo = getResetInfoWithMode("daily", user.dailyResetTime, user.dailyResetMode); - // rolling 模式没有 resetAt,使用 24 小时后作为 fallback + throw new RateLimitError( + "rate_limit_error", + message, + "daily_quota", + currentUsage, + limitValue, + null, // 滚动窗口没有固定重置时间 + null + ); + } else { + // fixed 模式:有固定重置时间 + const resetInfo = await getResetInfoWithMode( + "daily", + key.dailyResetTime, + key.dailyResetMode + ); const resetTime = resetInfo.resetAt?.toISOString() ?? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); - const { getLocale } = await import("next-intl/server"); - const locale = await getLocale(); const message = await getErrorMessageServer( locale, ERROR_CODES.RATE_LIMIT_DAILY_QUOTA_EXCEEDED, { - current: (dailyCheck.current || 0).toFixed(4), - limit: user.dailyQuota.toFixed(4), + current: currentUsage.toFixed(4), + limit: limitValue.toFixed(4), resetTime, } ); @@ -357,14 +348,84 @@ export class ProxyRateLimitGuard { "rate_limit_error", message, "daily_quota", - dailyCheck.current || 0, - user.dailyQuota, + currentUsage, + limitValue, resetTime, null ); } } + // 8. User 每日额度(User 独有的常用预算)- null 表示无限制 + if (user.dailyQuota !== null) { + const dailyCheck = await RateLimitService.checkUserDailyCost( + user.id, + user.dailyQuota, + user.dailyResetTime, + user.dailyResetMode + ); + + if (!dailyCheck.allowed) { + logger.warn(`[RateLimit] User daily limit exceeded: user=${user.id}, ${dailyCheck.reason}`); + + const { getLocale } = await import("next-intl/server"); + const locale = await getLocale(); + + // 根据模式选择不同的错误消息 + if (user.dailyResetMode === "rolling") { + // rolling 模式:使用滚动窗口专用消息(无固定重置时间) + const message = await getErrorMessageServer( + locale, + ERROR_CODES.RATE_LIMIT_DAILY_ROLLING_EXCEEDED, + { + current: (dailyCheck.current || 0).toFixed(4), + limit: user.dailyQuota.toFixed(4), + } + ); + + throw new RateLimitError( + "rate_limit_error", + message, + "daily_quota", + dailyCheck.current || 0, + user.dailyQuota, + null, // 滚动窗口没有固定重置时间 + null + ); + } else { + // fixed 模式:有固定重置时间 + const resetInfo = await getResetInfoWithMode( + "daily", + user.dailyResetTime, + user.dailyResetMode + ); + const resetTime = + resetInfo.resetAt?.toISOString() ?? + new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + const message = await getErrorMessageServer( + locale, + ERROR_CODES.RATE_LIMIT_DAILY_QUOTA_EXCEEDED, + { + current: (dailyCheck.current || 0).toFixed(4), + limit: user.dailyQuota.toFixed(4), + resetTime, + } + ); + + throw new RateLimitError( + "rate_limit_error", + message, + "daily_quota", + dailyCheck.current || 0, + user.dailyQuota, + resetTime, + null + ); + } + } + } + // ========== 第四层:中长期周期限额(混合检查)========== // 9. Key 周限额 @@ -379,7 +440,7 @@ export class ProxyRateLimitGuard { logger.warn(`[RateLimit] Key weekly limit exceeded: key=${key.id}, ${keyWeeklyCheck.reason}`); const { currentUsage, limitValue } = parseLimitInfo(keyWeeklyCheck.reason!); - const resetInfo = getResetInfo("weekly"); + const resetInfo = await getResetInfo("weekly"); const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString(); const { getLocale } = await import("next-intl/server"); @@ -415,7 +476,7 @@ export class ProxyRateLimitGuard { ); const { currentUsage, limitValue } = parseLimitInfo(userWeeklyCheck.reason!); - const resetInfo = getResetInfo("weekly"); + const resetInfo = await getResetInfo("weekly"); const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString(); const { getLocale } = await import("next-intl/server"); @@ -451,7 +512,7 @@ export class ProxyRateLimitGuard { ); const { currentUsage, limitValue } = parseLimitInfo(keyMonthlyCheck.reason!); - const resetInfo = getResetInfo("monthly"); + const resetInfo = await getResetInfo("monthly"); const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString(); const { getLocale } = await import("next-intl/server"); @@ -487,7 +548,7 @@ export class ProxyRateLimitGuard { ); const { currentUsage, limitValue } = parseLimitInfo(userMonthlyCheck.reason!); - const resetInfo = getResetInfo("monthly"); + const resetInfo = await getResetInfo("monthly"); const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString(); const { getLocale } = await import("next-intl/server"); diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 33b4fe437..0cfe29be6 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -34,6 +34,9 @@ export type UsageMetrics = { cache_creation_1h_input_tokens?: number; cache_ttl?: "5m" | "1h" | "mixed"; cache_read_input_tokens?: number; + // 图片 modality tokens(从 candidatesTokensDetails/promptTokensDetails 提取) + input_image_tokens?: number; + output_image_tokens?: number; }; /** @@ -1288,6 +1291,71 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null { hasAny = true; } + // Gemini modality-specific token details (IMAGE/TEXT) + // candidatesTokensDetails: 输出 token 按 modality 分类 + const candidatesDetails = usage.candidatesTokensDetails as + | Array<{ modality?: string; tokenCount?: number }> + | undefined; + if (Array.isArray(candidatesDetails) && candidatesDetails.length > 0) { + let imageTokens = 0; + let textTokens = 0; + let hasValidToken = false; + for (const detail of candidatesDetails) { + if (typeof detail.tokenCount === "number" && detail.tokenCount > 0) { + hasValidToken = true; + const modalityUpper = detail.modality?.toUpperCase(); + if (modalityUpper === "IMAGE") { + imageTokens += detail.tokenCount; + } else { + textTokens += detail.tokenCount; + } + } + } + if (imageTokens > 0) { + result.output_image_tokens = imageTokens; + hasAny = true; + } + if (hasValidToken) { + // 计算未分类的 TEXT tokens: candidatesTokenCount - details总和 + // 这些可能是图片生成的内部开销,按 TEXT 价格计费 + const detailsSum = imageTokens + textTokens; + const candidatesTotal = + typeof usage.candidatesTokenCount === "number" ? usage.candidatesTokenCount : 0; + const unaccountedTokens = Math.max(candidatesTotal - detailsSum, 0); + result.output_tokens = textTokens + unaccountedTokens; + hasAny = true; + } + } + + // promptTokensDetails: 输入 token 按 modality 分类 + const promptDetails = usage.promptTokensDetails as + | Array<{ modality?: string; tokenCount?: number }> + | undefined; + if (Array.isArray(promptDetails) && promptDetails.length > 0) { + let imageTokens = 0; + let textTokens = 0; + let hasValidToken = false; + for (const detail of promptDetails) { + if (typeof detail.tokenCount === "number" && detail.tokenCount > 0) { + hasValidToken = true; + const modalityUpper = detail.modality?.toUpperCase(); + if (modalityUpper === "IMAGE") { + imageTokens += detail.tokenCount; + } else { + textTokens += detail.tokenCount; + } + } + } + if (imageTokens > 0) { + result.input_image_tokens = imageTokens; + hasAny = true; + } + if (hasValidToken) { + result.input_tokens = textTokens; + hasAny = true; + } + } + if (typeof usage.output_tokens === "number") { result.output_tokens = usage.output_tokens; hasAny = true; diff --git a/src/components/customs/active-sessions-list.tsx b/src/components/customs/active-sessions-list.tsx index 20abeb0e1..be6a22a0b 100644 --- a/src/components/customs/active-sessions-list.tsx +++ b/src/components/customs/active-sessions-list.tsx @@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { Activity, Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { getActiveSessions } from "@/actions/active-sessions"; +import { useRouter } from "@/i18n/routing"; import type { CurrencyCode } from "@/lib/utils/currency"; import type { ActiveSessionInfo } from "@/types/session"; import { SessionListItem } from "./session-list-item"; @@ -28,6 +28,8 @@ interface ActiveSessionsListProps { showHeader?: boolean; /** 容器最大高度 */ maxHeight?: string; + /** 是否显示 Token/成本(默认显示) */ + showTokensCost?: boolean; /** 自定义类名 */ className?: string; } @@ -43,6 +45,7 @@ export function ActiveSessionsList({ maxItems, showHeader = true, maxHeight = "200px", + showTokensCost = true, className = "", }: ActiveSessionsListProps) { const router = useRouter(); @@ -103,6 +106,7 @@ export function ActiveSessionsList({ key={session.sessionId} session={session} currencyCode={currencyCode} + showTokensCost={showTokensCost} /> ))}
diff --git a/src/components/customs/session-list-item.tsx b/src/components/customs/session-list-item.tsx index 0d91ebd59..7f66aba36 100644 --- a/src/components/customs/session-list-item.tsx +++ b/src/components/customs/session-list-item.tsx @@ -41,13 +41,17 @@ function getStatusIcon(status: "in_progress" | "completed" | "error", statusCode * 简洁的 Session 列表项 * 可复用组件,用于活跃 Session 列表的单项展示 */ +export interface SessionListItemProps { + session: ActiveSessionInfo; + currencyCode?: CurrencyCode; + showTokensCost?: boolean; +} + export function SessionListItem({ session, currencyCode = "USD", -}: { - session: ActiveSessionInfo; - currencyCode?: CurrencyCode; -}) { + showTokensCost = true, +}: SessionListItemProps) { const statusInfo = getStatusIcon(session.status, session.statusCode); const StatusIcon = statusInfo.icon; const inputTokensDisplay = @@ -106,18 +110,22 @@ export function SessionListItem({
{/* Token 和成本 */} -
- {(inputTokensDisplay || outputTokensDisplay) && ( - - {inputTokensDisplay && `↑${inputTokensDisplay}`} - {inputTokensDisplay && outputTokensDisplay && " "} - {outputTokensDisplay && `↓${outputTokensDisplay}`} - - )} - {session.costUsd && ( - {formatCurrency(session.costUsd, currencyCode, 4)} - )} -
+ {showTokensCost && ( +
+ {(inputTokensDisplay || outputTokensDisplay) && ( + + {inputTokensDisplay && `↑${inputTokensDisplay}`} + {inputTokensDisplay && outputTokensDisplay && " "} + {outputTokensDisplay && `↓${outputTokensDisplay}`} + + )} + {session.costUsd && ( + + {formatCurrency(session.costUsd, currencyCode, 4)} + + )} +
+ )}
); diff --git a/src/components/customs/version-checker.tsx b/src/components/customs/version-checker.tsx index 00caa2fc8..0a0b777a6 100644 --- a/src/components/customs/version-checker.tsx +++ b/src/components/customs/version-checker.tsx @@ -1,7 +1,8 @@ "use client"; +import { formatInTimeZone } from "date-fns-tz"; import { ExternalLink, RefreshCw } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useTimeZone, useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -18,6 +19,7 @@ interface VersionInfo { export function VersionChecker() { const t = useTranslations("customs"); + const timeZone = useTimeZone() ?? "UTC"; const [versionInfo, setVersionInfo] = useState(null); const [loading, setLoading] = useState(true); @@ -93,7 +95,7 @@ export function VersionChecker() { {versionInfo.publishedAt && (

{t("version.publishedAt")}{" "} - {new Date(versionInfo.publishedAt).toLocaleDateString("zh-CN")} + {formatInTimeZone(new Date(versionInfo.publishedAt), timeZone, "yyyy-MM-dd")}

)}
diff --git a/src/components/ui/__tests__/tag-input-dialog.test.tsx b/src/components/ui/__tests__/tag-input-dialog.test.tsx new file mode 100644 index 000000000..f725bf3b1 --- /dev/null +++ b/src/components/ui/__tests__/tag-input-dialog.test.tsx @@ -0,0 +1,123 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, test, afterEach } from "vitest"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { TagInput } from "@/components/ui/tag-input"; + +function render(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +afterEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } +}); + +function DialogTagInput() { + const [value, setValue] = useState([]); + + return ( + + + + Tag Input + Tag input dialog test + + + + + ); +} + +describe("TagInput inside Dialog", () => { + test("renders suggestions under dialog content and supports click selection", async () => { + const { container, unmount } = render(); + + const input = document.querySelector("input"); + expect(input).not.toBeNull(); + + await act(async () => { + input?.focus(); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const dialogContent = document.querySelector('[data-slot="dialog-content"]'); + expect(dialogContent).not.toBeNull(); + const suggestionButton = Array.from(dialogContent?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Tag 1" + ); + + expect(suggestionButton).not.toBeNull(); + expect(suggestionButton?.closest('[data-slot="dialog-content"]')).not.toBeNull(); + + await act(async () => { + suggestionButton?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + }); + + const dialogContentAfterClick = document.querySelector('[data-slot="dialog-content"]'); + expect(dialogContentAfterClick?.textContent).toContain("tag1"); + + unmount(); + }); + + test("supports keyboard selection within dialog", async () => { + const { container, unmount } = render(); + + const input = document.querySelector("input"); + expect(input).not.toBeNull(); + + await act(async () => { + input?.focus(); + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + await act(async () => { + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const dialogContentAfterKey = document.querySelector('[data-slot="dialog-content"]'); + expect(dialogContentAfterKey?.textContent).toContain("tag1"); + + unmount(); + }); +}); diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index e54256a3d..5a6e19a35 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatInTimeZone } from "date-fns-tz"; import { useTranslations } from "next-intl"; import type { ReactNode } from "react"; import { @@ -238,7 +239,7 @@ export const TableColumnTypes = { title, render: (value) => { if (!value) return "-"; - return new Date(value).toLocaleDateString(); + return formatInTimeZone(new Date(value), "UTC", "yyyy-MM-dd"); }, ...options, }), diff --git a/src/components/ui/relative-time.tsx b/src/components/ui/relative-time.tsx index b87ce37c6..ec22c7a42 100644 --- a/src/components/ui/relative-time.tsx +++ b/src/components/ui/relative-time.tsx @@ -1,7 +1,8 @@ "use client"; import { format as formatDate } from "date-fns"; -import { useLocale, useTranslations } from "next-intl"; +import { formatInTimeZone } from "date-fns-tz"; +import { useLocale, useTimeZone, useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatDateDistance } from "@/lib/utils/date-format"; @@ -35,6 +36,7 @@ export function RelativeTime({ const [timeAgo, setTimeAgo] = useState(fallback); const [mounted, setMounted] = useState(false); const locale = useLocale(); + const timeZone = useTimeZone() ?? "UTC"; const tShort = useTranslations("common.relativeTimeShort"); // Format short distance with i18n @@ -70,10 +72,10 @@ export function RelativeTime({ if (!date) return fallback; const dateObj = typeof date === "string" ? new Date(date) : date; if (Number.isNaN(dateObj.getTime())) return fallback; - // date-fns does not fully support `z` for IANA abbreviations; use `OOOO` to show GMT offset. + // Use system timezone from next-intl for consistent display. // Example output: 2024-05-01 13:45:12 GMT+08:00 - return formatDate(dateObj, "yyyy-MM-dd HH:mm:ss OOOO"); - }, [date, fallback]); + return formatInTimeZone(dateObj, timeZone, "yyyy-MM-dd HH:mm:ss OOOO"); + }, [date, fallback, timeZone]); useEffect(() => { // 如果 date 为 null,直接显示 fallback diff --git a/src/components/ui/tag-input.tsx b/src/components/ui/tag-input.tsx index 9cdb531cf..7b14f1e39 100644 --- a/src/components/ui/tag-input.tsx +++ b/src/components/ui/tag-input.tsx @@ -72,6 +72,7 @@ export function TagInput({ left: number; width: number; } | null>(null); + const [portalContainer, setPortalContainer] = React.useState(null); const inputRef = React.useRef(null); const containerRef = React.useRef(null); const dropdownRef = React.useRef(null); @@ -100,42 +101,58 @@ export function TagInput({ previousShowSuggestions.current = showSuggestions; }, [showSuggestions, onSuggestionsClose]); - // Calculate dropdown position when showing suggestions - // Using fixed positioning, so use viewport coordinates directly (no scroll offset) - React.useEffect(() => { - if (showSuggestions && containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + 4, - left: rect.left, + React.useLayoutEffect(() => { + if (!containerRef.current) return; + const dialogContent = containerRef.current.closest('[data-slot="dialog-content"]'); + setPortalContainer(dialogContent instanceof HTMLElement ? dialogContent : null); + }, []); + + const getDropdownPosition = React.useCallback(() => { + if (!containerRef.current) return null; + const rect = containerRef.current.getBoundingClientRect(); + if (portalContainer) { + const containerRect = portalContainer.getBoundingClientRect(); + return { + top: rect.bottom - containerRect.top + portalContainer.scrollTop + 4, + left: rect.left - containerRect.left + portalContainer.scrollLeft, width: rect.width, - }); + }; } - }, [showSuggestions]); + return { + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }; + }, [portalContainer]); + + React.useEffect(() => { + if (!showSuggestions) return; + const position = getDropdownPosition(); + if (position) { + setDropdownPosition(position); + } + }, [showSuggestions, getDropdownPosition]); // Update position on scroll/resize (recalculate viewport coords) React.useEffect(() => { if (!showSuggestions) return; const updatePosition = () => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + 4, - left: rect.left, - width: rect.width, - }); + const position = getDropdownPosition(); + if (position) { + setDropdownPosition(position); } }; - window.addEventListener("scroll", updatePosition, true); + const scrollTarget: HTMLElement | Window = portalContainer ?? window; + scrollTarget.addEventListener("scroll", updatePosition, true); window.addEventListener("resize", updatePosition); return () => { - window.removeEventListener("scroll", updatePosition, true); + scrollTarget.removeEventListener("scroll", updatePosition, true); window.removeEventListener("resize", updatePosition); }; - }, [showSuggestions]); + }, [showSuggestions, getDropdownPosition, portalContainer]); // Close dropdown when clicking outside React.useEffect(() => { @@ -470,10 +487,13 @@ export function TagInput({ )} {/* 建议下拉列表 - 使用 Radix Portal 确保在 Dialog 中正确渲染 */} {showSuggestions && filteredSuggestions.length > 0 && dropdownPosition && ( - +
providerVendors.id, { - onDelete: 'restrict', - }), + providerVendorId: integer('provider_vendor_id') + .notNull() + .references(() => providerVendors.id, { + onDelete: 'restrict', + }), isEnabled: boolean('is_enabled').notNull().default(true), weight: integer('weight').notNull().default(1), @@ -397,13 +400,13 @@ export const messageRequest = pgTable('message_request', { originalModel: varchar('original_model', { length: 128 }), // Token 使用信息 - inputTokens: integer('input_tokens'), - outputTokens: integer('output_tokens'), + inputTokens: bigint('input_tokens', { mode: 'number' }), + outputTokens: bigint('output_tokens', { mode: 'number' }), ttfbMs: integer('ttfb_ms'), - cacheCreationInputTokens: integer('cache_creation_input_tokens'), - cacheReadInputTokens: integer('cache_read_input_tokens'), - cacheCreation5mInputTokens: integer('cache_creation_5m_input_tokens'), - cacheCreation1hInputTokens: integer('cache_creation_1h_input_tokens'), + cacheCreationInputTokens: bigint('cache_creation_input_tokens', { mode: 'number' }), + cacheReadInputTokens: bigint('cache_read_input_tokens', { mode: 'number' }), + cacheCreation5mInputTokens: bigint('cache_creation_5m_input_tokens', { mode: 'number' }), + cacheCreation1hInputTokens: bigint('cache_creation_1h_input_tokens', { mode: 'number' }), cacheTtlApplied: varchar('cache_ttl_applied', { length: 10 }), // 1M Context Window 应用状态 @@ -560,6 +563,11 @@ export const systemSettings = pgTable('system_settings', { // 计费模型来源配置: 'original' (重定向前) | 'redirected' (重定向后) billingModelSource: varchar('billing_model_source', { length: 20 }).notNull().default('original'), + // 系统时区配置 (IANA timezone identifier) + // 用于统一后端时间边界计算和前端日期/时间显示 + // null 表示使用环境变量 TZ 或默认 UTC + timezone: varchar('timezone', { length: 64 }), + // 日志清理配置 enableAutoCleanup: boolean('enable_auto_cleanup').default(false), cleanupRetentionDays: integer('cleanup_retention_days').default(30), @@ -605,6 +613,14 @@ export const systemSettings = pgTable('system_settings', { maxFixSize: 1024 * 1024, }), + // Quota lease settings + quotaDbRefreshIntervalSeconds: integer('quota_db_refresh_interval_seconds').default(10), + quotaLeasePercent5h: numeric('quota_lease_percent_5h', { precision: 5, scale: 4 }).default('0.05'), + quotaLeasePercentDaily: numeric('quota_lease_percent_daily', { precision: 5, scale: 4 }).default('0.05'), + quotaLeasePercentWeekly: numeric('quota_lease_percent_weekly', { precision: 5, scale: 4 }).default('0.05'), + quotaLeasePercentMonthly: numeric('quota_lease_percent_monthly', { precision: 5, scale: 4 }).default('0.05'), + quotaLeaseCapUsd: numeric('quota_lease_cap_usd', { precision: 10, scale: 2 }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }); @@ -684,8 +700,9 @@ export const notificationTargetBindings = pgTable( isEnabled: boolean('is_enabled').notNull().default(true), // 定时配置覆盖(可选,仅用于定时类通知) + // null 表示使用系统时区(由运行时 resolveSystemTimezone() 决定) scheduleCron: varchar('schedule_cron', { length: 100 }), - scheduleTimezone: varchar('schedule_timezone', { length: 50 }).default('Asia/Shanghai'), + scheduleTimezone: varchar('schedule_timezone', { length: 50 }), // 模板覆盖(可选,主要用于 custom webhook) templateOverride: jsonb('template_override'), diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 64cbab5cc..b03ea7549 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -4,6 +4,7 @@ */ import { getRequestConfig } from "next-intl/server"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { Locale } from "./config"; import { routing } from "./routing"; @@ -21,21 +22,13 @@ export default getRequestConfig(async ({ requestLocale }) => { // The `settings` namespace is composed by `messages//settings/index.ts` so key paths stay stable. const messages = await import(`../../messages/${locale}`).then((module) => module.default); + const timeZone = await resolveSystemTimezone(); + return { locale, messages, - // Optional: Configure date/time/number formatting - // formats: { - // dateTime: { - // short: { - // day: 'numeric', - // month: 'short', - // year: 'numeric' - // } - // } - // }, - // Optional: Configure time zone - // timeZone: 'Asia/Shanghai', + timeZone, + now: new Date(), // Optional: Enable runtime warnings for missing translations in development onError: process.env.NODE_ENV === "development" diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 4fa19dfc8..9749ab81b 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -96,6 +96,7 @@ export async function getCachedSystemSettings(): Promise { allowGlobalUsageView: false, currencyDisplay: "USD", billingModelSource: "original", + timezone: null, verboseProviderError: false, enableAutoCleanup: false, cleanupRetentionDays: 30, @@ -108,6 +109,12 @@ export async function getCachedSystemSettings(): Promise { enableCodexSessionIdCompletion: DEFAULT_SETTINGS.enableCodexSessionIdCompletion, enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, responseFixerConfig: DEFAULT_SETTINGS.responseFixerConfig, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, createdAt: new Date(), updatedAt: new Date(), } satisfies SystemSettings; diff --git a/src/lib/notification/notification-queue.ts b/src/lib/notification/notification-queue.ts index cc1e1da72..410ae17e0 100644 --- a/src/lib/notification/notification-queue.ts +++ b/src/lib/notification/notification-queue.ts @@ -2,6 +2,7 @@ import type { Job } from "bull"; import Queue from "bull"; import type { NotificationJobType } from "@/lib/constants/notification.constants"; import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { buildCircuitBreakerMessage, buildCostAlertMessage, @@ -137,13 +138,25 @@ function setupQueueProcessor(queue: Queue.Queue): void { }); try { + // Resolve timezone for formatting + // Priority: binding's scheduleTimezone > system timezone + let timezone: string | undefined; + if (bindingId) { + const { getBindingById } = await import("@/repository/notification-bindings"); + const binding = await getBindingById(bindingId); + timezone = binding?.scheduleTimezone ?? undefined; + } + if (!timezone) { + timezone = await resolveSystemTimezone(); + } + // 构建结构化消息 let message: StructuredMessage; let templateData: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData | undefined = data; switch (type) { case "circuit-breaker": - message = buildCircuitBreakerMessage(data as CircuitBreakerAlertData); + message = buildCircuitBreakerMessage(data as CircuitBreakerAlertData, timezone); break; case "daily-leaderboard": { // 动态生成排行榜数据 @@ -193,7 +206,7 @@ function setupQueueProcessor(queue: Queue.Queue): void { // 发送通知 let result; if (webhookUrl) { - result = await sendWebhookMessage(webhookUrl, message); + result = await sendWebhookMessage(webhookUrl, message, { timezone }); } else if (targetId) { const { getWebhookTargetById } = await import("@/repository/webhook-targets"); const target = await getWebhookTargetById(targetId); @@ -221,6 +234,7 @@ function setupQueueProcessor(queue: Queue.Queue): void { notificationType, data: templateData, templateOverride, + timezone, }); } else { throw new Error("Missing notification destination (webhookUrl/targetId)"); @@ -411,6 +425,7 @@ export async function scheduleNotifications() { } else { // 新模式:按绑定调度(支持 cron 覆盖) const { getEnabledBindingsByType } = await import("@/repository/notification-bindings"); + const systemTimezone = await resolveSystemTimezone(); if (settings.dailyLeaderboardEnabled) { const bindings = await getEnabledBindingsByType("daily_leaderboard"); @@ -419,7 +434,7 @@ export async function scheduleNotifications() { for (const binding of bindings) { const cron = binding.scheduleCron ?? defaultCron; - const tz = binding.scheduleTimezone ?? "Asia/Shanghai"; + const tz = binding.scheduleTimezone ?? systemTimezone; await queue.add( { @@ -449,7 +464,7 @@ export async function scheduleNotifications() { for (const binding of bindings) { const cron = binding.scheduleCron ?? defaultCron; - const tz = binding.scheduleTimezone ?? "Asia/Shanghai"; + const tz = binding.scheduleTimezone ?? systemTimezone; await queue.add( { diff --git a/src/lib/notification/tasks/daily-leaderboard.ts b/src/lib/notification/tasks/daily-leaderboard.ts index 2f06abdd7..a008435c5 100644 --- a/src/lib/notification/tasks/daily-leaderboard.ts +++ b/src/lib/notification/tasks/daily-leaderboard.ts @@ -1,4 +1,5 @@ import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { DailyLeaderboardData } from "@/lib/webhook"; import { findLast24HoursLeaderboard } from "@/repository/leaderboard"; @@ -29,11 +30,12 @@ export async function generateDailyLeaderboard(topN: number): Promise sum + entry.totalRequests, 0); const totalCost = leaderboard.reduce((sum, entry) => sum + entry.totalCost, 0); - // 格式化日期 (YYYY-MM-DD) + // 格式化日期 (YYYY-MM-DD) 使用系统时区 const today = new Date(); + const timezone = await resolveSystemTimezone(); const dateStr = today .toLocaleDateString("zh-CN", { - timeZone: "Asia/Shanghai", + timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", diff --git a/src/lib/provider-endpoints/probe-scheduler.ts b/src/lib/provider-endpoints/probe-scheduler.ts index 10ce09c87..7d54cd20f 100644 --- a/src/lib/provider-endpoints/probe-scheduler.ts +++ b/src/lib/provider-endpoints/probe-scheduler.ts @@ -6,7 +6,10 @@ import { renewLeaderLock, } from "@/lib/provider-endpoints/leader-lock"; import { probeProviderEndpointAndRecordByEndpoint } from "@/lib/provider-endpoints/probe"; -import { findEnabledProviderEndpointsForProbing } from "@/repository"; +import { + findEnabledProviderEndpointsForProbing, + type ProviderEndpointProbeTarget, +} from "@/repository"; const LOCK_KEY = "locks:endpoint-probe-scheduler"; @@ -15,10 +18,17 @@ function parseIntWithDefault(value: string | undefined, fallback: number): numbe return Number.isFinite(n) ? n : fallback; } -const INTERVAL_MS = Math.max( +// Base interval (default 60s) +const BASE_INTERVAL_MS = Math.max( 1, - parseIntWithDefault(process.env.ENDPOINT_PROBE_INTERVAL_MS, 10_000) + parseIntWithDefault(process.env.ENDPOINT_PROBE_INTERVAL_MS, 60_000) ); +// Single-vendor interval (10 minutes) +const SINGLE_VENDOR_INTERVAL_MS = 600_000; +// Timeout override interval (10 seconds) +const TIMEOUT_OVERRIDE_INTERVAL_MS = 10_000; +// Scheduler tick interval - use shortest possible interval to support timeout override +const TICK_INTERVAL_MS = Math.min(BASE_INTERVAL_MS, TIMEOUT_OVERRIDE_INTERVAL_MS); const TIMEOUT_MS = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_TIMEOUT_MS, 5_000)); const CONCURRENCY = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_CONCURRENCY, 10)); const CYCLE_JITTER_MS = Math.max( @@ -54,6 +64,67 @@ function shuffleInPlace(arr: T[]): void { } } +/** + * Count enabled endpoints per vendor + */ +function countEndpointsByVendor(endpoints: ProviderEndpointProbeTarget[]): Map { + const counts = new Map(); + for (const ep of endpoints) { + counts.set(ep.vendorId, (counts.get(ep.vendorId) ?? 0) + 1); + } + return counts; +} + +/** + * Calculate effective interval for an endpoint based on: + * 1. Timeout override (10s) - if lastProbeErrorType === "timeout" and lastProbeOk !== true + * 2. Single-vendor interval (10min) - if vendor has only 1 enabled endpoint + * 3. Base interval (60s) - default + * + * Priority: timeout override > single-vendor > base + */ +function getEffectiveIntervalMs( + endpoint: ProviderEndpointProbeTarget, + vendorEndpointCounts: Map +): number { + // Timeout override takes highest priority + const hasTimeoutError = + endpoint.lastProbeErrorType === "timeout" && endpoint.lastProbeOk !== true; + if (hasTimeoutError) { + return TIMEOUT_OVERRIDE_INTERVAL_MS; + } + + // Single-vendor interval + const vendorCount = vendorEndpointCounts.get(endpoint.vendorId) ?? 0; + if (vendorCount === 1) { + return SINGLE_VENDOR_INTERVAL_MS; + } + + // Default base interval + return BASE_INTERVAL_MS; +} + +/** + * Filter endpoints that are due for probing based on their effective interval + */ +function filterDueEndpoints( + endpoints: ProviderEndpointProbeTarget[], + vendorEndpointCounts: Map, + now: Date +): ProviderEndpointProbeTarget[] { + const nowMs = now.getTime(); + return endpoints.filter((ep) => { + // Never probed - always due + if (ep.lastProbedAt === null) { + return true; + } + + const effectiveInterval = getEffectiveIntervalMs(ep, vendorEndpointCounts); + const dueAt = ep.lastProbedAt.getTime() + effectiveInterval; + return nowMs >= dueAt; + }); +} + async function ensureLeaderLock(): Promise { const current = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__; if (current) { @@ -155,7 +226,17 @@ async function runProbeCycle(): Promise { return; } - const endpoints = await findEnabledProviderEndpointsForProbing(); + const allEndpoints = await findEnabledProviderEndpointsForProbing(); + if (allEndpoints.length === 0) { + return; + } + + // Calculate vendor endpoint counts for interval decisions + const vendorEndpointCounts = countEndpointsByVendor(allEndpoints); + + // Filter to only endpoints that are due for probing + const now = new Date(); + const endpoints = filterDueEndpoints(allEndpoints, vendorEndpointCounts, now); if (endpoints.length === 0) { return; } @@ -163,10 +244,11 @@ async function runProbeCycle(): Promise { const concurrency = Math.max(1, Math.min(CONCURRENCY, endpoints.length)); const minBatches = Math.ceil(endpoints.length / concurrency); const expectedFloorMs = minBatches * Math.max(0, TIMEOUT_MS); - if (expectedFloorMs > INTERVAL_MS) { + if (expectedFloorMs > TICK_INTERVAL_MS) { logger.warn("[EndpointProbeScheduler] Probe capacity may be insufficient", { - endpointsCount: endpoints.length, - intervalMs: INTERVAL_MS, + dueEndpointsCount: endpoints.length, + totalEndpointsCount: allEndpoints.length, + tickIntervalMs: TICK_INTERVAL_MS, timeoutMs: TIMEOUT_MS, concurrency, expectedFloorMs, @@ -222,10 +304,13 @@ export function startEndpointProbeScheduler(): void { schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_INTERVAL_ID__ = setInterval(() => { void runProbeCycle(); - }, INTERVAL_MS); + }, TICK_INTERVAL_MS); logger.info("[EndpointProbeScheduler] Started", { - intervalMs: INTERVAL_MS, + baseIntervalMs: BASE_INTERVAL_MS, + singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS, + timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS, + tickIntervalMs: TICK_INTERVAL_MS, timeoutMs: TIMEOUT_MS, concurrency: CONCURRENCY, jitterMs: CYCLE_JITTER_MS, @@ -254,7 +339,10 @@ export function stopEndpointProbeScheduler(): void { export function getEndpointProbeSchedulerStatus(): { started: boolean; running: boolean; - intervalMs: number; + baseIntervalMs: number; + singleVendorIntervalMs: number; + timeoutOverrideIntervalMs: number; + tickIntervalMs: number; timeoutMs: number; concurrency: number; jitterMs: number; @@ -263,7 +351,10 @@ export function getEndpointProbeSchedulerStatus(): { return { started: schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_STARTED__ === true, running: schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_RUNNING__ === true, - intervalMs: INTERVAL_MS, + baseIntervalMs: BASE_INTERVAL_MS, + singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS, + timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS, + tickIntervalMs: TICK_INTERVAL_MS, timeoutMs: TIMEOUT_MS, concurrency: CONCURRENCY, jitterMs: CYCLE_JITTER_MS, diff --git a/src/lib/rate-limit/lease-service.ts b/src/lib/rate-limit/lease-service.ts new file mode 100644 index 000000000..6ee2310c9 --- /dev/null +++ b/src/lib/rate-limit/lease-service.ts @@ -0,0 +1,384 @@ +/** + * Lease Service + * + * Implements lease-based budget slicing for rate limiting. + * DB is authoritative, Redis stores lease slices. + * + * Key concepts: + * - snapshotAtMs: Anchor point for window calculation (DB query timestamp) + * - currentUsage: DB authoritative usage at snapshot time + * - remainingBudget: Lease slice = min(limit * percent, remaining, capUsd) + * - ttlSeconds: Lease refresh interval from system settings + */ + +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis"; +import { + sumKeyCostInTimeRange, + sumProviderCostInTimeRange, + sumUserCostInTimeRange, +} from "@/repository/statistics"; +import { + type BudgetLease, + buildLeaseKey, + calculateLeaseSlice, + createBudgetLease, + deserializeLease, + getLeaseTimeRange, + isLeaseExpired, + type LeaseEntityTypeType, + type LeaseWindowType, + serializeLease, +} from "./lease"; +import type { DailyResetMode } from "./time-utils"; + +/** + * Parameters for getting/refreshing a cost lease + */ +export interface GetCostLeaseParams { + entityType: LeaseEntityTypeType; + entityId: number; + window: LeaseWindowType; + limitAmount: number; + resetTime?: string; + resetMode?: DailyResetMode; +} + +/** + * Parameters for decrementing a lease budget + */ +export interface DecrementLeaseBudgetParams { + entityType: LeaseEntityTypeType; + entityId: number; + window: LeaseWindowType; + cost: number; +} + +/** + * Result of decrementing a lease budget + */ +export interface DecrementLeaseBudgetResult { + success: boolean; + newRemaining: number; + failOpen?: boolean; +} + +/** + * Lease Service - manages budget leases for rate limiting + */ +export class LeaseService { + private static get redis() { + return getRedisClient(); + } + + /** + * Get a cost lease for an entity/window combination + * + * 1. Try to get cached lease from Redis + * 2. If valid (not expired), return it + * 3. If missing or expired, refresh from DB + * 4. If limitAmount changed, refresh from DB + * 5. On error, fail-open (return null) + */ + static async getCostLease(params: GetCostLeaseParams): Promise { + const { entityType, entityId, window, limitAmount } = params; + + try { + const redis = LeaseService.redis; + const leaseKey = buildLeaseKey(entityType, entityId, window); + + // Try Redis cache first + if (redis && redis.status === "ready") { + const cached = await redis.get(leaseKey); + + if (cached) { + const lease = deserializeLease(cached); + + if (lease && !isLeaseExpired(lease)) { + // Check if limit changed - force refresh if so + if (lease.limitAmount !== limitAmount) { + logger.debug("[LeaseService] Limit changed, force refresh", { + key: leaseKey, + cachedLimit: lease.limitAmount, + newLimit: limitAmount, + }); + return await LeaseService.refreshCostLeaseFromDb(params); + } + + logger.debug("[LeaseService] Cache hit", { + key: leaseKey, + remaining: lease.remainingBudget, + }); + return lease; + } + } + } + + // Cache miss or expired - refresh from DB + return await LeaseService.refreshCostLeaseFromDb(params); + } catch (error) { + logger.error("[LeaseService] getCostLease failed, fail-open", { + entityType, + entityId, + window, + error, + }); + return null; + } + } + + /** + * Refresh a lease from the database + * + * 1. Get system settings for lease config + * 2. Query DB for current usage in time window + * 3. Calculate lease slice (min of percent, remaining, cap) + * 4. Store in Redis with TTL + * 5. Return the new lease + */ + static async refreshCostLeaseFromDb(params: GetCostLeaseParams): Promise { + const { + entityType, + entityId, + window, + limitAmount, + resetTime = "00:00", + resetMode = "fixed", + } = params; + + try { + // Get system settings + const settings = await getCachedSystemSettings(); + const ttlSeconds = settings.quotaDbRefreshIntervalSeconds ?? 10; + const capUsd = settings.quotaLeaseCapUsd ?? undefined; + + // Get percent based on window type + const leasePercentConfig = { + quotaLeasePercent5h: settings.quotaLeasePercent5h ?? 0.05, + quotaLeasePercentDaily: settings.quotaLeasePercentDaily ?? 0.05, + quotaLeasePercentWeekly: settings.quotaLeasePercentWeekly ?? 0.05, + quotaLeasePercentMonthly: settings.quotaLeasePercentMonthly ?? 0.05, + }; + const percent = LeaseService.getLeasePercent(window, leasePercentConfig); + + // Calculate time range for DB query + const { startTime, endTime } = await getLeaseTimeRange(window, resetTime, resetMode); + + // Query DB for current usage + const currentUsage = await LeaseService.queryDbUsage( + entityType, + entityId, + startTime, + endTime + ); + + // Calculate lease slice + const remainingBudget = calculateLeaseSlice({ + limitAmount, + currentUsage, + percent, + capUsd, + }); + + // Create lease object + const snapshotAtMs = Date.now(); + const lease = createBudgetLease({ + entityType, + entityId, + window, + resetMode, + resetTime, + snapshotAtMs, + currentUsage, + limitAmount, + remainingBudget, + ttlSeconds, + }); + + // Store in Redis + const redis = LeaseService.redis; + if (redis && redis.status === "ready") { + const leaseKey = buildLeaseKey(entityType, entityId, window); + await redis.setex(leaseKey, ttlSeconds, serializeLease(lease)); + + logger.debug("[LeaseService] Lease refreshed from DB", { + key: leaseKey, + currentUsage, + remainingBudget, + ttl: ttlSeconds, + }); + } + + return lease; + } catch (error) { + logger.error("[LeaseService] refreshCostLeaseFromDb failed", { + entityType, + entityId, + window, + error, + }); + return null; + } + } + + /** + * Get the lease percent for a window type from system settings + */ + private static getLeasePercent( + window: LeaseWindowType, + settings: { + quotaLeasePercent5h: number; + quotaLeasePercentDaily: number; + quotaLeasePercentWeekly: number; + quotaLeasePercentMonthly: number; + } + ): number { + switch (window) { + case "5h": + return settings.quotaLeasePercent5h; + case "daily": + return settings.quotaLeasePercentDaily; + case "weekly": + return settings.quotaLeasePercentWeekly; + case "monthly": + return settings.quotaLeasePercentMonthly; + default: + return 0.05; // Default 5% + } + } + + /** + * Query database for usage in a time range + */ + private static async queryDbUsage( + entityType: LeaseEntityTypeType, + entityId: number, + startTime: Date, + endTime: Date + ): Promise { + switch (entityType) { + case "key": + return await sumKeyCostInTimeRange(entityId, startTime, endTime); + case "user": + return await sumUserCostInTimeRange(entityId, startTime, endTime); + case "provider": + return await sumProviderCostInTimeRange(entityId, startTime, endTime); + default: + return 0; + } + } + + /** + * Lua script for atomic lease budget decrement + * + * KEYS[1] = lease key + * ARGV[1] = cost to decrement + * + * Returns: [newRemaining, success] + * - success=1: decremented successfully + * - success=0, newRemaining=0: insufficient budget + * - success=0, newRemaining=-1: key not found + */ + private static readonly DECREMENT_LUA_SCRIPT = ` + local key = KEYS[1] + local cost = tonumber(ARGV[1]) + + -- Get current lease JSON + local leaseJson = redis.call('GET', key) + if not leaseJson then + return {-1, 0} + end + + -- Parse lease JSON + local lease = cjson.decode(leaseJson) + local remaining = tonumber(lease.remainingBudget) or 0 + + -- Check if budget is sufficient + if remaining < cost then + return {0, 0} + end + + -- Decrement budget + local newRemaining = remaining - cost + lease.remainingBudget = newRemaining + + -- Get TTL and update lease + local ttl = redis.call('TTL', key) + if ttl > 0 then + redis.call('SETEX', key, ttl, cjson.encode(lease)) + end + + return {newRemaining, 1} + `; + + /** + * Decrement lease budget atomically using Lua script + * + * Note: This uses Redis EVAL command to execute Lua scripts atomically. + * This is NOT JavaScript eval() - it's a safe Redis operation for atomic updates. + * + * 1. Try to decrement budget in Redis atomically + * 2. If successful, return new remaining budget + * 3. If insufficient budget, return success=false + * 4. On error or Redis not ready, fail-open (return success=true) + */ + static async decrementLeaseBudget( + params: DecrementLeaseBudgetParams + ): Promise { + const { entityType, entityId, window, cost } = params; + + try { + const redis = LeaseService.redis; + + // Fail-open if Redis is not ready + if (!redis || redis.status !== "ready") { + logger.warn("[LeaseService] Redis not ready, fail-open for decrement", { + entityType, + entityId, + window, + cost, + }); + return { success: true, newRemaining: -1, failOpen: true }; + } + + const leaseKey = buildLeaseKey(entityType, entityId, window); + + // Execute Lua script atomically using Redis EVAL command + const result = (await redis.eval(LeaseService.DECREMENT_LUA_SCRIPT, 1, leaseKey, cost)) as [ + number, + number, + ]; + + const [newRemaining, success] = result; + + if (success === 1) { + logger.debug("[LeaseService] Budget decremented", { + key: leaseKey, + cost, + newRemaining, + }); + return { success: true, newRemaining }; + } + + // Key not found or insufficient budget + logger.debug("[LeaseService] Decrement failed", { + key: leaseKey, + cost, + newRemaining, + reason: newRemaining === -1 ? "key_not_found" : "insufficient_budget", + }); + return { success: false, newRemaining }; + } catch (error) { + // Fail-open on any error + logger.error("[LeaseService] decrementLeaseBudget failed, fail-open", { + entityType, + entityId, + window, + cost, + error, + }); + return { success: true, newRemaining: -1, failOpen: true }; + } + } +} diff --git a/src/lib/rate-limit/lease.ts b/src/lib/rate-limit/lease.ts new file mode 100644 index 000000000..225b71bce --- /dev/null +++ b/src/lib/rate-limit/lease.ts @@ -0,0 +1,169 @@ +/** + * Lease Module + * + * Budget slicing mechanism for rate limiting. + * DB is authoritative, Redis stores lease slices. + */ + +import { + type DailyResetMode, + getTimeRangeForPeriodWithMode, + getTTLForPeriodWithMode, + type TimePeriod, +} from "./time-utils"; + +/** + * Lease window types + */ +export const LeaseWindow = ["5h", "daily", "weekly", "monthly"] as const; +export type LeaseWindowType = (typeof LeaseWindow)[number]; + +/** + * Entity types that can have leases + */ +export const LeaseEntityType = ["key", "user", "provider"] as const; +export type LeaseEntityTypeType = (typeof LeaseEntityType)[number]; + +/** + * Budget lease structure + */ +export interface BudgetLease { + entityType: LeaseEntityTypeType; + entityId: number; + window: LeaseWindowType; + resetMode: DailyResetMode; + resetTime: string; + snapshotAtMs: number; + currentUsage: number; + limitAmount: number; + remainingBudget: number; + ttlSeconds: number; +} + +/** + * Create a budget lease object + */ +export function createBudgetLease(params: BudgetLease): BudgetLease { + return { ...params }; +} + +/** + * Build Redis key for a lease + * Format: lease:{entityType}:{entityId}:{window} + */ +export function buildLeaseKey( + entityType: LeaseEntityTypeType, + entityId: number, + window: LeaseWindowType +): string { + return `lease:${entityType}:${entityId}:${window}`; +} + +/** + * Get time range for a lease window + * Delegates to time-utils for consistent behavior + */ +export async function getLeaseTimeRange( + window: LeaseWindowType, + resetTime = "00:00", + mode: DailyResetMode = "fixed" +): Promise<{ startTime: Date; endTime: Date }> { + return getTimeRangeForPeriodWithMode(window as TimePeriod, resetTime, mode); +} + +/** + * Get TTL in seconds for a lease window + * Delegates to time-utils for consistent behavior + */ +export async function getLeaseTtlSeconds( + window: LeaseWindowType, + resetTime = "00:00", + mode: DailyResetMode = "fixed" +): Promise { + return getTTLForPeriodWithMode(window as TimePeriod, resetTime, mode); +} + +/** + * Calculate lease slice parameters + */ +export interface CalculateLeaseSliceParams { + limitAmount: number; + currentUsage: number; + percent: number; + capUsd?: number; +} + +/** + * Calculate lease slice as percentage of limit + * Returns min(limit * percent, remaining budget, capUsd) + * Rounded to 4 decimal places + */ +export function calculateLeaseSlice(params: CalculateLeaseSliceParams): number { + const { limitAmount, currentUsage, percent, capUsd } = params; + + const remaining = Math.max(0, limitAmount - currentUsage); + if (remaining === 0) { + return 0; + } + + // Clamp percent to valid range [0, 1] + const safePercent = Math.min(1, Math.max(0, percent)); + let slice = limitAmount * safePercent; + + // Cap by remaining budget + slice = Math.min(slice, remaining); + + // Cap by USD limit if provided (ensure non-negative) + if (capUsd !== undefined) { + slice = Math.min(slice, Math.max(0, capUsd)); + } + + // Round to 4 decimal places, ensure non-negative + return Math.max(0, Math.round(slice * 10000) / 10000); +} + +/** + * Serialize a lease to JSON string for Redis storage + */ +export function serializeLease(lease: BudgetLease): string { + return JSON.stringify(lease); +} + +/** + * Deserialize a lease from JSON string + * Returns null if invalid JSON or incomplete data + */ +export function deserializeLease(json: string): BudgetLease | null { + try { + const parsed = JSON.parse(json); + + // Validate required fields + if ( + typeof parsed.entityType !== "string" || + typeof parsed.entityId !== "number" || + typeof parsed.window !== "string" || + typeof parsed.resetMode !== "string" || + typeof parsed.resetTime !== "string" || + typeof parsed.snapshotAtMs !== "number" || + typeof parsed.currentUsage !== "number" || + typeof parsed.limitAmount !== "number" || + typeof parsed.remainingBudget !== "number" || + typeof parsed.ttlSeconds !== "number" + ) { + return null; + } + + return parsed as BudgetLease; + } catch { + return null; + } +} + +/** + * Check if a lease has expired based on its TTL + */ +export function isLeaseExpired(lease: BudgetLease): boolean { + const now = Date.now(); + const expiresAt = lease.snapshotAtMs + lease.ttlSeconds * 1000; + return now >= expiresAt; +} diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index f3777d83b..8dbbdab93 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -81,6 +81,8 @@ import { sumUserCostInTimeRange, sumUserTotalCost, } from "@/repository/statistics"; +import type { LeaseWindowType } from "./lease"; +import { type DecrementLeaseBudgetResult, LeaseService } from "./lease-service"; import { type DailyResetMode, getTimeRangeForPeriodWithMode, @@ -395,7 +397,7 @@ export class RateLimitService { if (!limit.amount || limit.amount <= 0) continue; // 计算时间范围(使用支持模式的时间工具函数) - const { startTime, endTime } = getTimeRangeForPeriodWithMode( + const { startTime, endTime } = await getTimeRangeForPeriodWithMode( limit.period, limit.resetTime, limit.resetMode @@ -468,7 +470,7 @@ export class RateLimitService { } else { // daily fixed/周/月固定窗口:使用 STRING + 动态 TTL const { normalized, suffix } = RateLimitService.resolveDailyReset(limit.resetTime); - const ttl = getTTLForPeriodWithMode(limit.period, normalized, limit.resetMode); + const ttl = await getTTLForPeriodWithMode(limit.period, normalized, limit.resetMode); const periodKey = limit.period === "daily" ? `${limit.period}_${suffix}` : limit.period; await RateLimitService.redis.set( `${type}:${id}:cost_${periodKey}`, @@ -627,14 +629,22 @@ export class RateLimitService { const window24h = 24 * 60 * 60 * 1000; // 24 hours in ms // 计算动态 TTL(daily/周/月) - const ttlDailyKey = getTTLForPeriodWithMode("daily", keyDailyReset.normalized, keyDailyMode); + const ttlDailyKey = await getTTLForPeriodWithMode( + "daily", + keyDailyReset.normalized, + keyDailyMode + ); const ttlDailyProvider = keyDailyReset.normalized === providerDailyReset.normalized && keyDailyMode === providerDailyMode ? ttlDailyKey - : getTTLForPeriodWithMode("daily", providerDailyReset.normalized, providerDailyMode); - const ttlWeekly = getTTLForPeriod("weekly"); - const ttlMonthly = getTTLForPeriod("monthly"); + : await getTTLForPeriodWithMode( + "daily", + providerDailyReset.normalized, + providerDailyMode + ); + const ttlWeekly = await getTTLForPeriod("weekly"); + const ttlMonthly = await getTTLForPeriod("monthly"); // 1. 5h 滚动窗口:使用 Lua 脚本(ZSET) // Key 的 5h 滚动窗口 @@ -825,7 +835,7 @@ export class RateLimitService { sumProviderCostInTimeRange, } = await import("@/repository/statistics"); - const { startTime, endTime } = getTimeRangeForPeriodWithMode( + const { startTime, endTime } = await getTimeRangeForPeriodWithMode( period, dailyResetInfo.normalized, resetMode @@ -889,7 +899,7 @@ export class RateLimitService { } else { // daily fixed/周/月固定窗口:使用 STRING + 动态 TTL const redisKey = period === "daily" ? `${period}_${dailyResetInfo.suffix}` : period; - const ttl = getTTLForPeriodWithMode(period, dailyResetInfo.normalized, resetMode); + const ttl = await getTTLForPeriodWithMode(period, dailyResetInfo.normalized, resetMode); await RateLimitService.redis.set( `${type}:${id}:cost_${redisKey}`, current.toString(), @@ -1060,7 +1070,7 @@ export class RateLimitService { } else { // Cache Miss: 从数据库恢复 logger.info(`[RateLimit] Cache miss for ${key}, querying database`); - const { startTime, endTime } = getTimeRangeForPeriodWithMode( + const { startTime, endTime } = await getTimeRangeForPeriodWithMode( "daily", normalizedResetTime, mode @@ -1068,14 +1078,14 @@ export class RateLimitService { currentCost = await sumUserCostInTimeRange(userId, startTime, endTime); // Cache Warming: 写回 Redis - const ttl = getTTLForPeriodWithMode("daily", normalizedResetTime, "fixed"); + const ttl = await getTTLForPeriodWithMode("daily", normalizedResetTime, "fixed"); await RateLimitService.redis.set(key, currentCost.toString(), "EX", ttl); } } } else { // Slow Path: 数据库查询(Redis 不可用) logger.warn("[RateLimit] Redis unavailable, querying database for user daily cost"); - const { startTime, endTime } = getTimeRangeForPeriodWithMode( + const { startTime, endTime } = await getTimeRangeForPeriodWithMode( "daily", normalizedResetTime, mode @@ -1139,7 +1149,7 @@ export class RateLimitService { // Fixed 模式:使用 STRING 类型 const suffix = normalizedResetTime.replace(":", ""); const key = `user:${userId}:cost_daily_${suffix}`; - const ttl = getTTLForPeriodWithMode("daily", normalizedResetTime, "fixed"); + const ttl = await getTTLForPeriodWithMode("daily", normalizedResetTime, "fixed"); await RateLimitService.redis.pipeline().incrbyfloat(key, cost).expire(key, ttl).exec(); @@ -1285,4 +1295,141 @@ export class RateLimitService { return result; } } + + /** + * Check cost limits using lease-based mechanism + * + * This method uses the lease service to check if there's enough budget + * in the lease slice. If the lease is expired or missing, it will be + * refreshed from the database. + * + * @param entityId - The entity ID (key, user, or provider) + * @param entityType - The entity type + * @param limits - The cost limits to check + * @returns Whether the request is allowed and any failure reason + */ + static async checkCostLimitsWithLease( + entityId: number, + entityType: "key" | "user" | "provider", + limits: { + limit_5h_usd: number | null; + limit_daily_usd: number | null; + daily_reset_time?: string; + daily_reset_mode?: DailyResetMode; + limit_weekly_usd: number | null; + limit_monthly_usd: number | null; + } + ): Promise<{ allowed: boolean; reason?: string; failOpen?: boolean }> { + const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); + const dailyResetMode = limits.daily_reset_mode ?? "fixed"; + + // Define windows to check with their limits + const windowChecks: Array<{ + window: LeaseWindowType; + limit: number | null; + name: string; + resetTime: string; + resetMode: DailyResetMode; + }> = [ + { + window: "5h", + limit: limits.limit_5h_usd, + name: "5h", + resetTime: "00:00", + resetMode: "rolling" as DailyResetMode, + }, + { + window: "daily", + limit: limits.limit_daily_usd, + name: "daily", + resetTime: normalizedDailyReset, + resetMode: dailyResetMode, + }, + { + window: "weekly", + limit: limits.limit_weekly_usd, + name: "weekly", + resetTime: "00:00", + resetMode: "fixed" as DailyResetMode, + }, + { + window: "monthly", + limit: limits.limit_monthly_usd, + name: "monthly", + resetTime: "00:00", + resetMode: "fixed" as DailyResetMode, + }, + ]; + + try { + for (const check of windowChecks) { + if (!check.limit || check.limit <= 0) continue; + + // Get or refresh lease from cache/DB + const lease = await LeaseService.getCostLease({ + entityType, + entityId, + window: check.window, + limitAmount: check.limit, + resetTime: check.resetTime, + resetMode: check.resetMode, + }); + + // Fail-open if lease retrieval failed + if (!lease) { + logger.warn("[RateLimit] Lease retrieval failed, fail-open", { + entityType, + entityId, + window: check.window, + }); + continue; // Fail-open: allow this window check + } + + // Check if remaining budget is sufficient (> 0) + if (lease.remainingBudget <= 0) { + const typeName = + entityType === "key" ? "Key" : entityType === "provider" ? "Provider" : "User"; + return { + allowed: false, + reason: `${typeName} ${check.name} cost limit reached (usage: ${lease.currentUsage.toFixed(4)}/${check.limit.toFixed(4)})`, + }; + } + } + + return { allowed: true }; + } catch (error) { + logger.error("[RateLimit] checkCostLimitsWithLease failed, fail-open", { + entityType, + entityId, + error, + }); + return { allowed: true, failOpen: true }; + } + } + + /** + * Decrement lease budget after a request completes + * + * This should be called after the request is processed to deduct + * the actual cost from the lease budget. + * + * @param entityId - The entity ID + * @param entityType - The entity type + * @param window - The time window + * @param cost - The cost to deduct + * @returns The decrement result + */ + static async decrementLeaseBudget( + entityId: number, + entityType: "key" | "user" | "provider", + window: LeaseWindowType, + cost: number + ): Promise { + return LeaseService.decrementLeaseBudget({ + entityType, + entityId, + window, + cost, + }); + } } diff --git a/src/lib/rate-limit/time-utils.ts b/src/lib/rate-limit/time-utils.ts index 3709a8189..1edd48b94 100644 --- a/src/lib/rate-limit/time-utils.ts +++ b/src/lib/rate-limit/time-utils.ts @@ -15,7 +15,7 @@ import { startOfWeek, } from "date-fns"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; -import { getEnvConfig } from "@/lib/config"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; export type TimePeriod = "5h" | "daily" | "weekly" | "monthly"; export type DailyResetMode = "fixed" | "rolling"; @@ -38,10 +38,13 @@ export interface ResetInfo { * - weekly: 自然周(本周一 00:00 到现在) * - monthly: 自然月(本月 1 号 00:00 到现在) * - * 所有自然时间窗口使用配置的时区(Asia/Shanghai) + * 所有自然时间窗口使用系统配置时区(通过 resolveSystemTimezone 获取) */ -export function getTimeRangeForPeriod(period: TimePeriod, resetTime = "00:00"): TimeRange { - const timezone = getEnvConfig().TZ; // 'Asia/Shanghai' +export async function getTimeRangeForPeriod( + period: TimePeriod, + resetTime = "00:00" +): Promise { + const timezone = await resolveSystemTimezone(); const normalizedResetTime = normalizeResetTime(resetTime); const now = new Date(); const endTime = now; @@ -60,7 +63,7 @@ export function getTimeRangeForPeriod(period: TimePeriod, resetTime = "00:00"): } case "weekly": { - // 自然周:本周一 00:00 (Asia/Shanghai) + // 自然周:本周一 00:00 (系统时区) const zonedNow = toZonedTime(now, timezone); const zonedStartOfWeek = startOfWeek(zonedNow, { weekStartsOn: 1 }); // 周一 startTime = fromZonedTime(zonedStartOfWeek, timezone); @@ -68,7 +71,7 @@ export function getTimeRangeForPeriod(period: TimePeriod, resetTime = "00:00"): } case "monthly": { - // 自然月:本月 1 号 00:00 (Asia/Shanghai) + // 自然月:本月 1 号 00:00 (系统时区) const zonedNow = toZonedTime(now, timezone); const zonedStartOfMonth = startOfMonth(zonedNow); startTime = fromZonedTime(zonedStartOfMonth, timezone); @@ -85,11 +88,11 @@ export function getTimeRangeForPeriod(period: TimePeriod, resetTime = "00:00"): * - daily + fixed: 固定时间重置(使用 resetTime) * - 其他周期:使用原有逻辑 */ -export function getTimeRangeForPeriodWithMode( +export async function getTimeRangeForPeriodWithMode( period: TimePeriod, resetTime = "00:00", mode: DailyResetMode = "fixed" -): TimeRange { +): Promise { if (period === "daily" && mode === "rolling") { // 滚动窗口:过去 24 小时 const now = new Date(); @@ -110,8 +113,8 @@ export function getTimeRangeForPeriodWithMode( * - weekly: 到下周一 00:00 的秒数 * - monthly: 到下月 1 号 00:00 的秒数 */ -export function getTTLForPeriod(period: TimePeriod, resetTime = "00:00"): number { - const timezone = getEnvConfig().TZ; +export async function getTTLForPeriod(period: TimePeriod, resetTime = "00:00"): Promise { + const timezone = await resolveSystemTimezone(); const now = new Date(); const normalizedResetTime = normalizeResetTime(resetTime); @@ -152,11 +155,11 @@ export function getTTLForPeriod(period: TimePeriod, resetTime = "00:00"): number * - daily + fixed: 到下一个自定义重置时间的秒数 * - 其他周期:使用原有逻辑 */ -export function getTTLForPeriodWithMode( +export async function getTTLForPeriodWithMode( period: TimePeriod, resetTime = "00:00", mode: DailyResetMode = "fixed" -): number { +): Promise { if (period === "daily" && mode === "rolling") { return 24 * 3600; // 24 小时 } @@ -167,8 +170,8 @@ export function getTTLForPeriodWithMode( /** * 获取重置信息(用于前端展示) */ -export function getResetInfo(period: TimePeriod, resetTime = "00:00"): ResetInfo { - const timezone = getEnvConfig().TZ; +export async function getResetInfo(period: TimePeriod, resetTime = "00:00"): Promise { + const timezone = await resolveSystemTimezone(); const now = new Date(); const normalizedResetTime = normalizeResetTime(resetTime); @@ -216,11 +219,11 @@ export function getResetInfo(period: TimePeriod, resetTime = "00:00"): ResetInfo /** * 获取重置信息(支持滚动窗口模式) */ -export function getResetInfoWithMode( +export async function getResetInfoWithMode( period: TimePeriod, resetTime = "00:00", mode: DailyResetMode = "fixed" -): ResetInfo { +): Promise { if (period === "daily" && mode === "rolling") { return { type: "rolling", @@ -290,10 +293,10 @@ export function normalizeResetTime(resetTime?: string): string { /** * 计算距离午夜的秒数(用于每日限额) - * 使用配置的时区(Asia/Shanghai)而非服务器本地时区 + * 使用系统配置时区而非服务器本地时区 */ -export function getSecondsUntilMidnight(): number { - const timezone = getEnvConfig().TZ; +export async function getSecondsUntilMidnight(): Promise { + const timezone = await resolveSystemTimezone(); const now = new Date(); const zonedNow = toZonedTime(now, timezone); const zonedTomorrow = addDays(zonedNow, 1); @@ -316,8 +319,8 @@ export function getSecondsUntilMidnight(): number { /** * 获取每日限额的重置时间 */ -export function getDailyResetTime(): Date { - const timezone = getEnvConfig().TZ; +export async function getDailyResetTime(): Promise { + const timezone = await resolveSystemTimezone(); const now = new Date(); const zonedNow = toZonedTime(now, timezone); const zonedTomorrow = addDays(zonedNow, 1); diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index d114d0206..5bbdc3251 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -1,6 +1,6 @@ import { formatInTimeZone } from "date-fns-tz"; -import { getEnvConfig } from "@/lib/config"; import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { type DateRangeParams, findAllTimeLeaderboard, @@ -50,16 +50,17 @@ export interface LeaderboardFilters { /** * 构建缓存键 + * @param timezone - 已解析的系统时区(调用者应使用 resolveSystemTimezone() 获取) */ function buildCacheKey( period: LeaderboardPeriod, currencyDisplay: string, + timezone: string, scope: LeaderboardScope = "user", dateRange?: DateRangeParams, filters?: LeaderboardFilters ): string { const now = new Date(); - const tz = getEnvConfig().TZ; // ensure date formatting aligns with configured timezone const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : ""; let userFilterSuffix = ""; @@ -78,15 +79,15 @@ function buildCacheKey( return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else if (period === "daily") { // leaderboard:{scope}:daily:2025-01-15:USD - const dateStr = formatInTimeZone(now, tz, "yyyy-MM-dd"); + const dateStr = formatInTimeZone(now, timezone, "yyyy-MM-dd"); return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else if (period === "weekly") { // leaderboard:{scope}:weekly:2025-W03:USD (ISO week) - const weekStr = formatInTimeZone(now, tz, "yyyy-'W'ww"); + const weekStr = formatInTimeZone(now, timezone, "yyyy-'W'ww"); return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else if (period === "monthly") { // leaderboard:{scope}:monthly:2025-01:USD - const monthStr = formatInTimeZone(now, tz, "yyyy-MM"); + const monthStr = formatInTimeZone(now, timezone, "yyyy-MM"); return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; } else { // allTime: leaderboard:{scope}:allTime:USD (no date component) @@ -221,7 +222,9 @@ export async function getLeaderboardWithCache( return await queryDatabase(period, scope, dateRange, filters); } - const cacheKey = buildCacheKey(period, currencyDisplay, scope, dateRange, filters); + // Resolve timezone once per request + const timezone = await resolveSystemTimezone(); + const cacheKey = buildCacheKey(period, currencyDisplay, timezone, scope, dateRange, filters); const lockKey = `${cacheKey}:lock`; try { @@ -306,7 +309,9 @@ export async function invalidateLeaderboardCache( return; } - const cacheKey = buildCacheKey(period, currencyDisplay, scope); + // Resolve timezone once per request + const timezone = await resolveSystemTimezone(); + const cacheKey = buildCacheKey(period, currencyDisplay, timezone, scope); try { await redis.del(cacheKey); diff --git a/src/lib/utils/cost-calculation.ts b/src/lib/utils/cost-calculation.ts index a38b702de..1212a1f99 100644 --- a/src/lib/utils/cost-calculation.ts +++ b/src/lib/utils/cost-calculation.ts @@ -14,6 +14,9 @@ type UsageMetrics = { cache_creation_1h_input_tokens?: number; cache_ttl?: "5m" | "1h" | "mixed"; cache_read_input_tokens?: number; + // 图片 modality tokens(从 candidatesTokensDetails/promptTokensDetails 提取) + input_image_tokens?: number; + output_image_tokens?: number; }; function multiplyCost(quantity: number | undefined, unitCost: number | undefined): Decimal { @@ -285,6 +288,21 @@ export function calculateRequestCost( segments.push(multiplyCost(usage.cache_read_input_tokens, cacheReadCost)); } + // 图片 token 费用(Gemini image generation models) + // 输出图片 token:优先使用 output_cost_per_image_token,否则回退到 output_cost_per_token + if (usage.output_image_tokens != null && usage.output_image_tokens > 0) { + const imageCostPerToken = + priceData.output_cost_per_image_token ?? priceData.output_cost_per_token; + segments.push(multiplyCost(usage.output_image_tokens, imageCostPerToken)); + } + + // 输入图片 token:优先使用 input_cost_per_image_token,否则回退到 input_cost_per_token + if (usage.input_image_tokens != null && usage.input_image_tokens > 0) { + const imageCostPerToken = + priceData.input_cost_per_image_token ?? priceData.input_cost_per_token; + segments.push(multiplyCost(usage.input_image_tokens, imageCostPerToken)); + } + const total = segments.reduce((acc, segment) => acc.plus(segment), new Decimal(0)); // 应用倍率 diff --git a/src/lib/utils/date-format.ts b/src/lib/utils/date-format.ts index 6e3d5c6ac..66397a922 100644 --- a/src/lib/utils/date-format.ts +++ b/src/lib/utils/date-format.ts @@ -1,11 +1,13 @@ /** * Date Formatting Utilities with Locale Support - * Provides locale-aware date formatting using date-fns and next-intl + * Provides locale-aware date formatting using date-fns and next-intl. + * Supports optional IANA timezone via date-fns-tz for timezone-aware display. */ import type { Locale } from "date-fns"; import { format, formatDistance, formatRelative } from "date-fns"; import { enUS, ja, ru, zhCN, zhTW } from "date-fns/locale"; +import { formatInTimeZone } from "date-fns-tz"; /** * Map next-intl locale codes to date-fns locale objects @@ -28,20 +30,33 @@ export function getDateFnsLocale(locale: string): Locale { } /** - * Format date with locale support + * Format date with locale support and optional timezone. + * + * When a valid IANA timezone is provided (e.g., "Asia/Shanghai"), the date is + * rendered in that timezone using `formatInTimeZone` from date-fns-tz. + * Otherwise, the browser/server local timezone is used (original behaviour). + * * @param date - Date to format * @param formatString - Format string (e.g., "yyyy-MM-dd", "PPP") * @param locale - next-intl locale code + * @param timezone - Optional IANA timezone identifier (e.g., "America/New_York") * @returns Formatted date string */ export function formatDate( date: Date | number | string, formatString: string, - locale: string = "zh-CN" + locale: string = "zh-CN", + timezone?: string ): string { const dateObj = typeof date === "string" ? new Date(date) : date; const dateFnsLocale = getDateFnsLocale(locale); + if (timezone) { + return formatInTimeZone(dateObj, timezone, formatString, { + locale: dateFnsLocale, + }); + } + return format(dateObj, formatString, { locale: dateFnsLocale }); } diff --git a/src/lib/utils/date-input.ts b/src/lib/utils/date-input.ts new file mode 100644 index 000000000..b872e26c0 --- /dev/null +++ b/src/lib/utils/date-input.ts @@ -0,0 +1,63 @@ +import { parse } from "date-fns"; +import { fromZonedTime } from "date-fns-tz"; + +/** + * Parse a date string input and interpret it in the specified timezone. + * + * - Date-only (YYYY-MM-DD): Interprets as end-of-day (23:59:59) in timezone + * - Full datetime: Interprets as local time in timezone + * + * Returns a UTC Date that represents the correct instant. + * + * @param input - Date string in YYYY-MM-DD or ISO datetime format + * @param timezone - IANA timezone identifier (e.g., "Asia/Shanghai", "America/New_York") + * @returns Date object in UTC representing the input interpreted in the given timezone + * @throws Error if input is invalid + * + * @example + * // "2024-12-31" in Asia/Shanghai becomes 2024-12-31 23:59:59 Shanghai = 2024-12-31 15:59:59 UTC + * parseDateInputAsTimezone("2024-12-31", "Asia/Shanghai") + * + * @example + * // "2024-12-31T10:30:00" in Asia/Shanghai becomes 2024-12-31 10:30:00 Shanghai = 2024-12-31 02:30:00 UTC + * parseDateInputAsTimezone("2024-12-31T10:30:00", "Asia/Shanghai") + */ +export function parseDateInputAsTimezone(input: string, timezone: string): Date { + if (!input) { + throw new Error("Invalid date input: empty string"); + } + + // Date-only format: YYYY-MM-DD + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + // Parse as end-of-day (23:59:59) in the given timezone + const localDateTime = parse(`${input} 23:59:59`, "yyyy-MM-dd HH:mm:ss", new Date()); + + if (Number.isNaN(localDateTime.getTime())) { + throw new Error(`Invalid date input: ${input}`); + } + + // Convert from timezone local time to UTC + return fromZonedTime(localDateTime, timezone); + } + + // Check if input has timezone designator (Z or +-HH:MM offset) + // If so, parse directly as it already represents an absolute instant + const hasTimezoneDesignator = /([zZ]|[+-]\d{2}:?\d{2})$/.test(input); + if (hasTimezoneDesignator) { + const directDate = new Date(input); + if (Number.isNaN(directDate.getTime())) { + throw new Error(`Invalid date input: ${input}`); + } + return directDate; + } + + // ISO datetime without timezone: parse and treat as timezone local time + const localDate = new Date(input); + + if (Number.isNaN(localDate.getTime())) { + throw new Error(`Invalid date input: ${input}`); + } + + // Convert from timezone local time to UTC + return fromZonedTime(localDate, timezone); +} diff --git a/src/lib/utils/date.ts b/src/lib/utils/date.ts index b6d3b0a8d..bd862ec02 100644 --- a/src/lib/utils/date.ts +++ b/src/lib/utils/date.ts @@ -1,3 +1,5 @@ +import { formatInTimeZone } from "date-fns-tz"; + /** * Format a date to relative time (e.g., "2 hours ago") */ @@ -34,9 +36,13 @@ export function formatRelativeTime(date: Date): string { } /** - * Format a date to string + * Format a date to string. + * When a timezone is provided, uses formatInTimeZone for consistent display. */ -export function formatDate(date: Date, locale = "zh-CN"): string { +export function formatDate(date: Date, locale = "zh-CN", timezone?: string): string { + if (timezone) { + return formatInTimeZone(date, timezone, "yyyy-MM-dd"); + } return date.toLocaleDateString(locale, { year: "numeric", month: "2-digit", @@ -45,9 +51,13 @@ export function formatDate(date: Date, locale = "zh-CN"): string { } /** - * Format a date to datetime string + * Format a date to datetime string. + * When a timezone is provided, uses formatInTimeZone for consistent display. */ -export function formatDateTime(date: Date, locale = "zh-CN"): string { +export function formatDateTime(date: Date, locale = "zh-CN", timezone?: string): string { + if (timezone) { + return formatInTimeZone(date, timezone, "yyyy-MM-dd HH:mm:ss"); + } return date.toLocaleString(locale, { year: "numeric", month: "2-digit", diff --git a/src/lib/utils/error-messages.ts b/src/lib/utils/error-messages.ts index de27abd3f..95a40e7f1 100644 --- a/src/lib/utils/error-messages.ts +++ b/src/lib/utils/error-messages.ts @@ -97,11 +97,13 @@ export const BUSINESS_ERRORS = { export const RATE_LIMIT_ERRORS = { RATE_LIMIT_RPM_EXCEEDED: "RATE_LIMIT_RPM_EXCEEDED", RATE_LIMIT_5H_EXCEEDED: "RATE_LIMIT_5H_EXCEEDED", + RATE_LIMIT_5H_ROLLING_EXCEEDED: "RATE_LIMIT_5H_ROLLING_EXCEEDED", RATE_LIMIT_WEEKLY_EXCEEDED: "RATE_LIMIT_WEEKLY_EXCEEDED", 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_DAILY_QUOTA_EXCEEDED: "RATE_LIMIT_DAILY_QUOTA_EXCEEDED", + RATE_LIMIT_DAILY_ROLLING_EXCEEDED: "RATE_LIMIT_DAILY_ROLLING_EXCEEDED", } as const; /** diff --git a/src/lib/utils/timezone.ts b/src/lib/utils/timezone.ts new file mode 100644 index 000000000..659207fca --- /dev/null +++ b/src/lib/utils/timezone.ts @@ -0,0 +1,170 @@ +/** + * Timezone Utilities + * + * Provides timezone validation and resolution functions. + * Uses IANA timezone database identifiers (e.g., "Asia/Shanghai", "America/New_York"). + * + * resolveSystemTimezone() implements the fallback chain: + * DB timezone -> env TZ -> UTC + */ + +import { getEnvConfig } from "@/lib/config/env.schema"; +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; +import { logger } from "@/lib/logger"; + +/** + * Common IANA timezone identifiers for dropdown UI. + * Organized by region for better UX. + */ +export const COMMON_TIMEZONES = [ + // UTC + "UTC", + // Asia + "Asia/Shanghai", + "Asia/Tokyo", + "Asia/Seoul", + "Asia/Singapore", + "Asia/Hong_Kong", + "Asia/Taipei", + "Asia/Bangkok", + "Asia/Dubai", + "Asia/Kolkata", + // Europe + "Europe/London", + "Europe/Paris", + "Europe/Berlin", + "Europe/Moscow", + "Europe/Amsterdam", + "Europe/Rome", + "Europe/Madrid", + // Americas + "America/New_York", + "America/Los_Angeles", + "America/Chicago", + "America/Denver", + "America/Toronto", + "America/Vancouver", + "America/Sao_Paulo", + "America/Mexico_City", + // Pacific + "Pacific/Auckland", + "Pacific/Sydney", + "Australia/Melbourne", + "Australia/Perth", +] as const; + +export type CommonTimezone = (typeof COMMON_TIMEZONES)[number]; + +/** + * Validates if a string is a valid IANA timezone identifier. + * + * Uses the Intl.DateTimeFormat API which is based on the IANA timezone database. + * This approach is more reliable than maintaining a static list. + * + * @param timezone - The timezone string to validate + * @returns true if the timezone is valid, false otherwise + * + * @example + * isValidIANATimezone("Asia/Shanghai") // true + * isValidIANATimezone("America/New_York") // true + * isValidIANATimezone("UTC") // true + * isValidIANATimezone("Invalid/Zone") // false + * isValidIANATimezone("CST") // false (abbreviations not valid) + */ +export function isValidIANATimezone(timezone: string): boolean { + if (!timezone || typeof timezone !== "string") { + return false; + } + + try { + // Intl.DateTimeFormat will throw if the timezone is invalid + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return true; + } catch { + return false; + } +} + +/** + * Gets the display label for a timezone. + * Returns the offset and common name for UI display. + * + * @param timezone - IANA timezone identifier + * @param locale - Locale for formatting (default: "en") + * @returns Display label like "(UTC+08:00) Asia/Shanghai" + */ +export function getTimezoneLabel(timezone: string, locale = "en"): string { + if (!isValidIANATimezone(timezone)) { + return timezone; + } + + try { + const now = new Date(); + const formatter = new Intl.DateTimeFormat(locale, { + timeZone: timezone, + timeZoneName: "longOffset", + }); + + const parts = formatter.formatToParts(now); + const offsetPart = parts.find((p) => p.type === "timeZoneName"); + const offset = offsetPart?.value || ""; + + // Format: "(UTC+08:00) Asia/Shanghai" or "(GMT+08:00) Asia/Shanghai" + return `(${offset}) ${timezone}`; + } catch { + return timezone; + } +} + +/** + * Gets the current UTC offset in minutes for a timezone. + * + * @param timezone - IANA timezone identifier + * @returns Offset in minutes (positive = ahead of UTC, negative = behind) + */ +export function getTimezoneOffsetMinutes(timezone: string): number { + if (!isValidIANATimezone(timezone)) { + return 0; + } + + const now = new Date(); + const utcDate = new Date(now.toLocaleString("en-US", { timeZone: "UTC" })); + const tzDate = new Date(now.toLocaleString("en-US", { timeZone: timezone })); + + return (tzDate.getTime() - utcDate.getTime()) / (1000 * 60); +} + +/** + * Resolves the system timezone using the fallback chain: + * 1. DB system_settings.timezone (via cached settings) + * 2. env TZ variable + * 3. "UTC" as final fallback + * + * Each candidate is validated via isValidIANATimezone before being accepted. + * + * @returns Resolved IANA timezone identifier (always valid) + */ +export async function resolveSystemTimezone(): Promise { + // Step 1: Try DB timezone from cached system settings + try { + const settings = await getCachedSystemSettings(); + if (settings.timezone && isValidIANATimezone(settings.timezone)) { + return settings.timezone; + } + } catch (error) { + logger.warn("[TimezoneResolver] Failed to read cached system settings", { error }); + } + + // Step 2: Fallback to env TZ + try { + const { TZ } = getEnvConfig(); + if (TZ && isValidIANATimezone(TZ)) { + return TZ; + } + } catch (error) { + logger.warn("[TimezoneResolver] Failed to read env TZ", { error }); + } + + // Step 3: Ultimate fallback + return "UTC"; +} diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index b92501375..4c5b4d35b 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -6,6 +6,7 @@ import { } from "@/lib/constants/provider.constants"; import { USER_LIMITS } from "@/lib/constants/user.constants"; import { CURRENCY_CONFIG } from "@/lib/utils/currency"; +import { isValidIANATimezone } from "@/lib/utils/timezone"; const CACHE_TTL_PREFERENCE = z.enum(["inherit", "5m", "1h"]); const CONTEXT_1M_PREFERENCE = z.enum(["inherit", "force_enable", "disabled"]); @@ -728,6 +729,15 @@ export const UpdateSystemSettingsSchema = z.object({ billingModelSource: z .enum(["original", "redirected"], { message: "不支持的计费模型来源" }) .optional(), + // 系统时区配置(可选) + // 必须是有效的 IANA 时区标识符(如 "Asia/Shanghai", "America/New_York") + timezone: z + .string() + .refine((val) => isValidIANATimezone(val), { + message: "无效的时区标识符,请使用 IANA 时区格式(如 Asia/Shanghai)", + }) + .nullable() + .optional(), // 日志清理配置(可选) enableAutoCleanup: z.boolean().optional(), cleanupRetentionDays: z.coerce @@ -772,6 +782,35 @@ export const UpdateSystemSettingsSchema = z.object({ }) .partial() .optional(), + + // Quota lease settings + quotaDbRefreshIntervalSeconds: z.coerce + .number() + .int("DB refresh interval must be an integer") + .min(1, "DB refresh interval cannot be less than 1 second") + .max(300, "DB refresh interval cannot exceed 300 seconds") + .optional(), + quotaLeasePercent5h: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeasePercentDaily: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeasePercentWeekly: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeasePercentMonthly: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeaseCapUsd: z.coerce.number().min(0, "Lease cap cannot be negative").nullable().optional(), }); // 导出类型推断 diff --git a/src/lib/webhook/renderers/custom.ts b/src/lib/webhook/renderers/custom.ts index 1ad6c3f09..d5ccba5db 100644 --- a/src/lib/webhook/renderers/custom.ts +++ b/src/lib/webhook/renderers/custom.ts @@ -18,6 +18,7 @@ export class CustomRenderer implements Renderer { message, notificationType: options?.notificationType, data: options?.data, + timezone: options?.timezone, }); const bodyObject = this.interpolate(template, variables); diff --git a/src/lib/webhook/renderers/dingtalk.ts b/src/lib/webhook/renderers/dingtalk.ts index 23c45b903..330996d7b 100644 --- a/src/lib/webhook/renderers/dingtalk.ts +++ b/src/lib/webhook/renderers/dingtalk.ts @@ -4,24 +4,25 @@ import type { SectionContent, StructuredMessage, WebhookPayload, + WebhookSendOptions, } from "../types"; import { formatTimestamp } from "../utils/date"; import type { Renderer } from "./index"; export class DingTalkRenderer implements Renderer { - render(message: StructuredMessage): WebhookPayload { + render(message: StructuredMessage, options?: WebhookSendOptions): WebhookPayload { const markdown = { msgtype: "markdown", markdown: { title: this.escapeText(message.header.title), - text: this.buildMarkdown(message), + text: this.buildMarkdown(message, options?.timezone), }, }; return { body: JSON.stringify(markdown) }; } - private buildMarkdown(message: StructuredMessage): string { + private buildMarkdown(message: StructuredMessage, timezone?: string): string { const lines: string[] = []; lines.push(`### ${this.escapeText(message.header.title)}`); @@ -40,7 +41,7 @@ export class DingTalkRenderer implements Renderer { lines.push(""); } - lines.push(formatTimestamp(message.timestamp)); + lines.push(formatTimestamp(message.timestamp, timezone || "UTC")); return lines.join("\n").trim(); } diff --git a/src/lib/webhook/renderers/feishu.ts b/src/lib/webhook/renderers/feishu.ts index 8375d3c8b..e8dbb0824 100644 --- a/src/lib/webhook/renderers/feishu.ts +++ b/src/lib/webhook/renderers/feishu.ts @@ -5,6 +5,7 @@ import type { SectionContent, StructuredMessage, WebhookPayload, + WebhookSendOptions, } from "../types"; import { formatDateTime } from "../utils/date"; import type { Renderer } from "./index"; @@ -15,7 +16,7 @@ interface CardElement { } export class FeishuCardRenderer implements Renderer { - render(message: StructuredMessage): WebhookPayload { + render(message: StructuredMessage, options?: WebhookSendOptions): WebhookPayload { const elements: CardElement[] = []; // Sections @@ -34,7 +35,7 @@ export class FeishuCardRenderer implements Renderer { // Timestamp elements.push({ tag: "markdown", - content: formatDateTime(message.timestamp), + content: formatDateTime(message.timestamp, options?.timezone || "UTC"), text_size: "notation", }); diff --git a/src/lib/webhook/renderers/telegram.ts b/src/lib/webhook/renderers/telegram.ts index a095960de..977b6515c 100644 --- a/src/lib/webhook/renderers/telegram.ts +++ b/src/lib/webhook/renderers/telegram.ts @@ -4,6 +4,7 @@ import type { SectionContent, StructuredMessage, WebhookPayload, + WebhookSendOptions, } from "../types"; import { formatTimestamp } from "../utils/date"; import type { Renderer } from "./index"; @@ -11,8 +12,8 @@ import type { Renderer } from "./index"; export class TelegramRenderer implements Renderer { constructor(private readonly chatId: string) {} - render(message: StructuredMessage): WebhookPayload { - const html = this.buildHtml(message); + render(message: StructuredMessage, options?: WebhookSendOptions): WebhookPayload { + const html = this.buildHtml(message, options?.timezone); return { body: JSON.stringify({ chat_id: this.chatId, @@ -23,7 +24,7 @@ export class TelegramRenderer implements Renderer { }; } - private buildHtml(message: StructuredMessage): string { + private buildHtml(message: StructuredMessage, timezone?: string): string { const lines: string[] = []; lines.push(`${this.escapeHtml(message.header.title)}`); @@ -42,7 +43,7 @@ export class TelegramRenderer implements Renderer { lines.push(""); } - lines.push(this.escapeHtml(formatTimestamp(message.timestamp))); + lines.push(this.escapeHtml(formatTimestamp(message.timestamp, timezone || "UTC"))); return lines.join("\n").trim(); } diff --git a/src/lib/webhook/renderers/wechat.ts b/src/lib/webhook/renderers/wechat.ts index 3b3fb4f69..551fa17c4 100644 --- a/src/lib/webhook/renderers/wechat.ts +++ b/src/lib/webhook/renderers/wechat.ts @@ -4,12 +4,13 @@ import type { SectionContent, StructuredMessage, WebhookPayload, + WebhookSendOptions, } from "../types"; import { formatTimestamp } from "../utils/date"; import type { Renderer } from "./index"; export class WeChatRenderer implements Renderer { - render(message: StructuredMessage): WebhookPayload { + render(message: StructuredMessage, options?: WebhookSendOptions): WebhookPayload { const lines: string[] = []; // Header @@ -32,7 +33,7 @@ export class WeChatRenderer implements Renderer { } // Timestamp - lines.push(formatTimestamp(message.timestamp)); + lines.push(formatTimestamp(message.timestamp, options?.timezone || "UTC")); const content = lines.join("\n"); diff --git a/src/lib/webhook/templates/circuit-breaker.ts b/src/lib/webhook/templates/circuit-breaker.ts index 251e546c7..7238d0ea6 100644 --- a/src/lib/webhook/templates/circuit-breaker.ts +++ b/src/lib/webhook/templates/circuit-breaker.ts @@ -1,10 +1,13 @@ import type { CircuitBreakerAlertData, StructuredMessage } from "../types"; import { formatDateTime } from "../utils/date"; -export function buildCircuitBreakerMessage(data: CircuitBreakerAlertData): StructuredMessage { +export function buildCircuitBreakerMessage( + data: CircuitBreakerAlertData, + timezone?: string +): StructuredMessage { const fields = [ { label: "失败次数", value: `${data.failureCount} 次` }, - { label: "预计恢复", value: formatDateTime(data.retryAt) }, + { label: "预计恢复", value: formatDateTime(data.retryAt, timezone || "UTC") }, ]; if (data.lastError) { diff --git a/src/lib/webhook/templates/placeholders.ts b/src/lib/webhook/templates/placeholders.ts index ffa6ef68e..7bfd405e2 100644 --- a/src/lib/webhook/templates/placeholders.ts +++ b/src/lib/webhook/templates/placeholders.ts @@ -26,7 +26,7 @@ export const TEMPLATE_PLACEHOLDERS = { { key: "{{timestamp_local}}", label: "本地时间", - description: "本地格式化时间(Asia/Shanghai)", + description: "本地格式化时间(系统时区)", }, { key: "{{title}}", label: "消息标题", description: "通知标题" }, { key: "{{level}}", label: "消息级别", description: "info / warning / error" }, @@ -70,14 +70,15 @@ export function buildTemplateVariables(params: { message: StructuredMessage; notificationType?: WebhookNotificationType; data?: unknown; + timezone?: string; }): Record { - const { message, notificationType, data } = params; + const { message, notificationType, data, timezone } = params; const values: Record = {}; // 通用字段 values["{{timestamp}}"] = message.timestamp.toISOString(); - values["{{timestamp_local}}"] = formatLocalTimestamp(message.timestamp); + values["{{timestamp_local}}"] = formatLocalTimestamp(message.timestamp, timezone); values["{{title}}"] = message.header.title; values["{{level}}"] = message.header.level; values["{{sections}}"] = renderMessageSections(message); @@ -129,17 +130,31 @@ function safeJsonStringify(value: unknown): string { } } -function formatLocalTimestamp(date: Date): string { - return date.toLocaleString("zh-CN", { - timeZone: "Asia/Shanghai", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); +function formatLocalTimestamp(date: Date, timezone?: string): string { + try { + return date.toLocaleString("zh-CN", { + timeZone: timezone || "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } catch { + // Fallback to UTC if timezone is invalid + return date.toLocaleString("zh-CN", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } } function renderMessageSections(message: StructuredMessage): string { diff --git a/src/lib/webhook/templates/test-messages.ts b/src/lib/webhook/templates/test-messages.ts index d06b751f4..1a7f521e0 100644 --- a/src/lib/webhook/templates/test-messages.ts +++ b/src/lib/webhook/templates/test-messages.ts @@ -7,17 +7,23 @@ import { buildDailyLeaderboardMessage } from "./daily-leaderboard"; /** * 根据通知类型构建测试消息 * 使用模拟数据,完整展示真实消息格式 + * + * @param type - Notification job type + * @param timezone - IANA timezone identifier for date/time formatting (optional, defaults to UTC) */ -export function buildTestMessage(type: NotificationJobType): StructuredMessage { +export function buildTestMessage(type: NotificationJobType, timezone?: string): StructuredMessage { switch (type) { case "circuit-breaker": - return buildCircuitBreakerMessage({ - providerName: "测试供应商", - providerId: 0, - failureCount: 3, - retryAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), - lastError: "Connection timeout (示例错误)", - }); + return buildCircuitBreakerMessage( + { + providerName: "测试供应商", + providerId: 0, + failureCount: 3, + retryAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + lastError: "Connection timeout (示例错误)", + }, + timezone + ); case "cost-alert": return buildCostAlertMessage({ diff --git a/src/lib/webhook/types.ts b/src/lib/webhook/types.ts index e2b7b3f75..019f1993e 100644 --- a/src/lib/webhook/types.ts +++ b/src/lib/webhook/types.ts @@ -103,6 +103,8 @@ export interface WebhookSendOptions { notificationType?: WebhookNotificationType; data?: unknown; templateOverride?: Record | null; + /** IANA timezone identifier for date/time formatting */ + timezone?: string; } export interface WebhookPayload { diff --git a/src/lib/webhook/utils/date.ts b/src/lib/webhook/utils/date.ts index 806e43c4c..24eaeae49 100644 --- a/src/lib/webhook/utils/date.ts +++ b/src/lib/webhook/utils/date.ts @@ -1,20 +1,24 @@ +import { formatInTimeZone } from "date-fns-tz"; + /** - * 格式化日期时间为中国时区的本地化字符串 + * Format date-time for webhook messages. + * Uses ISO-like format (yyyy/MM/dd HH:mm:ss) for consistency across locales. + * + * @param date - Date object or ISO string to format + * @param timezone - IANA timezone identifier (required, use resolveSystemTimezone() for system default) + * @returns Formatted datetime string in the specified timezone + * + * @example + * formatDateTime(new Date(), "Asia/Shanghai") // "2024/01/15 14:30:00" */ -export function formatDateTime(date: Date | string): string { +export function formatDateTime(date: Date | string, timezone: string): string { const d = typeof date === "string" ? new Date(date) : date; - return d.toLocaleString("zh-CN", { - timeZone: "Asia/Shanghai", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); + return formatInTimeZone(d, timezone, "yyyy/MM/dd HH:mm:ss"); } -export function formatTimestamp(date: Date): string { - return formatDateTime(date); +/** + * Alias for formatDateTime for backward compatibility + */ +export function formatTimestamp(date: Date, timezone: string): string { + return formatDateTime(date, timezone); } diff --git a/src/repository/_shared/transformers.test.ts b/src/repository/_shared/transformers.test.ts index d5a54816c..c835d28fd 100644 --- a/src/repository/_shared/transformers.test.ts +++ b/src/repository/_shared/transformers.test.ts @@ -277,6 +277,7 @@ describe("src/repository/_shared/transformers.ts", () => { expect(result.allowGlobalUsageView).toBe(true); expect(result.currencyDisplay).toBe("USD"); expect(result.billingModelSource).toBe("original"); + expect(result.timezone).toBeNull(); expect(result.enableAutoCleanup).toBe(false); expect(result.cleanupRetentionDays).toBe(30); expect(result.cleanupSchedule).toBe("0 2 * * *"); diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 09e50ee2a..c1b3f38b3 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -179,6 +179,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { allowGlobalUsageView: dbSettings?.allowGlobalUsageView ?? true, currencyDisplay: dbSettings?.currencyDisplay ?? "USD", billingModelSource: dbSettings?.billingModelSource ?? "original", + timezone: dbSettings?.timezone ?? null, enableAutoCleanup: dbSettings?.enableAutoCleanup ?? false, cleanupRetentionDays: dbSettings?.cleanupRetentionDays ?? 30, cleanupSchedule: dbSettings?.cleanupSchedule ?? "0 2 * * *", @@ -194,6 +195,20 @@ export function toSystemSettings(dbSettings: any): SystemSettings { ...defaultResponseFixerConfig, ...(dbSettings?.responseFixerConfig ?? {}), }, + quotaDbRefreshIntervalSeconds: dbSettings?.quotaDbRefreshIntervalSeconds ?? 10, + quotaLeasePercent5h: dbSettings?.quotaLeasePercent5h + ? parseFloat(dbSettings.quotaLeasePercent5h) + : 0.05, + quotaLeasePercentDaily: dbSettings?.quotaLeasePercentDaily + ? parseFloat(dbSettings.quotaLeasePercentDaily) + : 0.05, + quotaLeasePercentWeekly: dbSettings?.quotaLeasePercentWeekly + ? parseFloat(dbSettings.quotaLeasePercentWeekly) + : 0.05, + quotaLeasePercentMonthly: dbSettings?.quotaLeasePercentMonthly + ? parseFloat(dbSettings.quotaLeasePercentMonthly) + : 0.05, + quotaLeaseCapUsd: dbSettings?.quotaLeaseCapUsd ? parseFloat(dbSettings.quotaLeaseCapUsd) : null, createdAt: dbSettings?.createdAt ? new Date(dbSettings.createdAt) : new Date(), updatedAt: dbSettings?.updatedAt ? new Date(dbSettings.updatedAt) : new Date(), }; diff --git a/src/repository/index.ts b/src/repository/index.ts index b0946ba8d..17258f18b 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -44,7 +44,7 @@ export { getDistinctProviderGroups, updateProvider, } from "./provider"; - +export type { ProviderEndpointProbeTarget } from "./provider-endpoints"; export { createProviderEndpoint, deleteProviderEndpointProbeLogsBeforeDateBatch, @@ -52,6 +52,7 @@ export { findEnabledProviderEndpointsForProbing, findProviderEndpointById, findProviderEndpointProbeLogs, + findProviderEndpointsByVendor, findProviderEndpointsByVendorAndType, findProviderVendorById, findProviderVendors, diff --git a/src/repository/key.ts b/src/repository/key.ts index 1347188f4..9a5631cfd 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -335,11 +335,11 @@ export async function findKeyUsageTodayBatch( keyId: keys.id, totalCost: sum(messageRequest.costUsd), totalTokens: sql`COALESCE(SUM( - COALESCE(${messageRequest.inputTokens}, 0) + - COALESCE(${messageRequest.outputTokens}, 0) + - COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + - COALESCE(${messageRequest.cacheReadInputTokens}, 0) - ), 0)::int`, + COALESCE(${messageRequest.inputTokens}, 0)::double precision + + COALESCE(${messageRequest.outputTokens}, 0)::double precision + + COALESCE(${messageRequest.cacheCreationInputTokens}, 0)::double precision + + COALESCE(${messageRequest.cacheReadInputTokens}, 0)::double precision + ), 0::double precision)`, }) .from(keys) .leftJoin( @@ -622,10 +622,10 @@ export async function findKeysWithStatistics(userId: number): Promise`count(*)::int`, totalCost: sum(messageRequest.costUsd), - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, }) .from(messageRequest) .where( @@ -771,10 +771,10 @@ export async function findKeysWithStatisticsBatch( model: messageRequest.model, callCount: sql`count(*)::int`, totalCost: sum(messageRequest.costUsd), - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, }) .from(messageRequest) .where( diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 0e96bf0d3..d173dfeff 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -3,7 +3,7 @@ import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest, providers, users } from "@/drizzle/schema"; -import { getEnvConfig } from "@/lib/config"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { ProviderType } from "@/types/provider"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { getSystemSettings } from "./system-config"; @@ -73,34 +73,34 @@ export interface ModelLeaderboardEntry { /** * 查询今日消耗排行榜(不限制数量) - * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 */ export async function findDailyLeaderboard( userFilters?: UserLeaderboardFilters ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters); } /** * 查询本月消耗排行榜(不限制数量) - * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 */ export async function findMonthlyLeaderboard( userFilters?: UserLeaderboardFilters ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findLeaderboardWithTimezone("monthly", timezone, undefined, userFilters); } /** * 查询本周消耗排行榜(不限制数量) - * 使用 SQL AT TIME ZONE 进行时区转换,确保"本周"基于配置时区 + * 使用 SQL AT TIME ZONE 进行时区转换,确保"本周"基于系统时区 */ export async function findWeeklyLeaderboard( userFilters?: UserLeaderboardFilters ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters); } @@ -110,7 +110,7 @@ export async function findWeeklyLeaderboard( export async function findAllTimeLeaderboard( userFilters?: UserLeaderboardFilters ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findLeaderboardWithTimezone("allTime", timezone, undefined, userFilters); } @@ -119,7 +119,7 @@ export async function findAllTimeLeaderboard( * 使用滚动24小时窗口而非日历日 */ export async function findLast24HoursLeaderboard(): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findLeaderboardWithTimezone("last24h", timezone); } @@ -244,29 +244,29 @@ export async function findCustomRangeLeaderboard( dateRange: DateRangeParams, userFilters?: UserLeaderboardFilters ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters); } /** * 查询今日供应商消耗排行榜(不限制数量) - * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 */ export async function findDailyProviderLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderLeaderboardWithTimezone("daily", timezone, undefined, providerType); } /** * 查询本月供应商消耗排行榜(不限制数量) - * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 */ export async function findMonthlyProviderLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderLeaderboardWithTimezone("monthly", timezone, undefined, providerType); } @@ -276,7 +276,7 @@ export async function findMonthlyProviderLeaderboard( export async function findWeeklyProviderLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderLeaderboardWithTimezone("weekly", timezone, undefined, providerType); } @@ -286,7 +286,7 @@ export async function findWeeklyProviderLeaderboard( export async function findAllTimeProviderLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderLeaderboardWithTimezone("allTime", timezone, undefined, providerType); } @@ -296,7 +296,7 @@ export async function findAllTimeProviderLeaderboard( export async function findDailyProviderCacheHitRateLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderCacheHitRateLeaderboardWithTimezone( "daily", timezone, @@ -311,7 +311,7 @@ export async function findDailyProviderCacheHitRateLeaderboard( export async function findMonthlyProviderCacheHitRateLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderCacheHitRateLeaderboardWithTimezone( "monthly", timezone, @@ -326,7 +326,7 @@ export async function findMonthlyProviderCacheHitRateLeaderboard( export async function findWeeklyProviderCacheHitRateLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderCacheHitRateLeaderboardWithTimezone( "weekly", timezone, @@ -341,7 +341,7 @@ export async function findWeeklyProviderCacheHitRateLeaderboard( export async function findAllTimeProviderCacheHitRateLeaderboard( providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderCacheHitRateLeaderboardWithTimezone( "allTime", timezone, @@ -439,9 +439,9 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( providerType?: ProviderType ): Promise { const totalInputTokensExpr = sql`( - COALESCE(${messageRequest.inputTokens}, 0) + - COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + - COALESCE(${messageRequest.cacheReadInputTokens}, 0) + COALESCE(${messageRequest.inputTokens}, 0)::double precision + + COALESCE(${messageRequest.cacheCreationInputTokens}, 0)::double precision + + COALESCE(${messageRequest.cacheReadInputTokens}, 0)::double precision )`; const cacheRequiredCondition = sql`( @@ -508,7 +508,7 @@ export async function findCustomRangeProviderLeaderboard( dateRange: DateRangeParams, providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderLeaderboardWithTimezone("custom", timezone, dateRange, providerType); } @@ -519,7 +519,7 @@ export async function findCustomRangeProviderCacheHitRateLeaderboard( dateRange: DateRangeParams, providerType?: ProviderType ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findProviderCacheHitRateLeaderboardWithTimezone( "custom", timezone, @@ -530,19 +530,19 @@ export async function findCustomRangeProviderCacheHitRateLeaderboard( /** * 查询今日模型调用排行榜(不限制数量) - * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 */ export async function findDailyModelLeaderboard(): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findModelLeaderboardWithTimezone("daily", timezone); } /** * 查询本月模型调用排行榜(不限制数量) - * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai) + * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 */ export async function findMonthlyModelLeaderboard(): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findModelLeaderboardWithTimezone("monthly", timezone); } @@ -550,7 +550,7 @@ export async function findMonthlyModelLeaderboard(): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findModelLeaderboardWithTimezone("weekly", timezone); } @@ -558,7 +558,7 @@ export async function findWeeklyModelLeaderboard(): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findModelLeaderboardWithTimezone("allTime", timezone); } @@ -631,6 +631,6 @@ async function findModelLeaderboardWithTimezone( export async function findCustomRangeModelLeaderboard( dateRange: DateRangeParams ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); return findModelLeaderboardWithTimezone("custom", timezone, dateRange); } diff --git a/src/repository/notification-bindings.ts b/src/repository/notification-bindings.ts index 20f4ccf72..431187951 100644 --- a/src/repository/notification-bindings.ts +++ b/src/repository/notification-bindings.ts @@ -3,12 +3,11 @@ import { and, desc, eq, notInArray } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { notificationTargetBindings, webhookTargets } from "@/drizzle/schema"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { WebhookProviderType, WebhookTarget, WebhookTestResult } from "./webhook-targets"; export type NotificationType = "circuit_breaker" | "daily_leaderboard" | "cost_alert"; -const DEFAULT_TIMEZONE = "Asia/Shanghai"; - export interface NotificationBinding { id: number; notificationType: NotificationType; @@ -159,12 +158,15 @@ export async function upsertBindings( type: NotificationType, bindings: BindingInput[] ): Promise { + // Resolve system timezone for default value + const defaultTimezone = await resolveSystemTimezone(); + const normalized = bindings .map((b) => ({ targetId: b.targetId, isEnabled: b.isEnabled ?? true, scheduleCron: b.scheduleCron ?? null, - scheduleTimezone: b.scheduleTimezone ?? DEFAULT_TIMEZONE, + scheduleTimezone: b.scheduleTimezone ?? defaultTimezone, templateOverride: b.templateOverride ?? null, })) .filter((b) => Number.isFinite(b.targetId) && b.targetId > 0); diff --git a/src/repository/overview.ts b/src/repository/overview.ts index 9bb2a04a9..d68ea8661 100644 --- a/src/repository/overview.ts +++ b/src/repository/overview.ts @@ -3,8 +3,8 @@ import { and, avg, count, eq, gte, isNull, sql, sum } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest } from "@/drizzle/schema"; -import { getEnvConfig } from "@/lib/config"; import { Decimal, toCostDecimal } from "@/lib/utils/currency"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; /** @@ -38,10 +38,10 @@ export interface OverviewMetricsWithComparison extends OverviewMetrics { /** * 获取今日概览统计数据 * 包括:今日总请求数、今日总消耗、平均响应时间、今日错误率 - * 使用 SQL AT TIME ZONE 确保"今日"基于配置时区(TZ 环境变量) + * 使用 SQL AT TIME ZONE 确保"今日"基于系统时区配置 */ export async function getOverviewMetrics(): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); const [result] = await db .select({ @@ -88,7 +88,7 @@ export async function getOverviewMetrics(): Promise { export async function getOverviewMetricsWithComparison( userId?: number ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); // 用户过滤条件 const userCondition = userId ? eq(messageRequest.userId, userId) : undefined; diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index aab0a8471..b395c068c 100644 --- a/src/repository/provider-endpoints.ts +++ b/src/repository/provider-endpoints.ts @@ -54,6 +54,76 @@ function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null { return null; } +/** + * Normalize URL to host:port format for vendor key when websiteUrl is empty. + * - IPv6 addresses are formatted as [ipv6]:port + * - Default ports: http=80, https=443 + * - URLs without scheme are assumed to be https + */ +function normalizeHostWithPort(rawUrl: string): string | null { + const trimmed = rawUrl.trim(); + if (!trimmed) return null; + + // Add https:// if no scheme present + let urlString = trimmed; + if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) { + urlString = `https://${trimmed}`; + } + + try { + const parsed = new URL(urlString); + const hostname = parsed.hostname?.toLowerCase(); + if (!hostname) return null; + + // Strip www. prefix + const normalizedHostname = hostname.startsWith("www.") ? hostname.slice(4) : hostname; + + // Determine port + let port: string; + if (parsed.port) { + port = parsed.port; + } else { + // Use protocol default port + port = parsed.protocol === "http:" ? "80" : "443"; + } + + // IPv6 addresses already have brackets from URL parser (e.g., "[::1]") + // Just append the port directly + return `${normalizedHostname}:${port}`; + } catch (error) { + logger.debug("[ProviderVendor] Failed to parse URL for host:port", { + urlLength: rawUrl.length, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Compute vendor clustering key based on URLs. + * + * Rules: + * - If websiteUrl is non-empty: key = normalized hostname (strip www, lowercase), ignore port + * - If websiteUrl is empty: key = host:port + * - IPv6 format: [ipv6]:port + * - Missing port: use protocol default (http=80, https=443) + * - No scheme: assume https + */ +export async function computeVendorKey(input: { + providerUrl: string; + websiteUrl?: string | null; +}): Promise { + const { providerUrl, websiteUrl } = input; + + // Case 1: websiteUrl is non-empty - use hostname only (existing behavior) + if (websiteUrl?.trim()) { + return normalizeWebsiteDomainFromUrl(websiteUrl); + } + + // Case 2: websiteUrl is empty - use host:port as key + return normalizeHostWithPort(providerUrl); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function toProviderVendor(row: any): ProviderVendor { return { @@ -106,7 +176,7 @@ function toProviderEndpointProbeLog(row: any): ProviderEndpointProbeLog { export type ProviderEndpointProbeTarget = Pick< ProviderEndpoint, - "id" | "url" | "lastProbedAt" | "lastProbeOk" + "id" | "url" | "vendorId" | "lastProbedAt" | "lastProbeOk" | "lastProbeErrorType" >; export async function findEnabledProviderEndpointsForProbing(): Promise< @@ -116,8 +186,10 @@ export async function findEnabledProviderEndpointsForProbing(): Promise< .select({ id: providerEndpoints.id, url: providerEndpoints.url, + vendorId: providerEndpoints.vendorId, lastProbedAt: providerEndpoints.lastProbedAt, lastProbeOk: providerEndpoints.lastProbeOk, + lastProbeErrorType: providerEndpoints.lastProbeErrorType, }) .from(providerEndpoints) .where(and(eq(providerEndpoints.isEnabled, true), isNull(providerEndpoints.deletedAt))) @@ -126,8 +198,10 @@ export async function findEnabledProviderEndpointsForProbing(): Promise< return rows.map((row) => ({ id: row.id, url: row.url, + vendorId: row.vendorId, lastProbedAt: toNullableDate(row.lastProbedAt), lastProbeOk: row.lastProbeOk ?? null, + lastProbeErrorType: row.lastProbeErrorType ?? null, })); } @@ -184,8 +258,11 @@ export async function getOrCreateProviderVendorIdFromUrls(input: { faviconUrl?: string | null; displayName?: string | null; }): Promise { - const domainSource = input.websiteUrl?.trim() ? input.websiteUrl : input.providerUrl; - const websiteDomain = normalizeWebsiteDomainFromUrl(domainSource); + // Use new computeVendorKey for consistent vendor key calculation + const websiteDomain = await computeVendorKey({ + providerUrl: input.providerUrl, + websiteUrl: input.websiteUrl, + }); if (!websiteDomain) { throw new Error("Failed to resolve provider vendor domain"); } @@ -231,9 +308,25 @@ export async function getOrCreateProviderVendorIdFromUrls(input: { * 从域名派生显示名称(直接使用域名的中间部分) * 例如: anthropic.com -> Anthropic, api.openai.com -> OpenAI */ -function deriveDisplayNameFromDomain(domain: string): string { - const parts = domain.split("."); - const name = parts[0] === "api" && parts[1] ? parts[1] : parts[0]; +export async function deriveDisplayNameFromDomain(domain: string): Promise { + const parts = domain + .split(".") + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length === 0) return ""; + if (parts.length === 1) { + const name = parts[0]; + return name.charAt(0).toUpperCase() + name.slice(1); + } + + const apiPrefixes = new Set(["api", "v1", "v2", "v3", "www"]); + let name = parts[parts.length - 2]; + if (apiPrefixes.has(name) && parts.length >= 3) { + name = parts[parts.length - 3]; + } + if (apiPrefixes.has(name) && parts.length >= 4) { + name = parts[parts.length - 4]; + } return name.charAt(0).toUpperCase() + name.slice(1); } @@ -285,10 +378,13 @@ export async function backfillProviderVendorsFromProviders(): Promise<{ for (const row of rows) { stats.processed++; - const domainSource = row.websiteUrl?.trim() || row.url; - const domain = normalizeWebsiteDomainFromUrl(domainSource); + // Use new computeVendorKey for consistent vendor key calculation + const vendorKey = await computeVendorKey({ + providerUrl: row.url, + websiteUrl: row.websiteUrl, + }); - if (!domain) { + if (!vendorKey) { logger.warn("[backfillVendors] Invalid URL for provider", { providerId: row.id, url: row.url, @@ -299,7 +395,9 @@ export async function backfillProviderVendorsFromProviders(): Promise<{ } try { - const displayName = deriveDisplayNameFromDomain(domain); + // For displayName, extract domain part (remove port if present) + const domainForDisplayName = vendorKey.replace(/:\d+$/, "").replace(/^\[|\]$/g, ""); + const displayName = await deriveDisplayNameFromDomain(domainForDisplayName); const vendorId = await getOrCreateProviderVendorIdFromUrls({ providerUrl: row.url, websiteUrl: row.websiteUrl ?? null, @@ -547,6 +645,33 @@ export async function findProviderEndpointsByVendorAndType( return rows.map(toProviderEndpoint); } +export async function findProviderEndpointsByVendor(vendorId: number): Promise { + const rows = await db + .select({ + id: providerEndpoints.id, + vendorId: providerEndpoints.vendorId, + providerType: providerEndpoints.providerType, + url: providerEndpoints.url, + label: providerEndpoints.label, + sortOrder: providerEndpoints.sortOrder, + isEnabled: providerEndpoints.isEnabled, + lastProbedAt: providerEndpoints.lastProbedAt, + lastProbeOk: providerEndpoints.lastProbeOk, + lastProbeStatusCode: providerEndpoints.lastProbeStatusCode, + lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs, + lastProbeErrorType: providerEndpoints.lastProbeErrorType, + lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage, + createdAt: providerEndpoints.createdAt, + updatedAt: providerEndpoints.updatedAt, + deletedAt: providerEndpoints.deletedAt, + }) + .from(providerEndpoints) + .where(and(eq(providerEndpoints.vendorId, vendorId), isNull(providerEndpoints.deletedAt))) + .orderBy(asc(providerEndpoints.sortOrder), asc(providerEndpoints.id)); + + return rows.map(toProviderEndpoint); +} + export async function createProviderEndpoint(payload: { vendorId: number; providerType: ProviderType; diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 4e6376e1b..50f3a2710 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -4,8 +4,8 @@ import { and, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { providers } from "@/drizzle/schema"; import { getCachedProviders } from "@/lib/cache/provider-cache"; -import { getEnvConfig } from "@/lib/config"; import { logger } from "@/lib/logger"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { CreateProviderData, Provider, UpdateProviderData } from "@/types/provider"; import { toProvider } from "./_shared/transformers"; import { @@ -793,9 +793,9 @@ export async function getProviderStatistics(): Promise< }> > { try { - // 统一的时区处理:使用 PostgreSQL AT TIME ZONE + 环境变量 TZ + // 统一的时区处理:使用 PostgreSQL AT TIME ZONE + 系统时区配置 // 参考 getUserStatisticsFromDB 的实现,避免 Node.js Date 带来的时区偏移 - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); // ⭐ 使用 providerChain 最后一项的 providerId 来确定最终供应商(兼容重试切换) // 如果 provider_chain 为空(无重试),则使用 provider_id 字段 diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 53e597410..5f43efb94 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -3,7 +3,7 @@ import { and, eq, gte, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest } from "@/drizzle/schema"; -import { getEnvConfig } from "@/lib/config"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { DatabaseKey, DatabaseKeyStatRow, @@ -21,7 +21,7 @@ import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; * 注意:这个函数使用原生SQL,因为涉及到PostgreSQL特定的generate_series函数 */ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); let query; switch (timeRange) { @@ -199,7 +199,7 @@ export async function getKeyStatisticsFromDB( userId: number, timeRange: TimeRange ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); let query; switch (timeRange) { @@ -402,7 +402,7 @@ export async function getMixedStatisticsFromDB( ownKeys: DatabaseKeyStatRow[]; othersAggregate: DatabaseStatRow[]; }> { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); let ownKeysQuery; let othersQuery; @@ -720,7 +720,7 @@ export async function getMixedStatisticsFromDB( * @deprecated 使用 sumUserCostInTimeRange() 替代 */ export async function sumUserCostToday(userId: number): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); const query = sql` SELECT COALESCE(SUM(mr.cost_usd), 0) AS total_cost @@ -1043,7 +1043,7 @@ export async function findKeyCostEntriesInTimeRange( export async function getRateLimitEventStats( filters: RateLimitEventFilters = {} ): Promise { - const timezone = getEnvConfig().TZ; + const timezone = await resolveSystemTimezone(); const { user_id, provider_id, limit_type, start_time, end_time, key_id } = filters; // 构建 WHERE 条件 diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 0f764b225..58ddc5f0f 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -140,6 +140,7 @@ function createFallbackSettings(): SystemSettings { allowGlobalUsageView: false, currencyDisplay: "USD", billingModelSource: "original", + timezone: null, enableAutoCleanup: false, cleanupRetentionDays: 30, cleanupSchedule: "0 2 * * *", @@ -158,6 +159,12 @@ function createFallbackSettings(): SystemSettings { maxJsonDepth: 200, maxFixSize: 1024 * 1024, }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, createdAt: now, updatedAt: now, }; @@ -174,6 +181,7 @@ export async function getSystemSettings(): Promise { allowGlobalUsageView: systemSettings.allowGlobalUsageView, currencyDisplay: systemSettings.currencyDisplay, billingModelSource: systemSettings.billingModelSource, + timezone: systemSettings.timezone, enableAutoCleanup: systemSettings.enableAutoCleanup, cleanupRetentionDays: systemSettings.cleanupRetentionDays, cleanupSchedule: systemSettings.cleanupSchedule, @@ -186,6 +194,12 @@ export async function getSystemSettings(): Promise { enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, enableResponseFixer: systemSettings.enableResponseFixer, responseFixerConfig: systemSettings.responseFixerConfig, + quotaDbRefreshIntervalSeconds: systemSettings.quotaDbRefreshIntervalSeconds, + quotaLeasePercent5h: systemSettings.quotaLeasePercent5h, + quotaLeasePercentDaily: systemSettings.quotaLeasePercentDaily, + quotaLeasePercentWeekly: systemSettings.quotaLeasePercentWeekly, + quotaLeasePercentMonthly: systemSettings.quotaLeasePercentMonthly, + quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, createdAt: systemSettings.createdAt, updatedAt: systemSettings.updatedAt, }; @@ -282,6 +296,11 @@ export async function updateSystemSettings( updates.billingModelSource = payload.billingModelSource; } + // 系统时区配置字段(如果提供) + if (payload.timezone !== undefined) { + updates.timezone = payload.timezone; + } + // 日志清理配置字段(如果提供) if (payload.enableAutoCleanup !== undefined) { updates.enableAutoCleanup = payload.enableAutoCleanup; @@ -338,6 +357,27 @@ export async function updateSystemSettings( }; } + // Quota lease settings(如果提供) + if (payload.quotaDbRefreshIntervalSeconds !== undefined) { + updates.quotaDbRefreshIntervalSeconds = payload.quotaDbRefreshIntervalSeconds; + } + if (payload.quotaLeasePercent5h !== undefined) { + updates.quotaLeasePercent5h = String(payload.quotaLeasePercent5h); + } + if (payload.quotaLeasePercentDaily !== undefined) { + updates.quotaLeasePercentDaily = String(payload.quotaLeasePercentDaily); + } + if (payload.quotaLeasePercentWeekly !== undefined) { + updates.quotaLeasePercentWeekly = String(payload.quotaLeasePercentWeekly); + } + if (payload.quotaLeasePercentMonthly !== undefined) { + updates.quotaLeasePercentMonthly = String(payload.quotaLeasePercentMonthly); + } + if (payload.quotaLeaseCapUsd !== undefined) { + updates.quotaLeaseCapUsd = + payload.quotaLeaseCapUsd === null ? null : String(payload.quotaLeaseCapUsd); + } + const [updated] = await db .update(systemSettings) .set(updates) @@ -348,6 +388,7 @@ export async function updateSystemSettings( allowGlobalUsageView: systemSettings.allowGlobalUsageView, currencyDisplay: systemSettings.currencyDisplay, billingModelSource: systemSettings.billingModelSource, + timezone: systemSettings.timezone, enableAutoCleanup: systemSettings.enableAutoCleanup, cleanupRetentionDays: systemSettings.cleanupRetentionDays, cleanupSchedule: systemSettings.cleanupSchedule, @@ -360,6 +401,12 @@ export async function updateSystemSettings( enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, enableResponseFixer: systemSettings.enableResponseFixer, responseFixerConfig: systemSettings.responseFixerConfig, + quotaDbRefreshIntervalSeconds: systemSettings.quotaDbRefreshIntervalSeconds, + quotaLeasePercent5h: systemSettings.quotaLeasePercent5h, + quotaLeasePercentDaily: systemSettings.quotaLeasePercentDaily, + quotaLeasePercentWeekly: systemSettings.quotaLeasePercentWeekly, + quotaLeasePercentMonthly: systemSettings.quotaLeasePercentMonthly, + quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, createdAt: systemSettings.createdAt, updatedAt: systemSettings.updatedAt, }); diff --git a/src/types/model-price.ts b/src/types/model-price.ts index ab2b62797..295d00f77 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -20,6 +20,12 @@ export interface ModelPriceData { // 图片生成价格 output_cost_per_image?: number; + // 图片 token 价格(按 token 计费,用于 Gemini 等模型的图片输出) + output_cost_per_image_token?: number; + // 图片输入价格(按张计费) + input_cost_per_image?: number; + // 图片输入 token 价格(按 token 计费) + input_cost_per_image_token?: number; // 搜索上下文价格 search_context_cost_per_query?: { diff --git a/src/types/system-config.ts b/src/types/system-config.ts index 36e907e89..06ea9e84e 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -22,6 +22,11 @@ export interface SystemSettings { // 计费模型来源配置 billingModelSource: BillingModelSource; + // 系统时区配置 (IANA timezone identifier) + // 用于统一后端时间边界计算和前端日期/时间显示 + // null 表示使用环境变量 TZ 或默认 UTC + timezone: string | null; + // 日志清理配置 enableAutoCleanup?: boolean; cleanupRetentionDays?: number; @@ -52,6 +57,14 @@ export interface SystemSettings { enableResponseFixer: boolean; responseFixerConfig: ResponseFixerConfig; + // Quota lease settings + quotaDbRefreshIntervalSeconds?: number; + quotaLeasePercent5h?: number; + quotaLeasePercentDaily?: number; + quotaLeasePercentWeekly?: number; + quotaLeasePercentMonthly?: number; + quotaLeaseCapUsd?: number | null; + createdAt: Date; updatedAt: Date; } @@ -67,6 +80,9 @@ export interface UpdateSystemSettingsInput { // 计费模型来源配置(可选) billingModelSource?: BillingModelSource; + // 系统时区配置(可选) + timezone?: string | null; + // 日志清理配置(可选) enableAutoCleanup?: boolean; cleanupRetentionDays?: number; @@ -94,4 +110,12 @@ export interface UpdateSystemSettingsInput { // 响应整流(可选) enableResponseFixer?: boolean; responseFixerConfig?: Partial; + + // Quota lease settings(可选) + quotaDbRefreshIntervalSeconds?: number; + quotaLeasePercent5h?: number; + quotaLeasePercentDaily?: number; + quotaLeasePercentWeekly?: number; + quotaLeasePercentMonthly?: number; + quotaLeaseCapUsd?: number | null; } diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index ae3ea8a41..580275668 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -103,6 +103,7 @@ function makeSystemSettings( allowGlobalUsageView: false, currencyDisplay: "USD", billingModelSource, + timezone: null, enableAutoCleanup: false, cleanupRetentionDays: 30, cleanupSchedule: "0 2 * * *", diff --git a/tests/unit/actions/my-usage-date-range-dst.test.ts b/tests/unit/actions/my-usage-date-range-dst.test.ts index f8b5bc40b..bf3fa3766 100644 --- a/tests/unit/actions/my-usage-date-range-dst.test.ts +++ b/tests/unit/actions/my-usage-date-range-dst.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({ getSession: vi.fn(), getSystemSettings: vi.fn(), findUsageLogsWithDetails: vi.fn(), - getEnvConfig: vi.fn(), + resolveSystemTimezone: vi.fn(), })); vi.mock("@/lib/auth", () => ({ @@ -24,14 +24,14 @@ vi.mock("@/repository/usage-logs", async (importOriginal) => { }; }); -vi.mock("@/lib/config", () => ({ - getEnvConfig: mocks.getEnvConfig, +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: mocks.resolveSystemTimezone, })); describe("my-usage date range parsing", () => { it("computes exclusive endTime as next local midnight across DST start", async () => { const tz = "America/Los_Angeles"; - mocks.getEnvConfig.mockReturnValue({ TZ: tz }); + mocks.resolveSystemTimezone.mockResolvedValue(tz); mocks.getSession.mockResolvedValue({ key: { id: 1, key: "k" }, @@ -60,7 +60,7 @@ describe("my-usage date range parsing", () => { it("computes exclusive endTime as next local midnight across DST end", async () => { const tz = "America/Los_Angeles"; - mocks.getEnvConfig.mockReturnValue({ TZ: tz }); + mocks.resolveSystemTimezone.mockResolvedValue(tz); mocks.getSession.mockResolvedValue({ key: { id: 1, key: "k" }, diff --git a/tests/unit/actions/my-usage-token-aggregation.test.ts b/tests/unit/actions/my-usage-token-aggregation.test.ts new file mode 100644 index 000000000..0a9a27ca6 --- /dev/null +++ b/tests/unit/actions/my-usage-token-aggregation.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test, vi } from "vitest"; + +// 禁用 tests/setup.ts 中基于 DSN/Redis 的默认同步与清理协调,避免无关依赖引入。 +process.env.DSN = ""; +process.env.AUTO_CLEANUP_TEST_DATA = "false"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery(result: T) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.where = vi.fn(() => query); + query.groupBy = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + + return query; +} + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getSystemSettings: vi.fn(), + getEnvConfig: vi.fn(), + getTimeRangeForPeriodWithMode: vi.fn(), + findUsageLogsStats: vi.fn(), + select: vi.fn(), + execute: vi.fn(async () => ({ count: 0 })), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: mocks.getSession, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, +})); + +vi.mock("@/lib/config", () => ({ + getEnvConfig: mocks.getEnvConfig, +})); + +vi.mock("@/lib/rate-limit/time-utils", () => ({ + getTimeRangeForPeriodWithMode: mocks.getTimeRangeForPeriodWithMode, +})); + +vi.mock("@/repository/usage-logs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findUsageLogsStats: mocks.findUsageLogsStats, + }; +}); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: mocks.select, + execute: mocks.execute, + }, +})); + +function expectNoIntTokenSum(selection: Record, field: string) { + const tokenSql = sqlToString(selection[field]).toLowerCase(); + expect(tokenSql).toContain("sum"); + expect(tokenSql).not.toContain("::int"); + expect(tokenSql).not.toContain("::int4"); + expect(tokenSql).toContain("double precision"); +} + +describe("my-usage token aggregation", () => { + test("getMyTodayStats: token sum 不应使用 ::int", async () => { + vi.resetModules(); + + const capturedSelections: Array> = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery([ + { + calls: 0, + inputTokens: 0, + outputTokens: 0, + costUsd: "0", + }, + ]) + ); + selectQueue.push(createThenableQuery([])); + + mocks.select.mockImplementation((selection: unknown) => { + capturedSelections.push(selection as Record); + return selectQueue.shift() ?? createThenableQuery([]); + }); + + mocks.getTimeRangeForPeriodWithMode.mockResolvedValue({ + startTime: new Date("2024-01-01T00:00:00.000Z"), + endTime: new Date("2024-01-02T00:00:00.000Z"), + }); + + mocks.getSession.mockResolvedValue({ + key: { + id: 1, + key: "k", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + }, + user: { id: 1 }, + }); + + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + billingModelSource: "original", + }); + + const { getMyTodayStats } = await import("@/actions/my-usage"); + const res = await getMyTodayStats(); + expect(res.ok).toBe(true); + + expect(capturedSelections.length).toBeGreaterThanOrEqual(2); + expectNoIntTokenSum(capturedSelections[0], "inputTokens"); + expectNoIntTokenSum(capturedSelections[0], "outputTokens"); + expectNoIntTokenSum(capturedSelections[1], "inputTokens"); + expectNoIntTokenSum(capturedSelections[1], "outputTokens"); + }); + + test("getMyStatsSummary: token sum 不应使用 ::int", async () => { + vi.resetModules(); + + const capturedSelections: Array> = []; + const selectQueue: any[] = []; + selectQueue.push(createThenableQuery([])); + selectQueue.push(createThenableQuery([])); + + mocks.select.mockImplementation((selection: unknown) => { + capturedSelections.push(selection as Record); + return selectQueue.shift() ?? createThenableQuery([]); + }); + + mocks.getEnvConfig.mockReturnValue({ TZ: "UTC" }); + + mocks.getSession.mockResolvedValue({ + key: { id: 1, key: "k" }, + user: { id: 1 }, + }); + + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + billingModelSource: "original", + }); + + mocks.findUsageLogsStats.mockResolvedValue({ + totalRequests: 0, + totalCost: 0, + totalTokens: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }); + + const { getMyStatsSummary } = await import("@/actions/my-usage"); + const res = await getMyStatsSummary({ startDate: "2024-01-01", endDate: "2024-01-01" }); + expect(res.ok).toBe(true); + + expect(capturedSelections).toHaveLength(2); + + for (const selection of capturedSelections) { + expectNoIntTokenSum(selection, "inputTokens"); + expectNoIntTokenSum(selection, "outputTokens"); + expectNoIntTokenSum(selection, "cacheCreationTokens"); + expectNoIntTokenSum(selection, "cacheReadTokens"); + } + }); +}); diff --git a/tests/unit/actions/providers-recluster.test.ts b/tests/unit/actions/providers-recluster.test.ts new file mode 100644 index 000000000..a9b775d6b --- /dev/null +++ b/tests/unit/actions/providers-recluster.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const findAllProvidersFreshMock = vi.fn(); +const findProviderVendorByIdMock = vi.fn(); +const getOrCreateProviderVendorIdFromUrlsMock = vi.fn(); +const computeVendorKeyMock = vi.fn(); +const backfillProviderEndpointsFromProvidersMock = vi.fn(); +const tryDeleteProviderVendorIfEmptyMock = vi.fn(); +const publishProviderCacheInvalidationMock = vi.fn(); +const dbMock = { + transaction: vi.fn(), + update: vi.fn(), +}; + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + findAllProviders: vi.fn(async () => []), + findAllProvidersFresh: findAllProvidersFreshMock, + findProviderById: vi.fn(async () => null), +})); + +vi.mock("@/repository/provider-endpoints", () => ({ + computeVendorKey: computeVendorKeyMock, + findProviderVendorById: findProviderVendorByIdMock, + getOrCreateProviderVendorIdFromUrls: getOrCreateProviderVendorIdFromUrlsMock, + backfillProviderEndpointsFromProviders: backfillProviderEndpointsFromProvidersMock, + tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock, +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: publishProviderCacheInvalidationMock, +})); + +vi.mock("@/drizzle/db", () => ({ + db: dbMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("reclusterProviderVendors", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("permission checks", () => { + it("returns error when not logged in", async () => { + getSessionMock.mockResolvedValue(null); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: false }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("returns error when user is not admin", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } }); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: false }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("allows admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([]); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: false }); + + expect(result.ok).toBe(true); + }); + }); + + describe("preview mode (confirm=false)", () => { + it("returns empty changes when no providers", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([]); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: false }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.applied).toBe(false); + expect(result.data.changes).toEqual([]); + expect(result.data.preview.providersMoved).toBe(0); + } + }); + + it("detects providers that need vendor change", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([ + { + id: 1, + name: "Provider 1", + url: "http://192.168.1.1:8080/v1/messages", + websiteUrl: null, + providerVendorId: 1, + }, + { + id: 2, + name: "Provider 2", + url: "http://192.168.1.1:9090/v1/messages", + websiteUrl: null, + providerVendorId: 1, // Same vendor but different port - should change + }, + ]); + + // Current vendor has domain "192.168.1.1" (old behavior) + findProviderVendorByIdMock.mockResolvedValue({ + id: 1, + websiteDomain: "192.168.1.1", + }); + + // New vendor keys include port + computeVendorKeyMock + .mockReturnValueOnce("192.168.1.1:8080") + .mockReturnValueOnce("192.168.1.1:9090"); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: false }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.applied).toBe(false); + expect(result.data.preview.providersMoved).toBe(2); + expect(result.data.changes.length).toBe(2); + } + }); + + it("does not modify database in preview mode", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([ + { + id: 1, + name: "Provider 1", + url: "http://192.168.1.1:8080/v1/messages", + websiteUrl: null, + providerVendorId: 1, + }, + ]); + findProviderVendorByIdMock.mockResolvedValue({ + id: 1, + websiteDomain: "192.168.1.1", + }); + computeVendorKeyMock.mockReturnValue("192.168.1.1:8080"); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + await reclusterProviderVendors({ confirm: false }); + + expect(dbMock.transaction).not.toHaveBeenCalled(); + expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled(); + }); + + it("skips providers with invalid URLs", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([ + { + id: 1, + name: "Invalid Provider", + url: "://invalid", + websiteUrl: null, + providerVendorId: null, + }, + ]); + computeVendorKeyMock.mockReturnValue(null); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: false }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.preview.skippedInvalidUrl).toBe(1); + expect(result.data.preview.providersMoved).toBe(0); + } + }); + }); + + describe("apply mode (confirm=true)", () => { + it("executes database updates in transaction", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([ + { + id: 1, + name: "Provider 1", + url: "http://192.168.1.1:8080/v1/messages", + websiteUrl: null, + providerVendorId: 1, + }, + ]); + findProviderVendorByIdMock.mockResolvedValue({ + id: 1, + websiteDomain: "192.168.1.1", + }); + computeVendorKeyMock.mockReturnValue("192.168.1.1:8080"); + getOrCreateProviderVendorIdFromUrlsMock.mockResolvedValue(2); + backfillProviderEndpointsFromProvidersMock.mockResolvedValue({}); + tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true); + dbMock.transaction.mockImplementation(async (fn) => { + return fn({ + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue({}), + }), + }), + }); + }); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: true }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.applied).toBe(true); + } + expect(dbMock.transaction).toHaveBeenCalled(); + }); + + it("publishes cache invalidation after apply", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([ + { + id: 1, + name: "Provider 1", + url: "http://192.168.1.1:8080/v1/messages", + websiteUrl: null, + providerVendorId: 1, + }, + ]); + findProviderVendorByIdMock.mockResolvedValue({ + id: 1, + websiteDomain: "192.168.1.1", + }); + computeVendorKeyMock.mockReturnValue("192.168.1.1:8080"); + getOrCreateProviderVendorIdFromUrlsMock.mockResolvedValue(2); + backfillProviderEndpointsFromProvidersMock.mockResolvedValue({}); + tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true); + dbMock.transaction.mockImplementation(async (fn) => { + return fn({ + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue({}), + }), + }), + }); + }); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + await reclusterProviderVendors({ confirm: true }); + + expect(publishProviderCacheInvalidationMock).toHaveBeenCalled(); + }); + + it("does not apply when no changes needed", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([ + { + id: 1, + name: "Provider 1", + url: "http://192.168.1.1:8080/v1/messages", + websiteUrl: null, + providerVendorId: 1, + }, + ]); + // Vendor already has correct domain + findProviderVendorByIdMock.mockResolvedValue({ + id: 1, + websiteDomain: "192.168.1.1:8080", + }); + computeVendorKeyMock.mockReturnValue("192.168.1.1:8080"); + + const { reclusterProviderVendors } = await import("@/actions/providers"); + const result = await reclusterProviderVendors({ confirm: true }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.applied).toBe(true); + expect(result.data.preview.providersMoved).toBe(0); + } + expect(dbMock.transaction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/actions/system-config-save.test.ts b/tests/unit/actions/system-config-save.test.ts new file mode 100644 index 000000000..6c6b6b73e --- /dev/null +++ b/tests/unit/actions/system-config-save.test.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { locales } from "@/i18n/config"; + +// Mock dependencies +const getSessionMock = vi.fn(); +const revalidatePathMock = vi.fn(); +const invalidateSystemSettingsCacheMock = vi.fn(); +const updateSystemSettingsMock = vi.fn(); +const getSystemSettingsMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: () => getSessionMock(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: (...args: unknown[]) => revalidatePathMock(...args), +})); + +vi.mock("@/lib/config", () => ({ + invalidateSystemSettingsCache: () => invalidateSystemSettingsCacheMock(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), + isValidIANATimezone: vi.fn(() => true), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: () => getSystemSettingsMock(), + updateSystemSettings: (...args: unknown[]) => updateSystemSettingsMock(...args), +})); + +// Import the action after mocks are set up +import { saveSystemSettings } from "@/actions/system-config"; + +describe("saveSystemSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: admin session + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + // Default: successful update + updateSystemSettingsMock.mockResolvedValue({ + id: 1, + siteTitle: "Test Site", + allowGlobalUsageView: false, + currencyDisplay: "CNY", + billingModelSource: "original", + timezone: null, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 3 * * *", + cleanupBatchSize: 1000, + enableClientVersionCheck: false, + verboseProviderError: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: false, + enableCodexSessionIdCompletion: false, + enableResponseFixer: false, + responseFixerConfig: { + fixEncoding: false, + fixStreamingJson: false, + fixEmptyResponse: false, + fixContentBlockDelta: false, + maxRetries: 3, + timeout: 5000, + }, + quotaDbRefreshIntervalSeconds: 60, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + }); + + it("should return error when user is not admin", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } }); + + const result = await saveSystemSettings({ siteTitle: "New Title" }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(updateSystemSettingsMock).not.toHaveBeenCalled(); + }); + + it("should return error when user is not logged in", async () => { + getSessionMock.mockResolvedValue(null); + + const result = await saveSystemSettings({ siteTitle: "New Title" }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(updateSystemSettingsMock).not.toHaveBeenCalled(); + }); + + it("should call updateSystemSettings with validated data", async () => { + const result = await saveSystemSettings({ + siteTitle: "New Site Title", + verboseProviderError: true, + }); + + expect(result.ok).toBe(true); + expect(updateSystemSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + siteTitle: "New Site Title", + verboseProviderError: true, + }) + ); + }); + + it("should invalidate system settings cache after successful save", async () => { + await saveSystemSettings({ siteTitle: "New Title" }); + + expect(invalidateSystemSettingsCacheMock).toHaveBeenCalled(); + }); + + describe("revalidatePath locale coverage", () => { + it("should revalidate paths for ALL supported locales", async () => { + await saveSystemSettings({ siteTitle: "New Title" }); + + // Collect all revalidatePath calls + const calls = revalidatePathMock.mock.calls.map((call) => call[0]); + + // Check that each locale's settings/config path is revalidated + for (const locale of locales) { + const expectedSettingsPath = `/${locale}/settings/config`; + expect(calls).toContain(expectedSettingsPath); + } + }); + + it("should revalidate dashboard paths for ALL supported locales", async () => { + await saveSystemSettings({ siteTitle: "New Title" }); + + const calls = revalidatePathMock.mock.calls.map((call) => call[0]); + + // Check that each locale's dashboard path is revalidated + for (const locale of locales) { + const expectedDashboardPath = `/${locale}/dashboard`; + expect(calls).toContain(expectedDashboardPath); + } + }); + + it("should revalidate root layout", async () => { + await saveSystemSettings({ siteTitle: "New Title" }); + + // Check that root layout is revalidated + expect(revalidatePathMock).toHaveBeenCalledWith("/", "layout"); + }); + + it("should call revalidatePath at least 2 * locales.length + 1 times", async () => { + await saveSystemSettings({ siteTitle: "New Title" }); + + // 2 paths per locale (settings/config + dashboard) + 1 for root layout + const expectedMinCalls = locales.length * 2 + 1; + expect(revalidatePathMock).toHaveBeenCalledTimes(expectedMinCalls); + }); + }); + + it("should return updated settings on success", async () => { + const mockUpdated = { + id: 1, + siteTitle: "Updated Title", + allowGlobalUsageView: true, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: "America/New_York", + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 3 * * *", + cleanupBatchSize: 1000, + enableClientVersionCheck: false, + verboseProviderError: true, + enableHttp2: true, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: false, + enableCodexSessionIdCompletion: false, + enableResponseFixer: false, + responseFixerConfig: { + fixEncoding: false, + fixStreamingJson: false, + fixEmptyResponse: false, + fixContentBlockDelta: false, + maxRetries: 3, + timeout: 5000, + }, + quotaDbRefreshIntervalSeconds: 60, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + updateSystemSettingsMock.mockResolvedValue(mockUpdated); + + const result = await saveSystemSettings({ + siteTitle: "Updated Title", + allowGlobalUsageView: true, + currencyDisplay: "USD", + timezone: "America/New_York", + verboseProviderError: true, + enableHttp2: true, + }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual(mockUpdated); + }); + + it("should handle repository errors gracefully", async () => { + updateSystemSettingsMock.mockRejectedValue(new Error("Database error")); + + const result = await saveSystemSettings({ siteTitle: "New Title" }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Database error"); + }); +}); diff --git a/tests/unit/components/session-list-item.test.tsx b/tests/unit/components/session-list-item.test.tsx new file mode 100644 index 000000000..c50f78faa --- /dev/null +++ b/tests/unit/components/session-list-item.test.tsx @@ -0,0 +1,104 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test, vi } from "vitest"; +import { SessionListItem } from "@/components/customs/session-list-item"; +import type { CurrencyCode } from "@/lib/utils/currency"; +import type { ActiveSessionInfo } from "@/types/session"; + +vi.mock("@/i18n/routing", () => ({ + Link: ({ + href, + children, + ...rest + }: { + href: string; + children: ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +vi.mock("@/lib/utils/currency", async () => { + const actual = + await vi.importActual("@/lib/utils/currency"); + return { + ...actual, + formatCurrency: () => "__COST__", + }; +}); + +const UP_ARROW = "\u2191"; +const DOWN_ARROW = "\u2193"; +const COST_SENTINEL = "__COST__"; + +type SessionListItemProps = { + session: ActiveSessionInfo; + currencyCode?: CurrencyCode; + showTokensCost?: boolean; +}; + +const SessionListItemTest = SessionListItem as unknown as ( + props: SessionListItemProps +) => JSX.Element; + +const baseSession: ActiveSessionInfo = { + sessionId: "session-1", + userName: "alice", + userId: 1, + keyId: 2, + keyName: "key-1", + providerId: 3, + providerName: "openai", + model: "gpt-4.1", + apiType: "chat", + startTime: 1700000000000, + status: "completed", + durationMs: 1500, + inputTokens: 100, + outputTokens: 50, + costUsd: "0.0123", +}; + +function renderTextContent(options?: { + showTokensCost?: boolean; + sessionOverrides?: Partial; +}) { + const session = { ...baseSession, ...(options?.sessionOverrides ?? {}) }; + const html = renderToStaticMarkup( + + ); + const container = document.createElement("div"); + container.innerHTML = html; + return container.textContent ?? ""; +} + +describe("SessionListItem showTokensCost", () => { + test("hides tokens and cost when disabled but keeps core fields", () => { + const text = renderTextContent({ showTokensCost: false }); + + expect(text).not.toContain(`${UP_ARROW}100`); + expect(text).not.toContain(`${DOWN_ARROW}50`); + expect(text).not.toContain(COST_SENTINEL); + + expect(text).toContain("alice"); + expect(text).toContain("key-1"); + expect(text).toContain("gpt-4.1"); + expect(text).toContain("@ openai"); + expect(text).toContain("1.5s"); + }); + + test("shows tokens and cost by default", () => { + const text = renderTextContent(); + + expect(text).toContain(`${UP_ARROW}100`); + expect(text).toContain(`${DOWN_ARROW}50`); + expect(text).toContain(COST_SENTINEL); + }); +}); diff --git a/tests/unit/dashboard-logs-time-range-utils.test.ts b/tests/unit/dashboard-logs-time-range-utils.test.ts index db14488ce..bceb2547e 100644 --- a/tests/unit/dashboard-logs-time-range-utils.test.ts +++ b/tests/unit/dashboard-logs-time-range-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { dateStringWithClockToTimestamp, formatClockFromTimestamp, + getQuickDateRange, inclusiveEndTimestampFromExclusive, parseClockString, } from "@/app/[locale]/dashboard/logs/_utils/time-range"; @@ -43,4 +44,18 @@ describe("dashboard logs time range utils", () => { const ts = new Date(2026, 0, 1, 1, 2, 3, 0).getTime(); expect(formatClockFromTimestamp(ts)).toBe("01:02:03"); }); + + test("getQuickDateRange uses server timezone for today/yesterday", () => { + const now = new Date("2024-01-02T02:00:00Z"); + const tz = "America/Los_Angeles"; + + expect(getQuickDateRange("today", tz, now)).toEqual({ + startDate: "2024-01-01", + endDate: "2024-01-01", + }); + expect(getQuickDateRange("yesterday", tz, now)).toEqual({ + startDate: "2023-12-31", + endDate: "2023-12-31", + }); + }); }); diff --git a/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx index acfda7403..c77d12df8 100644 --- a/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx +++ b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx @@ -41,11 +41,11 @@ vi.mock("@/actions/usage-logs", () => ({ statusCode: 200, inputTokens: 1, outputTokens: 1, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreation5mInputTokens: 0, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 10, cacheCreation1hInputTokens: 0, - cacheTtlApplied: null, + cacheTtlApplied: "1h", totalTokens: 2, costUsd: "0.000001", costMultiplier: null, @@ -160,3 +160,19 @@ describe("VirtualizedLogsTable - specialSettings display", () => { unmount(); }); }); + +describe("VirtualizedLogsTable - cache badge alignment", () => { + test("badge renders left while numbers stay right", async () => { + const { container, unmount } = renderWithIntl( + + ); + + await flushMicrotasks(); + await waitForText(container, "Loaded 1 records"); + + expect(container.innerHTML).toContain("1h"); + expect(container.innerHTML).toContain("ml-auto"); + + unmount(); + }); +}); diff --git a/tests/unit/dashboard-logs-warmup-ui.test.tsx b/tests/unit/dashboard-logs-warmup-ui.test.tsx index d8986867c..19b552de0 100644 --- a/tests/unit/dashboard-logs-warmup-ui.test.tsx +++ b/tests/unit/dashboard-logs-warmup-ui.test.tsx @@ -102,3 +102,56 @@ describe("UsageLogsTable - warmup 跳过展示", () => { unmount(); }); }); + +describe("UsageLogsTable - cache badge alignment", () => { + test("badge renders before numbers and keeps right-aligned tokens", () => { + const cacheLog: UsageLogRow = { + id: 2, + createdAt: new Date(), + sessionId: "session_cache", + requestSequence: 1, + userName: "user", + keyName: "key", + providerName: "provider", + model: "claude-sonnet-4-5-20250929", + originalModel: "claude-sonnet-4-5-20250929", + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 10, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: "1h", + totalTokens: 15, + costUsd: "0.000001", + costMultiplier: null, + durationMs: 10, + ttfbMs: 5, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: "claude_cli/1.0", + messagesCount: 1, + context1mApplied: false, + }; + + const { container, unmount } = renderWithIntl( + {}} + isPending={false} + /> + ); + + expect(container.innerHTML).toContain("1h"); + expect(container.innerHTML).toContain("ml-auto"); + + unmount(); + }); +}); diff --git a/tests/unit/dashboard/availability/availability-dashboard-ui.test.tsx b/tests/unit/dashboard/availability/availability-dashboard-ui.test.tsx new file mode 100644 index 000000000..8b6772fb7 --- /dev/null +++ b/tests/unit/dashboard/availability/availability-dashboard-ui.test.tsx @@ -0,0 +1,77 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { AvailabilityDashboard } from "@/app/[locale]/dashboard/availability/_components/availability-dashboard"; + +vi.mock("@/app/[locale]/dashboard/availability/_components/overview/overview-section", () => ({ + OverviewSection: () =>
, +})); +vi.mock("@/app/[locale]/dashboard/availability/_components/provider/provider-tab", () => ({ + ProviderTab: () =>
, +})); +vi.mock("@/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab", () => ({ + EndpointTab: () =>
, +})); + +function renderWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("AvailabilityDashboard UI", () => { + test("does not render Probe All floating button", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ providers: [], systemAvailability: 0 }), + })) + ); + + const { container, unmount } = renderWithIntl(); + + expect(container.textContent).not.toContain("Probe All"); + + unmount(); + }); +}); diff --git a/tests/unit/dashboard/availability/latency-chart.test.tsx b/tests/unit/dashboard/availability/latency-chart.test.tsx index 6831719c1..2f0334737 100644 --- a/tests/unit/dashboard/availability/latency-chart.test.tsx +++ b/tests/unit/dashboard/availability/latency-chart.test.tsx @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // Mock next-intl vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, + useTimeZone: () => "UTC", })); // Mock recharts to expose color props via data-* attributes diff --git a/tests/unit/dashboard/availability/latency-curve.test.tsx b/tests/unit/dashboard/availability/latency-curve.test.tsx index c437a5596..56579734d 100644 --- a/tests/unit/dashboard/availability/latency-curve.test.tsx +++ b/tests/unit/dashboard/availability/latency-curve.test.tsx @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // Mock next-intl vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, + useTimeZone: () => "UTC", })); // Mock recharts to expose color props via data-* attributes diff --git a/tests/unit/dashboard/dashboard-home-layout.test.tsx b/tests/unit/dashboard/dashboard-home-layout.test.tsx new file mode 100644 index 000000000..d7f9d52fb --- /dev/null +++ b/tests/unit/dashboard/dashboard-home-layout.test.tsx @@ -0,0 +1,228 @@ +/** + * @vitest-environment happy-dom + */ +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { DashboardBento } from "@/app/[locale]/dashboard/_components/bento/dashboard-bento"; +import { DashboardMain } from "@/app/[locale]/dashboard/_components/dashboard-main"; +import type { OverviewData } from "@/actions/overview"; +import type { UserStatisticsData } from "@/types/statistics"; + +const routingMocks = vi.hoisted(() => ({ + usePathname: vi.fn(), +})); +vi.mock("@/i18n/routing", () => ({ + usePathname: routingMocks.usePathname, +})); + +const overviewMocks = vi.hoisted(() => ({ + getOverviewData: vi.fn(), +})); +vi.mock("@/actions/overview", () => overviewMocks); + +const activeSessionsMocks = vi.hoisted(() => ({ + getActiveSessions: vi.fn(), +})); +vi.mock("@/actions/active-sessions", () => activeSessionsMocks); + +const statisticsMocks = vi.hoisted(() => ({ + getUserStatistics: vi.fn(), +})); +vi.mock("@/actions/statistics", () => statisticsMocks); + +vi.mock("@/app/[locale]/dashboard/_components/bento/live-sessions-panel", () => ({ + LiveSessionsPanel: () =>
, +})); + +vi.mock("@/app/[locale]/dashboard/_components/bento/leaderboard-card", () => ({ + LeaderboardCard: () =>
, +})); + +vi.mock("@/app/[locale]/dashboard/_components/bento/statistics-chart-card", () => ({ + StatisticsChartCard: () =>
, +})); + +const customsMessages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages/en/customs.json"), "utf8") +); +const dashboardMessages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages/en/dashboard.json"), "utf8") +); + +const mockOverviewData: OverviewData = { + concurrentSessions: 2, + todayRequests: 12, + todayCost: 1.23, + avgResponseTime: 456, + todayErrorRate: 0.1, + yesterdaySamePeriodRequests: 10, + yesterdaySamePeriodCost: 1.01, + yesterdaySamePeriodAvgResponseTime: 500, + recentMinuteRequests: 3, +}; + +const mockStatisticsData: UserStatisticsData = { + chartData: [], + users: [], + timeRange: "today", + resolution: "hour", + mode: "users", +}; + +function renderSimple(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function renderWithProviders(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + }); + + act(() => { + root.render( + + + {node} + + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function findByClassToken(root: ParentNode, token: string) { + return Array.from(root.querySelectorAll("*")).find((el) => + el.classList.contains(token) + ); +} + +function findClosestWithClasses(element: Element | null, classes: string[]) { + let current = element?.parentElement ?? null; + while (current) { + const hasAll = classes.every((cls) => current.classList.contains(cls)); + if (hasAll) return current; + current = current.parentElement; + } + return null; +} + +async function flushPromises() { + await act(async () => { + await Promise.resolve(); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + overviewMocks.getOverviewData.mockResolvedValue({ ok: true, data: mockOverviewData }); + activeSessionsMocks.getActiveSessions.mockResolvedValue({ ok: true, data: [] }); + statisticsMocks.getUserStatistics.mockResolvedValue({ ok: true, data: mockStatisticsData }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => [], + })) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("DashboardMain layout classes", () => { + test("pathname /dashboard has max-w-7xl and px-6", () => { + routingMocks.usePathname.mockReturnValue("/dashboard"); + const { container, unmount } = renderSimple( + +
+ + ); + + const main = container.querySelector("main"); + expect(main).toBeTruthy(); + expect(main?.className).toContain("px-6"); + expect(main?.className).toContain("max-w-7xl"); + + unmount(); + }); + + test("pathname /dashboard/logs keeps max-w-7xl", () => { + routingMocks.usePathname.mockReturnValue("/dashboard/logs"); + const { container, unmount } = renderSimple( + +
+ + ); + + const main = container.querySelector("main"); + expect(main).toBeTruthy(); + expect(main?.className).toContain("max-w-7xl"); + + unmount(); + }); +}); + +describe("DashboardBento admin layout", () => { + test("renders four-column layout with LiveSessionsPanel in last column", async () => { + const { container, unmount } = renderWithProviders( + + ); + await flushPromises(); + + const grid = findByClassToken(container, "lg:grid-cols-[1fr_1fr_1fr_280px]"); + expect(grid).toBeTruthy(); + + const livePanel = container.querySelector('[data-testid="live-sessions-panel"]'); + expect(livePanel).toBeTruthy(); + + expect(grid?.contains(livePanel as HTMLElement)).toBe(true); + + unmount(); + }); +}); diff --git a/tests/unit/lib/config/system-settings-cache.test.ts b/tests/unit/lib/config/system-settings-cache.test.ts index 8949e116c..41dd8eb81 100644 --- a/tests/unit/lib/config/system-settings-cache.test.ts +++ b/tests/unit/lib/config/system-settings-cache.test.ts @@ -30,6 +30,7 @@ function createSettings(overrides: Partial = {}): SystemSettings allowGlobalUsageView: false, currencyDisplay: "USD", billingModelSource: "original", + timezone: null, enableAutoCleanup: false, cleanupRetentionDays: 30, cleanupSchedule: "0 2 * * *", @@ -38,6 +39,8 @@ function createSettings(overrides: Partial = {}): SystemSettings verboseProviderError: false, enableHttp2: false, interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, enableResponseFixer: true, responseFixerConfig: { fixTruncatedJson: true, @@ -46,6 +49,12 @@ function createSettings(overrides: Partial = {}): SystemSettings maxJsonDepth: 200, maxFixSize: 1024 * 1024, }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), }; diff --git a/tests/unit/lib/cost-calculation-image-tokens.test.ts b/tests/unit/lib/cost-calculation-image-tokens.test.ts new file mode 100644 index 000000000..e23e6d0ee --- /dev/null +++ b/tests/unit/lib/cost-calculation-image-tokens.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "vitest"; +import { calculateRequestCost } from "@/lib/utils/cost-calculation"; + +describe("calculateRequestCost: image token pricing (Gemini image generation)", () => { + test("output_image_tokens 应使用 output_cost_per_image_token 计费", () => { + const cost = calculateRequestCost( + { output_image_tokens: 2000 }, + { + output_cost_per_token: 0.000012, + output_cost_per_image_token: 0.00012, + } + ); + + // 2000 * 0.00012 = 0.24 + expect(cost.toString()).toBe("0.24"); + }); + + test("output_image_tokens 未配置 image 价格时应回退到 output_cost_per_token", () => { + const cost = calculateRequestCost( + { output_image_tokens: 2000 }, + { + output_cost_per_token: 0.000012, + } + ); + + // 2000 * 0.000012 = 0.024 + expect(cost.toString()).toBe("0.024"); + }); + + test("input_image_tokens 应使用 input_cost_per_image_token 计费", () => { + const cost = calculateRequestCost( + { input_image_tokens: 560 }, + { + input_cost_per_token: 0.000002, + input_cost_per_image_token: 0.00000196, + } + ); + + // 560 * 0.00000196 = 0.0010976 + expect(cost.toNumber()).toBeCloseTo(0.0010976, 6); + }); + + test("input_image_tokens 未配置 image 价格时应回退到 input_cost_per_token", () => { + const cost = calculateRequestCost( + { input_image_tokens: 560 }, + { + input_cost_per_token: 0.000002, + } + ); + + // 560 * 0.000002 = 0.00112 + expect(cost.toString()).toBe("0.00112"); + }); + + test("混合响应:text + image tokens 应分别计费", () => { + const cost = calculateRequestCost( + { + input_tokens: 326, + output_tokens: 340, + output_image_tokens: 2000, + }, + { + input_cost_per_token: 0.000002, + output_cost_per_token: 0.000012, + output_cost_per_image_token: 0.00012, + } + ); + + // input: 326 * 0.000002 = 0.000652 + // output text: 340 * 0.000012 = 0.00408 + // output image: 2000 * 0.00012 = 0.24 + // total: 0.000652 + 0.00408 + 0.24 = 0.244732 + expect(cost.toNumber()).toBeCloseTo(0.244732, 6); + }); + + test("完整 Gemini image 响应计费示例", () => { + const cost = calculateRequestCost( + { + input_tokens: 326, + output_tokens: 340, + output_image_tokens: 2000, + }, + { + input_cost_per_token: 0.000002, + output_cost_per_token: 0.000012, + output_cost_per_image_token: 0.00012, + } + ); + + // Google 官方价格验证 + // input: 326 * $0.000002 = $0.000652 + // output text: 340 * $0.000012 = $0.00408 + // output image: 2000 * $0.00012 = $0.24 (4K image = 2000 tokens) + // total: $0.244732 + expect(cost.toNumber()).toBeCloseTo(0.244732, 6); + }); + + test("倍率应同时作用于 image token 费用", () => { + const cost = calculateRequestCost( + { output_image_tokens: 2000 }, + { + output_cost_per_image_token: 0.00012, + }, + 2 + ); + + // 2000 * 0.00012 * 2 = 0.48 + expect(cost.toString()).toBe("0.48"); + }); + + test("output_image_tokens 为 0 时不应产生费用", () => { + const cost = calculateRequestCost( + { output_image_tokens: 0 }, + { + output_cost_per_image_token: 0.00012, + } + ); + + expect(cost.toString()).toBe("0"); + }); + + test("output_image_tokens 为 undefined 时不应产生费用", () => { + const cost = calculateRequestCost( + { output_tokens: 1000 }, + { + output_cost_per_token: 0.000012, + output_cost_per_image_token: 0.00012, + } + ); + + // 只计算 output_tokens: 1000 * 0.000012 = 0.012 + expect(cost.toString()).toBe("0.012"); + }); + + test("同时有 input_image_tokens 和 output_image_tokens", () => { + const cost = calculateRequestCost( + { + input_image_tokens: 560, + output_image_tokens: 2000, + }, + { + input_cost_per_image_token: 0.00000196, + output_cost_per_image_token: 0.00012, + } + ); + + // input: 560 * 0.00000196 = 0.0010976 + // output: 2000 * 0.00012 = 0.24 + // total: 0.2410976 + expect(cost.toNumber()).toBeCloseTo(0.2410976, 6); + }); +}); diff --git a/tests/unit/lib/date-format-timezone.test.ts b/tests/unit/lib/date-format-timezone.test.ts new file mode 100644 index 000000000..418ab08dd --- /dev/null +++ b/tests/unit/lib/date-format-timezone.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { formatDate } from "@/lib/utils/date-format"; + +describe("formatDate with timezone parameter", () => { + // Fixed UTC timestamp: 2025-01-15T23:30:00Z + const utcDate = new Date("2025-01-15T23:30:00Z"); + + it("returns formatted date without timezone (original behaviour)", () => { + const result = formatDate(utcDate, "yyyy-MM-dd", "en"); + // Without timezone, result depends on local TZ - just verify it returns a string + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("formats date in UTC timezone", () => { + const result = formatDate(utcDate, "yyyy-MM-dd HH:mm", "en", "UTC"); + expect(result).toBe("2025-01-15 23:30"); + }); + + it("formats date in Asia/Shanghai timezone (UTC+8)", () => { + // 2025-01-15T23:30:00Z => 2025-01-16T07:30:00 in Asia/Shanghai + const result = formatDate(utcDate, "yyyy-MM-dd HH:mm", "en", "Asia/Shanghai"); + expect(result).toBe("2025-01-16 07:30"); + }); + + it("formats date in America/New_York timezone (UTC-5 in January)", () => { + // 2025-01-15T23:30:00Z => 2025-01-15T18:30:00 in America/New_York (EST) + const result = formatDate(utcDate, "yyyy-MM-dd HH:mm", "en", "America/New_York"); + expect(result).toBe("2025-01-15 18:30"); + }); + + it("handles date-only format with timezone that crosses midnight", () => { + // 2025-01-15T23:30:00Z is already 2025-01-16 in Asia/Shanghai + const dateOnly = formatDate(utcDate, "yyyy-MM-dd", "en", "Asia/Shanghai"); + expect(dateOnly).toBe("2025-01-16"); + }); + + it("preserves locale formatting with timezone", () => { + const result = formatDate(utcDate, "PPP", "en", "UTC"); + // PPP in en locale: "January 15th, 2025" + expect(result).toContain("January"); + expect(result).toContain("2025"); + }); + + it("works with string date input and timezone", () => { + const result = formatDate("2025-06-01T12:00:00Z", "yyyy-MM-dd HH:mm", "en", "Asia/Tokyo"); + // UTC 12:00 => JST 21:00 + expect(result).toBe("2025-06-01 21:00"); + }); + + it("works with numeric timestamp and timezone", () => { + const ts = utcDate.getTime(); + const result = formatDate(ts, "yyyy-MM-dd HH:mm", "en", "UTC"); + expect(result).toBe("2025-01-15 23:30"); + }); + + it("falls back to local format when timezone is undefined", () => { + const result = formatDate(utcDate, "yyyy-MM-dd", "en", undefined); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); diff --git a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts index da793d846..d1ba2de29 100644 --- a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts +++ b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts @@ -1,8 +1,10 @@ type ProbeTarget = { id: number; url: string; + vendorId: number; lastProbedAt: Date | null; lastProbeOk: boolean | null; + lastProbeErrorType: string | null; }; type ProbeResult = { @@ -14,12 +16,14 @@ type ProbeResult = { errorMessage: string | null; }; -function makeEndpoint(id: number): ProbeTarget { +function makeEndpoint(id: number, overrides: Partial = {}): ProbeTarget { return { id, url: `https://example.com/${id}`, - lastProbedAt: null, - lastProbeOk: null, + vendorId: overrides.vendorId ?? 1, + lastProbedAt: overrides.lastProbedAt ?? null, + lastProbeOk: overrides.lastProbeOk ?? null, + lastProbeErrorType: overrides.lastProbeErrorType ?? null, }; } @@ -168,4 +172,275 @@ describe("provider-endpoints: probe scheduler", () => { stopEndpointProbeScheduler(); }); + + describe("dynamic interval calculation", () => { + test("default interval is 60s - endpoints probed 60s ago should be probed", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:01:00Z")); + + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000"); + vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0"); + + acquireLeaderLockMock = vi.fn(async () => ({ + key: "locks:endpoint-probe-scheduler", + lockId: "test", + lockType: "memory" as const, + })); + renewLeaderLockMock = vi.fn(async () => true); + releaseLeaderLockMock = vi.fn(async () => {}); + + // Two endpoints from SAME vendor (multi-endpoint vendor uses base 60s interval) + // Both probed 61s ago - should be due + const endpoint = makeEndpoint(1, { + vendorId: 1, + lastProbedAt: new Date("2024-01-01T11:59:59Z"), // 61s ago + }); + const endpoint2 = makeEndpoint(2, { + vendorId: 1, // Same vendor + lastProbedAt: new Date("2024-01-01T11:59:59Z"), // 61s ago + }); + + findEnabledEndpointsMock = vi.fn(async () => [endpoint, endpoint2]); + probeByEndpointMock = vi.fn(async () => makeOkResult()); + + const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + startEndpointProbeScheduler(); + await flushMicrotasks(); + + // Both endpoints should be probed since they're due (61s > 60s interval) + expect(probeByEndpointMock).toHaveBeenCalledTimes(2); + + stopEndpointProbeScheduler(); + }); + + test("single-endpoint vendor uses 10min interval", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:05:00Z")); + + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000"); + vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0"); + + acquireLeaderLockMock = vi.fn(async () => ({ + key: "locks:endpoint-probe-scheduler", + lockId: "test", + lockType: "memory" as const, + })); + renewLeaderLockMock = vi.fn(async () => true); + releaseLeaderLockMock = vi.fn(async () => {}); + + // Vendor 1: single endpoint probed 5min ago (should NOT be due - 10min interval) + // Vendor 2: two endpoints, one probed 30s ago (should NOT be due - 60s interval but recently probed) + const singleVendorEndpoint = makeEndpoint(1, { + vendorId: 1, + lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 5min ago + }); + const multiVendorEndpoint1 = makeEndpoint(2, { + vendorId: 2, + lastProbedAt: new Date("2024-01-01T12:04:30Z"), // 30s ago - NOT due + }); + const multiVendorEndpoint2 = makeEndpoint(3, { + vendorId: 2, + lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 5min ago - should be due + }); + + findEnabledEndpointsMock = vi.fn(async () => [ + singleVendorEndpoint, + multiVendorEndpoint1, + multiVendorEndpoint2, + ]); + probeByEndpointMock = vi.fn(async () => makeOkResult()); + + const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + startEndpointProbeScheduler(); + await flushMicrotasks(); + + // Only multiVendorEndpoint2 should be probed (5min > 60s, multi-endpoint vendor) + // singleVendorEndpoint not due (5min < 10min) + // multiVendorEndpoint1 not due (30s < 60s) + expect(probeByEndpointMock).toHaveBeenCalledTimes(1); + expect(probeByEndpointMock.mock.calls[0][0].endpoint.id).toBe(3); + + stopEndpointProbeScheduler(); + }); + + test("timeout endpoint uses 10s override interval", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:00:15Z")); + + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000"); + vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0"); + + acquireLeaderLockMock = vi.fn(async () => ({ + key: "locks:endpoint-probe-scheduler", + lockId: "test", + lockType: "memory" as const, + })); + renewLeaderLockMock = vi.fn(async () => true); + releaseLeaderLockMock = vi.fn(async () => {}); + + // Endpoint with timeout error 15s ago - should be due (10s override) + const timeoutEndpoint = makeEndpoint(1, { + vendorId: 1, + lastProbedAt: new Date("2024-01-01T12:00:00Z"), + lastProbeOk: false, + lastProbeErrorType: "timeout", + }); + // Normal endpoint from same vendor probed 15s ago - not due (60s interval) + const normalEndpoint = makeEndpoint(2, { + vendorId: 1, + lastProbedAt: new Date("2024-01-01T12:00:00Z"), + lastProbeOk: true, + }); + + findEnabledEndpointsMock = vi.fn(async () => [timeoutEndpoint, normalEndpoint]); + probeByEndpointMock = vi.fn(async () => makeOkResult()); + + const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + startEndpointProbeScheduler(); + await flushMicrotasks(); + + // Only timeout endpoint should be probed + expect(probeByEndpointMock).toHaveBeenCalledTimes(1); + expect(probeByEndpointMock.mock.calls[0][0].endpoint.id).toBe(1); + + stopEndpointProbeScheduler(); + }); + + test("timeout override takes priority over 10min single-vendor interval", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:00:15Z")); + + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000"); + vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0"); + + acquireLeaderLockMock = vi.fn(async () => ({ + key: "locks:endpoint-probe-scheduler", + lockId: "test", + lockType: "memory" as const, + })); + renewLeaderLockMock = vi.fn(async () => true); + releaseLeaderLockMock = vi.fn(async () => {}); + + // Single-endpoint vendor with timeout error 15s ago + // Without timeout, would use 10min interval and not be due + // With timeout, uses 10s override and IS due + const timeoutSingleVendor = makeEndpoint(1, { + vendorId: 1, // only endpoint for this vendor + lastProbedAt: new Date("2024-01-01T12:00:00Z"), + lastProbeOk: false, + lastProbeErrorType: "timeout", + }); + + findEnabledEndpointsMock = vi.fn(async () => [timeoutSingleVendor]); + probeByEndpointMock = vi.fn(async () => makeOkResult()); + + const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + startEndpointProbeScheduler(); + await flushMicrotasks(); + + // Timeout override should take priority + expect(probeByEndpointMock).toHaveBeenCalledTimes(1); + + stopEndpointProbeScheduler(); + }); + + test("recovered endpoint (lastProbeOk=true) reverts to normal interval", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:00:15Z")); + + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000"); + vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0"); + + acquireLeaderLockMock = vi.fn(async () => ({ + key: "locks:endpoint-probe-scheduler", + lockId: "test", + lockType: "memory" as const, + })); + renewLeaderLockMock = vi.fn(async () => true); + releaseLeaderLockMock = vi.fn(async () => {}); + + // Had timeout before but now recovered (lastProbeOk=true) - uses normal interval + const recoveredEndpoint = makeEndpoint(1, { + vendorId: 1, + lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 15s ago + lastProbeOk: true, // recovered! + lastProbeErrorType: "timeout", // had timeout before + }); + // Multi-vendor so 60s base interval applies + const otherEndpoint = makeEndpoint(2, { + vendorId: 1, + lastProbedAt: new Date("2024-01-01T12:00:00Z"), + lastProbeOk: true, + }); + + findEnabledEndpointsMock = vi.fn(async () => [recoveredEndpoint, otherEndpoint]); + probeByEndpointMock = vi.fn(async () => makeOkResult()); + + const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + startEndpointProbeScheduler(); + await flushMicrotasks(); + + // Neither should be probed - 15s < 60s and lastProbeOk=true means no timeout override + expect(probeByEndpointMock).toHaveBeenCalledTimes(0); + + stopEndpointProbeScheduler(); + }); + + test("null lastProbedAt is always due for probing", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:00:00Z")); + + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000"); + vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0"); + + acquireLeaderLockMock = vi.fn(async () => ({ + key: "locks:endpoint-probe-scheduler", + lockId: "test", + lockType: "memory" as const, + })); + renewLeaderLockMock = vi.fn(async () => true); + releaseLeaderLockMock = vi.fn(async () => {}); + + // Never probed endpoint should always be due + const neverProbed = makeEndpoint(1, { + vendorId: 1, + lastProbedAt: null, + }); + + findEnabledEndpointsMock = vi.fn(async () => [neverProbed]); + probeByEndpointMock = vi.fn(async () => makeOkResult()); + + const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + + startEndpointProbeScheduler(); + await flushMicrotasks(); + + expect(probeByEndpointMock).toHaveBeenCalledTimes(1); + + stopEndpointProbeScheduler(); + }); + }); }); diff --git a/tests/unit/lib/rate-limit/lease-service.test.ts b/tests/unit/lib/rate-limit/lease-service.test.ts new file mode 100644 index 000000000..06a26b6f9 --- /dev/null +++ b/tests/unit/lib/rate-limit/lease-service.test.ts @@ -0,0 +1,1179 @@ +/** + * Lease Service Tests + * + * TDD: RED phase - tests for lease generation and refresh mechanism + * DB is authoritative, Redis stores lease slices. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock Redis client using vi.hoisted to avoid TDZ issues +const mockRedis = vi.hoisted(() => ({ + status: "ready", + get: vi.fn(), + set: vi.fn(), + setex: vi.fn(), + eval: vi.fn(), + exists: vi.fn(), + del: vi.fn(), + pipeline: vi.fn(() => ({ + get: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + expire: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([]), + })), +})); + +// Mock dependencies +vi.mock("@/lib/config", () => ({ + getEnvConfig: () => ({ TZ: "Asia/Shanghai" }), +})); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: () => mockRedis, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/repository/statistics", () => ({ + sumKeyCostInTimeRange: vi.fn(), + sumUserCostInTimeRange: vi.fn(), + sumProviderCostInTimeRange: vi.fn(), + findKeyCostEntriesInTimeRange: vi.fn(), + findUserCostEntriesInTimeRange: vi.fn(), + findProviderCostEntriesInTimeRange: vi.fn(), +})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: vi.fn(), +})); + +describe("LeaseService", () => { + const nowMs = 1706400000000; // 2024-01-28 00:00:00 UTC + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("getCostLease", () => { + it("should return cached lease from Redis if valid", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { deserializeLease } = await import("@/lib/rate-limit/lease"); + + // Setup: cached lease in Redis + const cachedLease = { + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 5000, // 5 seconds ago + currentUsage: 50, + limitAmount: 100, + remainingBudget: 2.5, + ttlSeconds: 60, // 60 seconds TTL + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedLease)); + + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result).not.toBeNull(); + expect(result?.remainingBudget).toBe(2.5); + }); + + it("should refresh lease from DB when Redis cache is empty", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + // Setup: no cache + mockRedis.get.mockResolvedValue(null); + + // Setup: DB returns usage + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(40); + + // Setup: system settings + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result).not.toBeNull(); + expect(result?.currentUsage).toBe(40); + // remainingBudget = min(100 * 0.05, 100 - 40) = min(5, 60) = 5 + expect(result?.remainingBudget).toBe(5); + expect(sumKeyCostInTimeRange).toHaveBeenCalled(); + }); + + it("should refresh lease when TTL has expired", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + // Setup: expired lease in Redis + const expiredLease = { + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 120 * 1000, // 2 minutes ago + currentUsage: 50, + limitAmount: 100, + remainingBudget: 2.5, + ttlSeconds: 60, // 60 seconds TTL - expired! + }; + mockRedis.get.mockResolvedValue(JSON.stringify(expiredLease)); + + // Setup: DB returns new usage + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(55); + + // Setup: system settings + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result).not.toBeNull(); + expect(result?.currentUsage).toBe(55); + expect(sumKeyCostInTimeRange).toHaveBeenCalled(); + }); + + it("should return null and fail-open when DB query fails", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + const { logger } = await import("@/lib/logger"); + + // Setup: no cache + mockRedis.get.mockResolvedValue(null); + + // Setup: DB throws error + vi.mocked(sumKeyCostInTimeRange).mockRejectedValue(new Error("DB connection failed")); + + // Setup: system settings + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + // Fail-open: return null, log error + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + + it("should use correct percent based on window type", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + mockRedis.get.mockResolvedValue(null); + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(0); + + // System settings with different percents per window + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, // 10% + quotaLeasePercentDaily: 0.05, // 5% + quotaLeasePercentWeekly: 0.03, // 3% + quotaLeasePercentMonthly: 0.02, // 2% + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + // Test 5h window + const result5h = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "5h", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + expect(result5h?.remainingBudget).toBe(10); // 100 * 0.1 = 10 + + // Test weekly window + const resultWeekly = await LeaseService.getCostLease({ + entityType: "key", + entityId: 456, + window: "weekly", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + expect(resultWeekly?.remainingBudget).toBe(3); // 100 * 0.03 = 3 + }); + + it("should respect capUsd from system settings", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + mockRedis.get.mockResolvedValue(null); + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(0); + + // System settings with capUsd = 3 + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: 3, // Cap at $3 + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + // limit=1000, percent=0.05 -> 50, but cap=3 -> slice=3 + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 1000, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result?.remainingBudget).toBe(3); + }); + + it("should use system settings refresh interval as TTL", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + mockRedis.get.mockResolvedValue(null); + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(0); + + // System settings with 30s refresh interval + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 30, // 30 seconds + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result?.ttlSeconds).toBe(30); + // Verify setex was called with correct TTL + expect(mockRedis.setex).toHaveBeenCalledWith(expect.any(String), 30, expect.any(String)); + }); + + it("should handle user entity type", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumUserCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + mockRedis.get.mockResolvedValue(null); + vi.mocked(sumUserCostInTimeRange).mockResolvedValue(25); + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + const result = await LeaseService.getCostLease({ + entityType: "user", + entityId: 999, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "rolling", + }); + + expect(result).not.toBeNull(); + expect(result?.entityType).toBe("user"); + expect(result?.currentUsage).toBe(25); + expect(sumUserCostInTimeRange).toHaveBeenCalled(); + }); + + it("should handle provider entity type", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumProviderCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + mockRedis.get.mockResolvedValue(null); + vi.mocked(sumProviderCostInTimeRange).mockResolvedValue(75); + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + const result = await LeaseService.getCostLease({ + entityType: "provider", + entityId: 555, + window: "monthly", + limitAmount: 200, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result).not.toBeNull(); + expect(result?.entityType).toBe("provider"); + expect(result?.currentUsage).toBe(75); + // remainingBudget = min(200 * 0.02, 200 - 75) = min(4, 125) = 4 + expect(result?.remainingBudget).toBe(4); + expect(sumProviderCostInTimeRange).toHaveBeenCalled(); + }); + + it("should return 0 remaining budget when usage exceeds limit", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + mockRedis.get.mockResolvedValue(null); + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(105); // Over limit + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result?.remainingBudget).toBe(0); + }); + }); + + describe("refreshCostLeaseFromDb", () => { + it("should query DB and create new lease", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(30); + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + const result = await LeaseService.refreshCostLeaseFromDb({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + expect(result).not.toBeNull(); + expect(result?.snapshotAtMs).toBe(nowMs); + expect(result?.currentUsage).toBe(30); + expect(result?.remainingBudget).toBe(5); // min(100*0.05, 70) = 5 + }); + + it("should store lease in Redis with correct TTL", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + const { buildLeaseKey } = await import("@/lib/rate-limit/lease"); + + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(0); + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + id: 1, + siteTitle: "Test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 15, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.03, + quotaLeasePercentMonthly: 0.02, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockRedis.setex.mockResolvedValue("OK"); + + await LeaseService.refreshCostLeaseFromDb({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, + resetTime: "00:00", + resetMode: "fixed", + }); + + const expectedKey = buildLeaseKey("key", 123, "daily"); + expect(mockRedis.setex).toHaveBeenCalledWith( + expectedKey, + 15, // TTL from system settings + expect.any(String) + ); + }); + }); + + describe("decrementLeaseBudget", () => { + it("should decrement lease budget atomically using Lua script", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { buildLeaseKey } = await import("@/lib/rate-limit/lease"); + + // Lua script returns: [newRemaining, success] + // newRemaining = 2.5, success = 1 (decremented successfully) + mockRedis.eval.mockResolvedValue([2.5, 1]); + + const result = await LeaseService.decrementLeaseBudget({ + entityType: "key", + entityId: 123, + window: "daily", + cost: 0.5, + }); + + expect(result).toEqual({ + success: true, + newRemaining: 2.5, + }); + + const expectedKey = buildLeaseKey("key", 123, "daily"); + expect(mockRedis.eval).toHaveBeenCalledWith( + expect.any(String), // Lua script + 1, // Number of keys + expectedKey, // KEYS[1] + 0.5 // ARGV[1] = cost + ); + }); + + it("should return success=false when budget is insufficient", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + + // Lua script returns: [0, 0] = insufficient budget + mockRedis.eval.mockResolvedValue([0, 0]); + + const result = await LeaseService.decrementLeaseBudget({ + entityType: "key", + entityId: 123, + window: "daily", + cost: 10.0, + }); + + expect(result).toEqual({ + success: false, + newRemaining: 0, + }); + }); + + it("should return success=false when lease key does not exist", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + + // Lua script returns: [-1, 0] = key not found + mockRedis.eval.mockResolvedValue([-1, 0]); + + const result = await LeaseService.decrementLeaseBudget({ + entityType: "key", + entityId: 123, + window: "daily", + cost: 1.0, + }); + + expect(result).toEqual({ + success: false, + newRemaining: -1, + }); + }); + + it("should fail-open on Redis error", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { logger } = await import("@/lib/logger"); + + mockRedis.eval.mockRejectedValue(new Error("Redis connection failed")); + + const result = await LeaseService.decrementLeaseBudget({ + entityType: "key", + entityId: 123, + window: "daily", + cost: 1.0, + }); + + // Fail-open: return success=true to allow request + expect(result).toEqual({ + success: true, + newRemaining: -1, + failOpen: true, + }); + expect(logger.error).toHaveBeenCalled(); + }); + + it("should fail-open when Redis is not ready", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + + // Simulate Redis not ready + mockRedis.status = "connecting"; + + const result = await LeaseService.decrementLeaseBudget({ + entityType: "key", + entityId: 123, + window: "daily", + cost: 1.0, + }); + + // Fail-open: return success=true + expect(result).toEqual({ + success: true, + newRemaining: -1, + failOpen: true, + }); + + // Restore Redis status for other tests + mockRedis.status = "ready"; + }); + + it("should handle different entity types", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { buildLeaseKey } = await import("@/lib/rate-limit/lease"); + + mockRedis.eval.mockResolvedValue([5.0, 1]); + + // Test user entity + await LeaseService.decrementLeaseBudget({ + entityType: "user", + entityId: 999, + window: "weekly", + cost: 2.0, + }); + + const expectedUserKey = buildLeaseKey("user", 999, "weekly"); + expect(mockRedis.eval).toHaveBeenCalledWith(expect.any(String), 1, expectedUserKey, 2.0); + + // Test provider entity + await LeaseService.decrementLeaseBudget({ + entityType: "provider", + entityId: 555, + window: "monthly", + cost: 3.5, + }); + + const expectedProviderKey = buildLeaseKey("provider", 555, "monthly"); + expect(mockRedis.eval).toHaveBeenCalledWith(expect.any(String), 1, expectedProviderKey, 3.5); + }); + + it("should handle zero cost decrement", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + + mockRedis.eval.mockResolvedValue([5.0, 1]); + + const result = await LeaseService.decrementLeaseBudget({ + entityType: "key", + entityId: 123, + window: "daily", + cost: 0, + }); + + expect(result.success).toBe(true); + expect(result.newRemaining).toBe(5.0); + }); + }); + + describe("getCostLease - limit change detection", () => { + const nowMs = 1706400000000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should force refresh when limitAmount increases", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + + // Setup: cached lease with limitAmount=100 + const cachedLease = { + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 5000, + currentUsage: 50, + limitAmount: 100, // Old limit + remainingBudget: 2.5, + ttlSeconds: 60, + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedLease)); + + // Mock system settings and DB query for refresh + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercentDaily: 0.05, + } as ReturnType extends Promise ? T : never); + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(50); + + // Call with increased limitAmount=150 + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 150, // New limit (increased) + resetTime: "00:00", + resetMode: "fixed", + }); + + // Should have refreshed from DB + expect(sumKeyCostInTimeRange).toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result?.limitAmount).toBe(150); + }); + + it("should force refresh when limitAmount decreases", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + + // Setup: cached lease with limitAmount=100 + const cachedLease = { + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 5000, + currentUsage: 50, + limitAmount: 100, // Old limit + remainingBudget: 2.5, + ttlSeconds: 60, + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedLease)); + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercentDaily: 0.05, + } as ReturnType extends Promise ? T : never); + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(50); + + // Call with decreased limitAmount=50 + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 50, // New limit (decreased) + resetTime: "00:00", + resetMode: "fixed", + }); + + // Should have refreshed from DB + expect(sumKeyCostInTimeRange).toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result?.limitAmount).toBe(50); + }); + + it("should return cached lease when limitAmount unchanged", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + + // Setup: cached lease with limitAmount=100 + const cachedLease = { + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 5000, + currentUsage: 50, + limitAmount: 100, + remainingBudget: 2.5, + ttlSeconds: 60, + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedLease)); + + // Call with same limitAmount=100 + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 123, + window: "daily", + limitAmount: 100, // Same limit + resetTime: "00:00", + resetMode: "fixed", + }); + + // Should NOT have refreshed from DB + expect(sumKeyCostInTimeRange).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result?.limitAmount).toBe(100); + expect(result?.remainingBudget).toBe(2.5); + }); + + it("should allow requests after limit increase for over-limit user", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + const { sumUserCostInTimeRange } = await import("@/repository/statistics"); + + // Setup: user is over limit (usage=100, limit=100, remaining=0) + const cachedLease = { + entityType: "user", + entityId: 456, + window: "daily", + resetMode: "rolling", + resetTime: "00:00", + snapshotAtMs: nowMs - 5000, + currentUsage: 100, + limitAmount: 100, // Old limit + remainingBudget: 0, // Over limit + ttlSeconds: 60, + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedLease)); + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercentDaily: 0.05, + } as ReturnType extends Promise ? T : never); + vi.mocked(sumUserCostInTimeRange).mockResolvedValue(100); // Current usage still 100 + + // Admin increases limit to 150 + const result = await LeaseService.getCostLease({ + entityType: "user", + entityId: 456, + window: "daily", + limitAmount: 150, // Increased limit + resetTime: "00:00", + resetMode: "rolling", + }); + + // Should have refreshed and now have remaining budget + expect(sumUserCostInTimeRange).toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result?.limitAmount).toBe(150); + // remainingBudget = min(150 * 0.05, 150 - 100) = min(7.5, 50) = 7.5 + expect(result?.remainingBudget).toBeGreaterThan(0); + }); + + it("should block requests after limit decrease below usage", async () => { + const { LeaseService } = await import("@/lib/rate-limit/lease-service"); + const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); + const { sumKeyCostInTimeRange } = await import("@/repository/statistics"); + + // Setup: user has used 80, limit was 100, remaining=1 + const cachedLease = { + entityType: "key", + entityId: 789, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 5000, + currentUsage: 80, + limitAmount: 100, // Old limit + remainingBudget: 1, // Still has budget + ttlSeconds: 60, + }; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedLease)); + + vi.mocked(getCachedSystemSettings).mockResolvedValue({ + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercentDaily: 0.05, + } as ReturnType extends Promise ? T : never); + vi.mocked(sumKeyCostInTimeRange).mockResolvedValue(80); // Current usage still 80 + + // Admin decreases limit to 50 (below current usage of 80) + const result = await LeaseService.getCostLease({ + entityType: "key", + entityId: 789, + window: "daily", + limitAmount: 50, // Decreased limit (below usage) + resetTime: "00:00", + resetMode: "fixed", + }); + + // Should have refreshed and now have 0 remaining budget + expect(sumKeyCostInTimeRange).toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result?.limitAmount).toBe(50); + // remainingBudget = min(50 * 0.05, 50 - 80) = min(2.5, -30) = 0 (clamped) + expect(result?.remainingBudget).toBe(0); + }); + }); +}); diff --git a/tests/unit/lib/rate-limit/lease.test.ts b/tests/unit/lib/rate-limit/lease.test.ts new file mode 100644 index 000000000..6816cbb0b --- /dev/null +++ b/tests/unit/lib/rate-limit/lease.test.ts @@ -0,0 +1,577 @@ +/** + * Lease Module Tests + * + * TDD: RED phase - tests for lease budget slicing mechanism + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock resolveSystemTimezone before importing lease module +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), +})); + +import { resolveSystemTimezone } from "@/lib/utils/timezone"; + +describe("lease module", () => { + const nowMs = 1706400000000; // 2024-01-28 00:00:00 UTC + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("LeaseWindow type", () => { + it("should support 5h, daily, weekly, monthly periods", async () => { + const { LeaseWindow } = await import("@/lib/rate-limit/lease"); + const windows: (typeof LeaseWindow)[number][] = ["5h", "daily", "weekly", "monthly"]; + expect(windows).toHaveLength(4); + }); + }); + + describe("LeaseEntityType type", () => { + it("should support key, user, provider entity types", async () => { + const { LeaseEntityType } = await import("@/lib/rate-limit/lease"); + const types: (typeof LeaseEntityType)[number][] = ["key", "user", "provider"]; + expect(types).toHaveLength(3); + }); + }); + + describe("BudgetLease interface", () => { + it("should contain required fields", async () => { + const { createBudgetLease } = await import("@/lib/rate-limit/lease"); + + const lease = createBudgetLease({ + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "18:00", + snapshotAtMs: nowMs, + currentUsage: 50, + limitAmount: 100, + remainingBudget: 2.5, + ttlSeconds: 3600, + }); + + expect(lease.entityType).toBe("key"); + expect(lease.entityId).toBe(123); + expect(lease.window).toBe("daily"); + expect(lease.resetMode).toBe("fixed"); + expect(lease.resetTime).toBe("18:00"); + expect(lease.snapshotAtMs).toBe(nowMs); + expect(lease.currentUsage).toBe(50); + expect(lease.limitAmount).toBe(100); + expect(lease.remainingBudget).toBe(2.5); + expect(lease.ttlSeconds).toBe(3600); + }); + }); + + describe("buildLeaseKey", () => { + it("should build key lease key with window", async () => { + const { buildLeaseKey } = await import("@/lib/rate-limit/lease"); + + expect(buildLeaseKey("key", 123, "5h")).toBe("lease:key:123:5h"); + expect(buildLeaseKey("key", 456, "daily")).toBe("lease:key:456:daily"); + expect(buildLeaseKey("key", 789, "weekly")).toBe("lease:key:789:weekly"); + expect(buildLeaseKey("key", 101, "monthly")).toBe("lease:key:101:monthly"); + }); + + it("should build provider lease key with window", async () => { + const { buildLeaseKey } = await import("@/lib/rate-limit/lease"); + + expect(buildLeaseKey("provider", 1, "5h")).toBe("lease:provider:1:5h"); + expect(buildLeaseKey("provider", 2, "daily")).toBe("lease:provider:2:daily"); + }); + + it("should build user lease key with window", async () => { + const { buildLeaseKey } = await import("@/lib/rate-limit/lease"); + + expect(buildLeaseKey("user", 100, "monthly")).toBe("lease:user:100:monthly"); + }); + }); + + describe("getLeaseTimeRange", () => { + it("should return 5h rolling window range", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + const range = await getLeaseTimeRange("5h"); + + expect(range.endTime.getTime()).toBe(nowMs); + expect(range.startTime.getTime()).toBe(nowMs - 5 * 60 * 60 * 1000); + }); + + it("should return daily rolling window range (24h)", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + const range = await getLeaseTimeRange("daily", "00:00", "rolling"); + + expect(range.endTime.getTime()).toBe(nowMs); + expect(range.startTime.getTime()).toBe(nowMs - 24 * 60 * 60 * 1000); + }); + + it("should return daily fixed window range with custom reset time", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + const range = await getLeaseTimeRange("daily", "18:00", "fixed"); + + // Should calculate based on fixed reset time + expect(range.endTime.getTime()).toBe(nowMs); + expect(range.startTime.getTime()).toBeLessThan(nowMs); + }); + + it("should return weekly natural window range", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + const range = await getLeaseTimeRange("weekly"); + + expect(range.endTime.getTime()).toBe(nowMs); + // Should start from Monday 00:00 + expect(range.startTime.getTime()).toBeLessThan(nowMs); + }); + + it("should return monthly natural window range", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + const range = await getLeaseTimeRange("monthly"); + + expect(range.endTime.getTime()).toBe(nowMs); + // Should start from 1st of month 00:00 + expect(range.startTime.getTime()).toBeLessThan(nowMs); + }); + }); + + describe("getLeaseTtlSeconds", () => { + it("should return 5h TTL for 5h window", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + const ttl = await getLeaseTtlSeconds("5h"); + + expect(ttl).toBe(5 * 3600); + }); + + it("should return 24h TTL for daily rolling window", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + const ttl = await getLeaseTtlSeconds("daily", "00:00", "rolling"); + + expect(ttl).toBe(24 * 3600); + }); + + it("should return dynamic TTL for daily fixed window", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + const ttl = await getLeaseTtlSeconds("daily", "18:00", "fixed"); + + // Should be positive and less than 24h + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(24 * 3600); + }); + + it("should return dynamic TTL for weekly window", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + const ttl = await getLeaseTtlSeconds("weekly"); + + // Should be positive and less than 7 days + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(7 * 24 * 3600); + }); + + it("should return dynamic TTL for monthly window", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + const ttl = await getLeaseTtlSeconds("monthly"); + + // Should be positive and less than 31 days + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(31 * 24 * 3600); + }); + }); + + describe("calculateLeaseSlice", () => { + it("should calculate slice as percentage of limit", async () => { + const { calculateLeaseSlice } = await import("@/lib/rate-limit/lease"); + + // limit=100, percent=0.05 -> slice=5 + const slice = calculateLeaseSlice({ + limitAmount: 100, + currentUsage: 0, + percent: 0.05, + }); + + expect(slice).toBe(5); + }); + + it("should not exceed remaining budget (limit - usage)", async () => { + const { calculateLeaseSlice } = await import("@/lib/rate-limit/lease"); + + // limit=100, usage=98, percent=0.05 -> remaining=2, slice=min(5,2)=2 + const slice = calculateLeaseSlice({ + limitAmount: 100, + currentUsage: 98, + percent: 0.05, + }); + + expect(slice).toBe(2); + }); + + it("should respect capUsd if provided", async () => { + const { calculateLeaseSlice } = await import("@/lib/rate-limit/lease"); + + // limit=1000, percent=0.05 -> 50, but cap=3 -> slice=3 + const slice = calculateLeaseSlice({ + limitAmount: 1000, + currentUsage: 0, + percent: 0.05, + capUsd: 3, + }); + + expect(slice).toBe(3); + }); + + it("should return 0 when usage exceeds limit", async () => { + const { calculateLeaseSlice } = await import("@/lib/rate-limit/lease"); + + const slice = calculateLeaseSlice({ + limitAmount: 100, + currentUsage: 105, + percent: 0.05, + }); + + expect(slice).toBe(0); + }); + + it("should return 0 when usage equals limit", async () => { + const { calculateLeaseSlice } = await import("@/lib/rate-limit/lease"); + + const slice = calculateLeaseSlice({ + limitAmount: 100, + currentUsage: 100, + percent: 0.05, + }); + + expect(slice).toBe(0); + }); + + it("should round to 4 decimal places", async () => { + const { calculateLeaseSlice } = await import("@/lib/rate-limit/lease"); + + const slice = calculateLeaseSlice({ + limitAmount: 33.333333, + currentUsage: 0, + percent: 0.05, + }); + + // 33.333333 * 0.05 = 1.6666666... + expect(slice).toBe(1.6667); + }); + }); + + describe("serializeLease / deserializeLease", () => { + it("should serialize lease to JSON string", async () => { + const { createBudgetLease, serializeLease } = await import("@/lib/rate-limit/lease"); + + const lease = createBudgetLease({ + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "18:00", + snapshotAtMs: nowMs, + currentUsage: 50, + limitAmount: 100, + remainingBudget: 2.5, + ttlSeconds: 3600, + }); + + const json = serializeLease(lease); + expect(typeof json).toBe("string"); + + const parsed = JSON.parse(json); + expect(parsed.entityType).toBe("key"); + expect(parsed.remainingBudget).toBe(2.5); + }); + + it("should deserialize JSON string to lease", async () => { + const { createBudgetLease, deserializeLease, serializeLease } = await import( + "@/lib/rate-limit/lease" + ); + + const original = createBudgetLease({ + entityType: "provider", + entityId: 456, + window: "weekly", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs, + currentUsage: 25, + limitAmount: 200, + remainingBudget: 10, + ttlSeconds: 86400, + }); + + const json = serializeLease(original); + const restored = deserializeLease(json); + + expect(restored).not.toBeNull(); + expect(restored?.entityType).toBe("provider"); + expect(restored?.entityId).toBe(456); + expect(restored?.remainingBudget).toBe(10); + }); + + it("should return null for invalid JSON", async () => { + const { deserializeLease } = await import("@/lib/rate-limit/lease"); + + const result = deserializeLease("invalid json"); + expect(result).toBeNull(); + }); + + it("should return null for incomplete lease data", async () => { + const { deserializeLease } = await import("@/lib/rate-limit/lease"); + + const result = deserializeLease(JSON.stringify({ entityType: "key" })); + expect(result).toBeNull(); + }); + }); + + describe("isLeaseExpired", () => { + it("should return true when TTL has passed", async () => { + const { createBudgetLease, isLeaseExpired } = await import("@/lib/rate-limit/lease"); + + const lease = createBudgetLease({ + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 3700 * 1000, // Created 3700s ago + currentUsage: 50, + limitAmount: 100, + remainingBudget: 2.5, + ttlSeconds: 3600, // 1 hour TTL + }); + + expect(isLeaseExpired(lease)).toBe(true); + }); + + it("should return false when TTL has not passed", async () => { + const { createBudgetLease, isLeaseExpired } = await import("@/lib/rate-limit/lease"); + + const lease = createBudgetLease({ + entityType: "key", + entityId: 123, + window: "daily", + resetMode: "fixed", + resetTime: "00:00", + snapshotAtMs: nowMs - 1800 * 1000, // Created 1800s ago + currentUsage: 50, + limitAmount: 100, + remainingBudget: 2.5, + ttlSeconds: 3600, // 1 hour TTL + }); + + expect(isLeaseExpired(lease)).toBe(false); + }); + }); +}); + +/** + * Lease Module Timezone Consistency Tests + * + * Verify that lease module delegates to time-utils correctly + * and produces consistent timezone behavior + */ +describe("lease timezone consistency", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("getLeaseTimeRange timezone behavior", () => { + it("should use configured timezone for daily fixed window", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + // 2024-01-15 02:00:00 UTC = 2024-01-15 10:00:00 Shanghai + const utcTime = new Date("2024-01-15T02:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + // Reset at 08:00 Shanghai, we've passed it + const range = await getLeaseTimeRange("daily", "08:00", "fixed"); + + // Window starts at 08:00 Shanghai = 00:00 UTC + expect(range.startTime.toISOString()).toBe("2024-01-15T00:00:00.000Z"); + expect(range.endTime.toISOString()).toBe("2024-01-15T02:00:00.000Z"); + }); + + it("should use configured timezone for weekly window", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + // 2024-01-17 00:00:00 UTC = Wednesday 08:00 Shanghai + const utcTime = new Date("2024-01-17T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const range = await getLeaseTimeRange("weekly"); + + // Monday 00:00 Shanghai = Sunday 16:00 UTC + expect(range.startTime.toISOString()).toBe("2024-01-14T16:00:00.000Z"); + }); + + it("should use configured timezone for monthly window", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + // 2024-01-15 00:00:00 UTC = 2024-01-15 08:00 Shanghai + const utcTime = new Date("2024-01-15T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const range = await getLeaseTimeRange("monthly"); + + // Jan 1 00:00 Shanghai = Dec 31 16:00 UTC + expect(range.startTime.toISOString()).toBe("2023-12-31T16:00:00.000Z"); + }); + + it("should ignore timezone for rolling windows (5h)", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + const utcTime = new Date("2024-01-15T12:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("America/New_York"); + + const range = await getLeaseTimeRange("5h"); + + // 5h is always rolling, timezone doesn't matter + expect(range.startTime.toISOString()).toBe("2024-01-15T07:00:00.000Z"); + expect(range.endTime.toISOString()).toBe("2024-01-15T12:00:00.000Z"); + }); + + it("should ignore timezone for daily rolling window", async () => { + const { getLeaseTimeRange } = await import("@/lib/rate-limit/lease"); + + const utcTime = new Date("2024-01-15T12:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Europe/London"); + + const range = await getLeaseTimeRange("daily", "08:00", "rolling"); + + // Daily rolling is 24h back, timezone doesn't matter + expect(range.startTime.toISOString()).toBe("2024-01-14T12:00:00.000Z"); + expect(range.endTime.toISOString()).toBe("2024-01-15T12:00:00.000Z"); + }); + }); + + describe("getLeaseTtlSeconds timezone behavior", () => { + it("should calculate TTL based on configured timezone for daily fixed", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + // 2024-01-15 02:00:00 UTC = 2024-01-15 10:00:00 Shanghai + const utcTime = new Date("2024-01-15T02:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + // Next reset at 08:00 Shanghai tomorrow = 22 hours away + const ttl = await getLeaseTtlSeconds("daily", "08:00", "fixed"); + + expect(ttl).toBe(22 * 3600); + }); + + it("should calculate TTL based on configured timezone for weekly", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + // 2024-01-17 00:00:00 UTC = Wednesday 08:00 Shanghai + const utcTime = new Date("2024-01-17T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + // Next Monday 00:00 Shanghai = 112 hours away + const ttl = await getLeaseTtlSeconds("weekly"); + + expect(ttl).toBe(112 * 3600); + }); + + it("should return fixed TTL for rolling windows", async () => { + const { getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + + vi.mocked(resolveSystemTimezone).mockResolvedValue("Pacific/Auckland"); + + expect(await getLeaseTtlSeconds("5h")).toBe(5 * 3600); + expect(await getLeaseTtlSeconds("daily", "08:00", "rolling")).toBe(24 * 3600); + }); + }); + + describe("cross-module consistency", () => { + it("should produce same results as time-utils for daily fixed", async () => { + const { getLeaseTimeRange, getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + const { getTimeRangeForPeriod, getTTLForPeriod } = await import( + "@/lib/rate-limit/time-utils" + ); + + const utcTime = new Date("2024-01-15T02:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const leaseRange = await getLeaseTimeRange("daily", "08:00", "fixed"); + const timeUtilsRange = await getTimeRangeForPeriod("daily", "08:00"); + + expect(leaseRange.startTime.toISOString()).toBe(timeUtilsRange.startTime.toISOString()); + expect(leaseRange.endTime.toISOString()).toBe(timeUtilsRange.endTime.toISOString()); + + const leaseTtl = await getLeaseTtlSeconds("daily", "08:00", "fixed"); + const timeUtilsTtl = await getTTLForPeriod("daily", "08:00"); + + expect(leaseTtl).toBe(timeUtilsTtl); + }); + + it("should produce same results as time-utils for weekly", async () => { + const { getLeaseTimeRange, getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + const { getTimeRangeForPeriod, getTTLForPeriod } = await import( + "@/lib/rate-limit/time-utils" + ); + + const utcTime = new Date("2024-01-17T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const leaseRange = await getLeaseTimeRange("weekly"); + const timeUtilsRange = await getTimeRangeForPeriod("weekly"); + + expect(leaseRange.startTime.toISOString()).toBe(timeUtilsRange.startTime.toISOString()); + + const leaseTtl = await getLeaseTtlSeconds("weekly"); + const timeUtilsTtl = await getTTLForPeriod("weekly"); + + expect(leaseTtl).toBe(timeUtilsTtl); + }); + + it("should produce same results as time-utils for monthly", async () => { + const { getLeaseTimeRange, getLeaseTtlSeconds } = await import("@/lib/rate-limit/lease"); + const { getTimeRangeForPeriod, getTTLForPeriod } = await import( + "@/lib/rate-limit/time-utils" + ); + + const utcTime = new Date("2024-01-15T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const leaseRange = await getLeaseTimeRange("monthly"); + const timeUtilsRange = await getTimeRangeForPeriod("monthly"); + + expect(leaseRange.startTime.toISOString()).toBe(timeUtilsRange.startTime.toISOString()); + + const leaseTtl = await getLeaseTtlSeconds("monthly"); + const timeUtilsTtl = await getTTLForPeriod("monthly"); + + expect(leaseTtl).toBe(timeUtilsTtl); + }); + }); +}); diff --git a/tests/unit/lib/rate-limit/rolling-window-5h.test.ts b/tests/unit/lib/rate-limit/rolling-window-5h.test.ts new file mode 100644 index 000000000..1fe0cd1d2 --- /dev/null +++ b/tests/unit/lib/rate-limit/rolling-window-5h.test.ts @@ -0,0 +1,668 @@ +/** + * 5h Rolling Window Tests + * + * TDD: RED phase - tests to verify 5h quota uses true sliding window + * + * Expected behavior: + * - 5h window = current time - 5 hours (rolling, not fixed reset time) + * - Entries older than 5h should be excluded automatically + * - No "reset time" concept for 5h window + * - Error messages should NOT show a fixed reset time, but indicate rolling window + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock resolveSystemTimezone before importing modules +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), +})); + +const pipelineCommands: Array = []; + +const pipeline = { + zadd: vi.fn((...args: unknown[]) => { + pipelineCommands.push(["zadd", ...args]); + return pipeline; + }), + expire: vi.fn((...args: unknown[]) => { + pipelineCommands.push(["expire", ...args]); + return pipeline; + }), + exec: vi.fn(async () => { + pipelineCommands.push(["exec"]); + return []; + }), + incrbyfloat: vi.fn(() => pipeline), + zremrangebyscore: vi.fn(() => pipeline), + zcard: vi.fn(() => pipeline), +}; + +const redisClient = { + status: "ready", + eval: vi.fn(async () => "0"), + exists: vi.fn(async () => 1), + get: vi.fn(async () => null), + set: vi.fn(async () => "OK"), + setex: vi.fn(async () => "OK"), + pipeline: vi.fn(() => pipeline), +}; + +vi.mock("@/lib/redis", () => ({ + getRedisClient: () => redisClient, +})); + +const statisticsMock = { + // total cost + sumKeyTotalCost: vi.fn(async () => 0), + sumUserTotalCost: vi.fn(async () => 0), + sumProviderTotalCost: vi.fn(async () => 0), + + // fixed-window sums + sumKeyCostInTimeRange: vi.fn(async () => 0), + sumProviderCostInTimeRange: vi.fn(async () => 0), + sumUserCostInTimeRange: vi.fn(async () => 0), + + // rolling-window entries + findKeyCostEntriesInTimeRange: vi.fn(async () => []), + findProviderCostEntriesInTimeRange: vi.fn(async () => []), + findUserCostEntriesInTimeRange: vi.fn(async () => []), +}; + +vi.mock("@/repository/statistics", () => statisticsMock); + +describe("RateLimitService - 5h rolling window behavior", () => { + const baseTime = 1700000000000; // Base timestamp + + beforeEach(() => { + pipelineCommands.length = 0; + vi.resetAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date(baseTime)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("Scenario 1: Basic rolling window - entries expire after 5h", () => { + it("T0: consume $10, window should be $10", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // trackCost calls eval twice (key + provider) + redisClient.eval.mockResolvedValueOnce("10"); // TRACK key + redisClient.eval.mockResolvedValueOnce("10"); // TRACK provider + + await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime }); + + // getCurrentCost calls eval once, then exists + redisClient.eval.mockResolvedValueOnce("10"); // GET query + redisClient.exists.mockResolvedValueOnce(1); // key exists + + const current = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current).toBe(10); + }); + + it("T1 (3h later): consume $20, window should be $30", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // T0: Track $10 (2 evals: key + provider) + redisClient.eval.mockResolvedValueOnce("10"); + redisClient.eval.mockResolvedValueOnce("10"); + await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime }); + + // T1: Move to 3h later + const t1 = baseTime + 3 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t1)); + + // Track $20 (2 evals: key + provider) + redisClient.eval.mockResolvedValueOnce("20"); + redisClient.eval.mockResolvedValueOnce("20"); + await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 }); + + // getCurrentCost: eval returns sum + redisClient.eval.mockResolvedValueOnce("30"); + redisClient.exists.mockResolvedValueOnce(1); + + const current = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current).toBe(30); + }); + + it("T2 (6h later): query cost, should only include T1 ($20) as T0 expired", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // T0: Track $10 (2 evals) + redisClient.eval.mockResolvedValueOnce("10"); + redisClient.eval.mockResolvedValueOnce("10"); + await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime }); + + // T1: 3h later, track $20 (2 evals) + const t1 = baseTime + 3 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t1)); + redisClient.eval.mockResolvedValueOnce("30"); + redisClient.eval.mockResolvedValueOnce("30"); + await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 }); + + // T2: 6h after T0 (3h after T1) + const t2 = baseTime + 6 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t2)); + + // Lua script should clean T0 and return only T1 + redisClient.eval.mockResolvedValueOnce("20"); + redisClient.exists.mockResolvedValueOnce(1); + + const current = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current).toBe(20); + + // Verify Lua script was called with correct window calculation + const evalCall = redisClient.eval.mock.calls[redisClient.eval.mock.calls.length - 1]; + expect(evalCall[3]).toBe(t2.toString()); // now + expect(evalCall[4]).toBe((5 * 60 * 60 * 1000).toString()); // 5h window + }); + }); + + describe("Scenario 2: Window boundary - 4h59m vs 5h01m", () => { + it("T0: consume $5, T1 (4h59m later): consume $10, window = $15", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // T0: Track $5 (2 evals) + redisClient.eval.mockResolvedValueOnce("5"); + redisClient.eval.mockResolvedValueOnce("5"); + await RateLimitService.trackCost(1, 2, "sess", 5, { requestId: 1, createdAtMs: baseTime }); + + // T1: 4h59m later (still within 5h) + const t1 = baseTime + (4 * 60 + 59) * 60 * 1000; + vi.setSystemTime(new Date(t1)); + redisClient.eval.mockResolvedValueOnce("15"); + redisClient.eval.mockResolvedValueOnce("15"); + await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 2, createdAtMs: t1 }); + + // Both entries should be in window + redisClient.eval.mockResolvedValueOnce("15"); + redisClient.exists.mockResolvedValueOnce(1); + + const current = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current).toBe(15); + }); + + it("T2 (5h01m after T0): query, window = $10 (T0 expired)", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // T0: Track $5 (2 evals) + redisClient.eval.mockResolvedValueOnce("5"); + redisClient.eval.mockResolvedValueOnce("5"); + await RateLimitService.trackCost(1, 2, "sess", 5, { requestId: 1, createdAtMs: baseTime }); + + // T1: 4h59m later (2 evals) + const t1 = baseTime + (4 * 60 + 59) * 60 * 1000; + vi.setSystemTime(new Date(t1)); + redisClient.eval.mockResolvedValueOnce("15"); + redisClient.eval.mockResolvedValueOnce("15"); + await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 2, createdAtMs: t1 }); + + // T2: 5h01m after T0 + const t2 = baseTime + (5 * 60 + 1) * 60 * 1000; + vi.setSystemTime(new Date(t2)); + + // T0 should be cleaned, only T1 remains + redisClient.eval.mockResolvedValueOnce("10"); + redisClient.exists.mockResolvedValueOnce(1); + + const current = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current).toBe(10); + }); + }); + + describe("Scenario 3: Multiple entries rolling out", () => { + it("should correctly calculate window with multiple entries at different times", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // T0: $10 (2 evals) + redisClient.eval.mockResolvedValueOnce("10"); + redisClient.eval.mockResolvedValueOnce("10"); + await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: baseTime }); + + // T1: 1h later, $20 (2 evals) + const t1 = baseTime + 1 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t1)); + redisClient.eval.mockResolvedValueOnce("30"); + redisClient.eval.mockResolvedValueOnce("30"); + await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 }); + + // T2: 2h later, $15 (2 evals) + const t2 = baseTime + 2 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t2)); + redisClient.eval.mockResolvedValueOnce("45"); + redisClient.eval.mockResolvedValueOnce("45"); + await RateLimitService.trackCost(1, 2, "sess", 15, { requestId: 3, createdAtMs: t2 }); + + // T3: 3h after T0, $25 (2 evals) + const t3 = baseTime + 3 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t3)); + redisClient.eval.mockResolvedValueOnce("70"); + redisClient.eval.mockResolvedValueOnce("70"); + await RateLimitService.trackCost(1, 2, "sess", 25, { requestId: 4, createdAtMs: t3 }); + + // At T3: all 4 entries within window = $70 + redisClient.eval.mockResolvedValueOnce("70"); + redisClient.exists.mockResolvedValueOnce(1); + const currentT3 = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(currentT3).toBe(70); + + // T4: 6h after T0 + const t4 = baseTime + 6 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t4)); + + // T0 and T1 expired, only T2 and T3 remain = $40 + redisClient.eval.mockResolvedValueOnce("40"); + redisClient.exists.mockResolvedValueOnce(1); + const currentT4 = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(currentT4).toBe(40); + }); + }); + + describe("Scenario 4: Limit check with rolling window", () => { + it("should reject request when rolling window exceeds limit", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // T0: consume $40 (2 evals for trackCost) + redisClient.eval.mockResolvedValueOnce("40"); + redisClient.eval.mockResolvedValueOnce("40"); + await RateLimitService.trackCost(1, 2, "sess", 40, { requestId: 1, createdAtMs: baseTime }); + + // Check limit (5h = $50) - checkCostLimits calls eval + redisClient.eval.mockResolvedValueOnce("40"); + redisClient.exists.mockResolvedValueOnce(1); + const checkT0 = await RateLimitService.checkCostLimits(1, "key", { + limit_5h_usd: 50, + limit_daily_usd: null, + limit_weekly_usd: null, + limit_monthly_usd: null, + }); + expect(checkT0.allowed).toBe(true); + + // T1: 3h later, try to consume $20 (would make window $60 > $50) + const t1 = baseTime + 3 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t1)); + + // checkCostLimits: eval returns current = $40 + redisClient.eval.mockResolvedValueOnce("40"); + redisClient.exists.mockResolvedValueOnce(1); + + const checkT1 = await RateLimitService.checkCostLimits(1, "key", { + limit_5h_usd: 50, + limit_daily_usd: null, + limit_weekly_usd: null, + limit_monthly_usd: null, + }); + + // Current is $40, limit is $50, should still be allowed + expect(checkT1.allowed).toBe(true); + + // After adding $20, would be $60 - trackCost (2 evals) + redisClient.eval.mockResolvedValueOnce("60"); + redisClient.eval.mockResolvedValueOnce("60"); + await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: t1 }); + + // Verify window now shows $60 + redisClient.eval.mockResolvedValueOnce("60"); + redisClient.exists.mockResolvedValueOnce(1); + const currentT1 = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(currentT1).toBe(60); + + // T2: 6h after T0, T0's $40 expires, window = $20 + const t2 = baseTime + 6 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t2)); + + redisClient.eval.mockResolvedValueOnce("20"); + redisClient.exists.mockResolvedValueOnce(1); + const checkT2 = await RateLimitService.checkCostLimits(1, "key", { + limit_5h_usd: 50, + limit_daily_usd: null, + limit_weekly_usd: null, + limit_monthly_usd: null, + }); + expect(checkT2.allowed).toBe(true); + }); + }); + + describe("Scenario 5: Cross-day rolling window", () => { + it("should handle entries across day boundary correctly", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // Day1 22:00 UTC + const day1_22h = new Date("2024-01-15T22:00:00.000Z").getTime(); + vi.setSystemTime(new Date(day1_22h)); + + // Track $10 (2 evals) + redisClient.eval.mockResolvedValueOnce("10"); + redisClient.eval.mockResolvedValueOnce("10"); + await RateLimitService.trackCost(1, 2, "sess", 10, { requestId: 1, createdAtMs: day1_22h }); + + // Day2 01:00 UTC (3h later, crossed midnight) + const day2_01h = new Date("2024-01-16T01:00:00.000Z").getTime(); + vi.setSystemTime(new Date(day2_01h)); + + // Track $20 (2 evals) + redisClient.eval.mockResolvedValueOnce("30"); + redisClient.eval.mockResolvedValueOnce("30"); + await RateLimitService.trackCost(1, 2, "sess", 20, { requestId: 2, createdAtMs: day2_01h }); + + // Both entries in window = $30 + redisClient.eval.mockResolvedValueOnce("30"); + redisClient.exists.mockResolvedValueOnce(1); + const current01h = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current01h).toBe(30); + + // Day2 04:00 UTC (6h after day1_22h) + const day2_04h = new Date("2024-01-16T04:00:00.000Z").getTime(); + vi.setSystemTime(new Date(day2_04h)); + + // First entry expired, only second remains = $20 + redisClient.eval.mockResolvedValueOnce("20"); + redisClient.exists.mockResolvedValueOnce(1); + const current04h = await RateLimitService.getCurrentCost(1, "key", "5h"); + expect(current04h).toBe(20); + }); + }); + + describe("Verify no fixed reset time exists for 5h window", () => { + it("should not have any fixed reset time concept", async () => { + const { getResetInfo } = await import("@/lib/rate-limit/time-utils"); + + const info = await getResetInfo("5h"); + + // 5h window is rolling type, no resetAt timestamp + expect(info.type).toBe("rolling"); + expect(info.period).toBe("5 小时"); + expect(info.resetAt).toBeUndefined(); + }); + + it("should always calculate window as (now - 5h) to now", async () => { + const { getTimeRangeForPeriod } = await import("@/lib/rate-limit/time-utils"); + + const now1 = new Date("2024-01-15T10:00:00.000Z").getTime(); + vi.setSystemTime(new Date(now1)); + + const range1 = await getTimeRangeForPeriod("5h"); + expect(range1.endTime.getTime()).toBe(now1); + expect(range1.startTime.getTime()).toBe(now1 - 5 * 60 * 60 * 1000); + + // Different time + const now2 = new Date("2024-01-16T15:30:00.000Z").getTime(); + vi.setSystemTime(new Date(now2)); + + const range2 = await getTimeRangeForPeriod("5h"); + expect(range2.endTime.getTime()).toBe(now2); + expect(range2.startTime.getTime()).toBe(now2 - 5 * 60 * 60 * 1000); + }); + }); + + describe("Provider 5h rolling window", () => { + it("should work identically for provider entities", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // T0: provider consumes $15 (2 evals) + redisClient.eval.mockResolvedValueOnce("15"); + redisClient.eval.mockResolvedValueOnce("15"); + await RateLimitService.trackCost(1, 2, "sess", 15, { requestId: 1, createdAtMs: baseTime }); + + // T1: 4h later, consume $25 (2 evals) + const t1 = baseTime + 4 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t1)); + redisClient.eval.mockResolvedValueOnce("40"); + redisClient.eval.mockResolvedValueOnce("40"); + await RateLimitService.trackCost(1, 2, "sess", 25, { requestId: 2, createdAtMs: t1 }); + + // Window = $40 + redisClient.eval.mockResolvedValueOnce("40"); + redisClient.exists.mockResolvedValueOnce(1); + const currentT1 = await RateLimitService.getCurrentCost(2, "provider", "5h"); + expect(currentT1).toBe(40); + + // T2: 6h after T0 + const t2 = baseTime + 6 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t2)); + + // Only T1 remains = $25 + redisClient.eval.mockResolvedValueOnce("25"); + redisClient.exists.mockResolvedValueOnce(1); + const currentT2 = await RateLimitService.getCurrentCost(2, "provider", "5h"); + expect(currentT2).toBe(25); + }); + }); + + describe("Cache miss and DB recovery", () => { + it("should restore from DB entries with correct time range on cache miss", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // Simulate cache miss: eval returns 0 and key doesn't exist + redisClient.eval.mockResolvedValueOnce("0"); + redisClient.exists.mockResolvedValueOnce(0); + + // Mock DB entries within 5h window + const now = baseTime + 3 * 60 * 60 * 1000; // 3h later + vi.setSystemTime(new Date(now)); + + statisticsMock.findKeyCostEntriesInTimeRange.mockResolvedValueOnce([ + { id: 1, createdAt: new Date(baseTime), costUsd: 10 }, + { id: 2, createdAt: new Date(baseTime + 1 * 60 * 60 * 1000), costUsd: 20 }, + { id: 3, createdAt: new Date(baseTime + 2 * 60 * 60 * 1000), costUsd: 15 }, + ]); + + const current = await RateLimitService.getCurrentCost(1, "key", "5h"); + + // Should sum all entries = $45 + expect(current).toBeCloseTo(45, 10); + + // Verify DB was called with correct time range (now - 5h to now) + expect(statisticsMock.findKeyCostEntriesInTimeRange).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + getTime: expect.any(Function), + }), + expect.objectContaining({ + getTime: expect.any(Function), + }) + ); + + const [, startTime, endTime] = statisticsMock.findKeyCostEntriesInTimeRange.mock.calls[0]; + expect(endTime.getTime()).toBe(now); + expect(startTime.getTime()).toBe(now - 5 * 60 * 60 * 1000); + }); + }); +}); + +/** + * Tests for error message and resetTime when 5h limit is exceeded + * + * Key expectation: 5h rolling window should NOT have a fixed "reset time" + * The current implementation incorrectly calculates resetTime as Date.now() + 5h + * which implies "start counting from when limit is hit" + * + * Expected behavior for rolling window: + * - resetTime concept doesn't apply to rolling windows + * - Should indicate "rolling 5h window" in the message + * - Earliest entry expiry time might be useful to show when some budget will free up + */ +describe("5h limit exceeded - error message and resetTime", () => { + const baseTime = 1700000000000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(baseTime)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("resetTime semantics for rolling window", () => { + it("5h window getResetInfo should return rolling type without resetAt", async () => { + const { getResetInfo } = await import("@/lib/rate-limit/time-utils"); + + const info = await getResetInfo("5h"); + + // Rolling windows have no fixed reset time + expect(info.type).toBe("rolling"); + expect(info.resetAt).toBeUndefined(); + expect(info.period).toBe("5 小时"); + }); + + it("5h rolling window should NOT use (now + 5h) as reset time", async () => { + // This test documents the expected behavior: + // For rolling windows, the "reset time" concept is misleading + // Because usage gradually rolls out as entries age past 5h + // + // WRONG: resetTime = now + 5h (implies "start counting from trigger") + // RIGHT: No fixed reset time, or show when earliest entry expires + + const { getResetInfo } = await import("@/lib/rate-limit/time-utils"); + + const t1 = baseTime; + vi.setSystemTime(new Date(t1)); + const info1 = await getResetInfo("5h"); + + // Move forward 3 hours + const t2 = baseTime + 3 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t2)); + const info2 = await getResetInfo("5h"); + + // Both should indicate rolling type, no specific resetAt + expect(info1.type).toBe("rolling"); + expect(info2.type).toBe("rolling"); + expect(info1.resetAt).toBeUndefined(); + expect(info2.resetAt).toBeUndefined(); + }); + + it("time range should always be (now - 5h, now), not anchored to trigger time", async () => { + const { getTimeRangeForPeriod } = await import("@/lib/rate-limit/time-utils"); + + // T1: Check time range + const t1 = baseTime; + vi.setSystemTime(new Date(t1)); + const range1 = await getTimeRangeForPeriod("5h"); + expect(range1.startTime.getTime()).toBe(t1 - 5 * 60 * 60 * 1000); + expect(range1.endTime.getTime()).toBe(t1); + + // T2: 3 hours later, time range should shift + const t2 = baseTime + 3 * 60 * 60 * 1000; + vi.setSystemTime(new Date(t2)); + const range2 = await getTimeRangeForPeriod("5h"); + expect(range2.startTime.getTime()).toBe(t2 - 5 * 60 * 60 * 1000); + expect(range2.endTime.getTime()).toBe(t2); + + // The window should have shifted, not stayed anchored + expect(range2.startTime.getTime()).toBe(range1.startTime.getTime() + 3 * 60 * 60 * 1000); + }); + }); + + describe("error message content verification", () => { + it("error message should indicate rolling window nature", async () => { + // For rolling windows, the message should NOT say "Resets at " + // Instead, it should convey that this is a rolling 5-hour window + // + // Example of problematic message: + // "5-hour cost limit exceeded. Resets at 2024-01-15T15:00:00Z" + // (This implies you wait until 15:00 and then everything resets) + // + // Better message: + // "5-hour rolling window cost limit exceeded. Usage is calculated over the past 5 hours." + // or + // "5-hour cost limit exceeded. Oldest usage will roll off in X hours." + + const { getResetInfo } = await import("@/lib/rate-limit/time-utils"); + const info = await getResetInfo("5h"); + + // The info should clearly indicate this is a rolling window + expect(info.type).toBe("rolling"); + // And provide the period description + expect(info.period).toBeDefined(); + }); + }); + + describe("comparison with daily fixed window", () => { + it("daily fixed window SHOULD have a specific reset time", async () => { + const { getResetInfo } = await import("@/lib/rate-limit/time-utils"); + + const info = await getResetInfo("daily", "18:00"); + + // Daily fixed windows have a specific reset time + expect(info.type).toBe("custom"); + expect(info.resetAt).toBeDefined(); + expect(info.resetAt).toBeInstanceOf(Date); + }); + + it("daily rolling window should NOT have a specific reset time", async () => { + const { getResetInfoWithMode } = await import("@/lib/rate-limit/time-utils"); + + const info = await getResetInfoWithMode("daily", "18:00", "rolling"); + + // Daily rolling also has no fixed reset + expect(info.type).toBe("rolling"); + expect(info.resetAt).toBeUndefined(); + expect(info.period).toBe("24 小时"); + }); + }); + + describe("weekly and monthly windows for comparison", () => { + it("weekly window should have natural reset time (next Monday)", async () => { + const { getResetInfo } = await import("@/lib/rate-limit/time-utils"); + + const info = await getResetInfo("weekly"); + + expect(info.type).toBe("natural"); + expect(info.resetAt).toBeDefined(); + }); + + it("monthly window should have natural reset time (1st of next month)", async () => { + const { getResetInfo } = await import("@/lib/rate-limit/time-utils"); + + const info = await getResetInfo("monthly"); + + expect(info.type).toBe("natural"); + expect(info.resetAt).toBeDefined(); + }); + }); +}); + +/** + * Integration test: verify the full flow from limit check to error message + * + * This test verifies that when a 5h limit is exceeded: + * 1. The check correctly identifies the limit is exceeded + * 2. The error response contains appropriate information about the rolling window + * 3. The resetTime in the error is semantically correct for a rolling window + */ +describe("5h limit exceeded - full flow integration", () => { + const baseTime = 1700000000000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(baseTime)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("checkCostLimits should return appropriate failure info for 5h exceeded", async () => { + const { RateLimitService } = await import("@/lib/rate-limit"); + + // Mock current usage: $60 (exceeds $50 limit) + redisClient.eval.mockResolvedValueOnce("60"); + redisClient.exists.mockResolvedValueOnce(1); + + const result = await RateLimitService.checkCostLimits(1, "key", { + limit_5h_usd: 50, // Limit: $50 + limit_daily_usd: null, + limit_weekly_usd: null, + limit_monthly_usd: null, + }); + + expect(result.allowed).toBe(false); + // The reason should indicate the limit was exceeded + expect(result.reason).toContain("5小时"); + expect(result.reason).toContain("60"); + expect(result.reason).toContain("50"); + }); +}); diff --git a/tests/unit/lib/rate-limit/time-utils.test.ts b/tests/unit/lib/rate-limit/time-utils.test.ts index 240af534b..e44d4f02f 100644 --- a/tests/unit/lib/rate-limit/time-utils.test.ts +++ b/tests/unit/lib/rate-limit/time-utils.test.ts @@ -1,8 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock resolveSystemTimezone before importing time-utils +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), +})); + +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { getDailyResetTime, + getResetInfo, getResetInfoWithMode, getSecondsUntilMidnight, + getTimeRangeForPeriod, getTimeRangeForPeriodWithMode, getTTLForPeriod, getTTLForPeriodWithMode, @@ -21,39 +30,472 @@ describe("rate-limit time-utils", () => { vi.useRealTimers(); }); - it("normalizeResetTime:非法时间应回退到安全默认值", () => { + it("normalizeResetTime: illegal time should fallback to safe default", () => { expect(normalizeResetTime("abc")).toBe("00:00"); expect(normalizeResetTime("99:10")).toBe("00:10"); expect(normalizeResetTime("12:70")).toBe("12:00"); }); - it("getTimeRangeForPeriodWithMode:daily rolling 应返回过去 24 小时窗口", () => { - const { startTime, endTime } = getTimeRangeForPeriodWithMode("daily", "00:00", "rolling"); + it("getTimeRangeForPeriodWithMode: daily rolling should return past 24h window", async () => { + const { startTime, endTime } = await getTimeRangeForPeriodWithMode("daily", "00:00", "rolling"); expect(endTime.getTime()).toBe(nowMs); expect(startTime.getTime()).toBe(nowMs - 24 * 60 * 60 * 1000); }); - it("getResetInfoWithMode:daily rolling 应返回 rolling 语义", () => { - const info = getResetInfoWithMode("daily", "00:00", "rolling"); + it("getResetInfoWithMode: daily rolling should return rolling semantics", async () => { + const info = await getResetInfoWithMode("daily", "00:00", "rolling"); expect(info.type).toBe("rolling"); expect(info.period).toBe("24 小时"); }); - it("getTTLForPeriodWithMode:daily rolling TTL 应为 24 小时", () => { - expect(getTTLForPeriodWithMode("daily", "00:00", "rolling")).toBe(24 * 3600); + it("getTTLForPeriodWithMode: daily rolling TTL should be 24h", async () => { + expect(await getTTLForPeriodWithMode("daily", "00:00", "rolling")).toBe(24 * 3600); }); - it("getTTLForPeriod:5h TTL 应为 5 小时", () => { - expect(getTTLForPeriod("5h")).toBe(5 * 3600); + it("getTTLForPeriod: 5h TTL should be 5h", async () => { + expect(await getTTLForPeriod("5h")).toBe(5 * 3600); }); - it("getSecondsUntilMidnight/getDailyResetTime:应能计算出合理的每日重置时间", () => { - const seconds = getSecondsUntilMidnight(); + it("getSecondsUntilMidnight/getDailyResetTime: should compute reasonable daily reset time", async () => { + const seconds = await getSecondsUntilMidnight(); expect(seconds).toBeGreaterThan(0); expect(seconds).toBeLessThanOrEqual(24 * 3600); - const resetAt = getDailyResetTime(); + const resetAt = await getDailyResetTime(); expect(resetAt.getTime()).toBeGreaterThan(nowMs); }); }); + +/** + * Timezone Consistency Tests + * + * Verify that all time calculations use resolveSystemTimezone() consistently + * and produce correct results across different timezone configurations. + */ +describe("timezone consistency", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should use timezone from resolveSystemTimezone for daily fixed calculations", async () => { + // Set time to 2024-01-15 02:00:00 UTC + // In Asia/Shanghai (+8), this is 2024-01-15 10:00:00 + const utcTime = new Date("2024-01-15T02:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + // Reset time 08:00 Shanghai = 00:00 UTC + // At Shanghai 10:00, we've passed 08:00, so window starts at today's 08:00 Shanghai = 00:00 UTC + const { startTime } = await getTimeRangeForPeriod("daily", "08:00"); + + // Verify resolveSystemTimezone was called + expect(resolveSystemTimezone).toHaveBeenCalled(); + + // Start should be 2024-01-15 00:00:00 UTC (08:00 Shanghai) + expect(startTime.toISOString()).toBe("2024-01-15T00:00:00.000Z"); + }); + + it("should calculate daily fixed window correctly for Asia/Shanghai", async () => { + // 2024-01-15 00:00:00 UTC = 2024-01-15 08:00:00 Shanghai + // Reset at 08:00 Shanghai, we're exactly at reset time + const utcTime = new Date("2024-01-15T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime, endTime } = await getTimeRangeForPeriod("daily", "08:00"); + + // At exactly 08:00 Shanghai, window starts at 08:00 Shanghai today = 00:00 UTC + expect(startTime.toISOString()).toBe("2024-01-15T00:00:00.000Z"); + expect(endTime.toISOString()).toBe("2024-01-15T00:00:00.000Z"); + }); + + it("should calculate daily fixed window correctly before reset time", async () => { + // 2024-01-14 23:00:00 UTC = 2024-01-15 07:00:00 Shanghai + // Reset at 08:00 Shanghai, we haven't reached it yet + const utcTime = new Date("2024-01-14T23:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("daily", "08:00"); + + // Before 08:00 Shanghai, window starts at yesterday's 08:00 Shanghai = 2024-01-14 00:00 UTC + expect(startTime.toISOString()).toBe("2024-01-14T00:00:00.000Z"); + }); + + it("should calculate daily fixed window correctly for America/New_York", async () => { + // 2024-01-15 14:00:00 UTC = 2024-01-15 09:00:00 New York (EST, -5) + // Reset at 08:00 New York, we've passed it + const utcTime = new Date("2024-01-15T14:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("America/New_York"); + + const { startTime } = await getTimeRangeForPeriod("daily", "08:00"); + + // 08:00 New York = 13:00 UTC + expect(startTime.toISOString()).toBe("2024-01-15T13:00:00.000Z"); + }); + + it("should calculate weekly window start in configured timezone", async () => { + // 2024-01-17 00:00:00 UTC = Wednesday + // In Asia/Shanghai (+8), this is 2024-01-17 08:00:00 (still Wednesday) + // Week starts Monday 00:00 Shanghai = 2024-01-15 00:00 Shanghai = 2024-01-14 16:00 UTC + const utcTime = new Date("2024-01-17T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("weekly"); + + // Monday 00:00 Shanghai = Sunday 16:00 UTC + expect(startTime.toISOString()).toBe("2024-01-14T16:00:00.000Z"); + }); + + it("should calculate monthly window start in configured timezone", async () => { + // 2024-01-15 00:00:00 UTC = 2024-01-15 08:00:00 Shanghai + // Month starts Jan 1 00:00 Shanghai = Dec 31 16:00 UTC + const utcTime = new Date("2024-01-15T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("monthly"); + + // Jan 1 00:00 Shanghai = Dec 31 16:00 UTC + expect(startTime.toISOString()).toBe("2023-12-31T16:00:00.000Z"); + }); + + it("should handle day boundary crossing between UTC and local TZ", async () => { + // Edge case: 2024-01-15 23:30:00 UTC = 2024-01-16 07:30:00 Shanghai + // Reset at 08:00 Shanghai - we're in Shanghai's "tomorrow" but before reset + const utcTime = new Date("2024-01-15T23:30:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("daily", "08:00"); + + // In Shanghai it's Jan 16 07:30, before 08:00 reset + // So window starts at Jan 15 08:00 Shanghai = Jan 15 00:00 UTC + expect(startTime.toISOString()).toBe("2024-01-15T00:00:00.000Z"); + }); + + it("should use rolling mode regardless of timezone for daily rolling", async () => { + const utcTime = new Date("2024-01-15T12:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("America/New_York"); + + const { startTime, endTime } = await getTimeRangeForPeriodWithMode("daily", "08:00", "rolling"); + + // Rolling mode: always 24 hours back, timezone doesn't matter + expect(endTime.toISOString()).toBe("2024-01-15T12:00:00.000Z"); + expect(startTime.toISOString()).toBe("2024-01-14T12:00:00.000Z"); + }); + + it("should use 5h rolling window regardless of timezone", async () => { + const utcTime = new Date("2024-01-15T12:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Europe/London"); + + const { startTime, endTime } = await getTimeRangeForPeriod("5h"); + + // 5h rolling: always 5 hours back + expect(endTime.toISOString()).toBe("2024-01-15T12:00:00.000Z"); + expect(startTime.toISOString()).toBe("2024-01-15T07:00:00.000Z"); + }); +}); + +/** + * TTL Calculation Timezone Tests + * + * Verify that TTL calculations use server timezone consistently + */ +describe("TTL timezone consistency", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should calculate daily fixed TTL based on configured timezone", async () => { + // 2024-01-15 02:00:00 UTC = 2024-01-15 10:00:00 Shanghai + // Reset at 08:00 Shanghai, next reset is tomorrow 08:00 Shanghai = 2024-01-16 00:00 UTC + // TTL = 22 hours = 79200 seconds + const utcTime = new Date("2024-01-15T02:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const ttl = await getTTLForPeriod("daily", "08:00"); + + // From 10:00 Shanghai to next 08:00 Shanghai = 22 hours + expect(ttl).toBe(22 * 3600); + }); + + it("should calculate daily fixed TTL correctly when close to reset time", async () => { + // 2024-01-14 23:30:00 UTC = 2024-01-15 07:30:00 Shanghai + // Reset at 08:00 Shanghai, next reset is in 30 minutes + const utcTime = new Date("2024-01-14T23:30:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const ttl = await getTTLForPeriod("daily", "08:00"); + + // 30 minutes = 1800 seconds + expect(ttl).toBe(30 * 60); + }); + + it("should calculate weekly TTL based on configured timezone", async () => { + // 2024-01-17 00:00:00 UTC = Wednesday 08:00 Shanghai + // Next Monday 00:00 Shanghai = 2024-01-22 00:00 Shanghai = 2024-01-21 16:00 UTC + const utcTime = new Date("2024-01-17T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const ttl = await getTTLForPeriod("weekly"); + + // From Wed 08:00 to Mon 00:00 = 4 days + 16 hours = 112 hours + expect(ttl).toBe(112 * 3600); + }); + + it("should calculate monthly TTL based on configured timezone", async () => { + // 2024-01-30 00:00:00 UTC = 2024-01-30 08:00:00 Shanghai + // Next month Feb 1 00:00 Shanghai = 2024-01-31 16:00 UTC + const utcTime = new Date("2024-01-30T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const ttl = await getTTLForPeriod("monthly"); + + // From Jan 30 08:00 to Feb 1 00:00 Shanghai = 1 day + 16 hours = 40 hours + expect(ttl).toBe(40 * 3600); + }); + + it("should return 24h TTL for daily rolling regardless of timezone", async () => { + vi.mocked(resolveSystemTimezone).mockResolvedValue("Pacific/Auckland"); + + const ttl = await getTTLForPeriodWithMode("daily", "08:00", "rolling"); + + expect(ttl).toBe(24 * 3600); + }); + + it("should return 5h TTL for 5h period regardless of timezone", async () => { + vi.mocked(resolveSystemTimezone).mockResolvedValue("America/Los_Angeles"); + + const ttl = await getTTLForPeriod("5h"); + + expect(ttl).toBe(5 * 3600); + }); +}); + +/** + * ResetInfo Timezone Tests + * + * Verify that reset info calculations use server timezone consistently + */ +describe("ResetInfo timezone consistency", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return next reset time in configured timezone for daily", async () => { + // 2024-01-15 02:00:00 UTC = 2024-01-15 10:00:00 Shanghai + // Next reset at 08:00 Shanghai = 2024-01-16 00:00:00 UTC + const utcTime = new Date("2024-01-15T02:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const info = await getResetInfo("daily", "08:00"); + + expect(info.type).toBe("custom"); + expect(info.resetAt?.toISOString()).toBe("2024-01-16T00:00:00.000Z"); + }); + + it("should return next Monday for weekly in configured timezone", async () => { + // 2024-01-17 00:00:00 UTC = Wednesday 08:00 Shanghai + // Next Monday 00:00 Shanghai = 2024-01-21 16:00 UTC + const utcTime = new Date("2024-01-17T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const info = await getResetInfo("weekly"); + + expect(info.type).toBe("natural"); + expect(info.resetAt?.toISOString()).toBe("2024-01-21T16:00:00.000Z"); + }); + + it("should return next month start for monthly in configured timezone", async () => { + // 2024-01-15 00:00:00 UTC = 2024-01-15 08:00 Shanghai + // Feb 1 00:00 Shanghai = 2024-01-31 16:00 UTC + const utcTime = new Date("2024-01-15T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const info = await getResetInfo("monthly"); + + expect(info.type).toBe("natural"); + expect(info.resetAt?.toISOString()).toBe("2024-01-31T16:00:00.000Z"); + }); + + it("should return rolling type for 5h period", async () => { + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const info = await getResetInfo("5h"); + + expect(info.type).toBe("rolling"); + expect(info.period).toBe("5 小时"); + expect(info.resetAt).toBeUndefined(); + }); + + it("should return rolling type for daily rolling mode", async () => { + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const info = await getResetInfoWithMode("daily", "08:00", "rolling"); + + expect(info.type).toBe("rolling"); + expect(info.period).toBe("24 小时"); + }); +}); + +/** + * Edge Cases and Boundary Tests + */ +describe("timezone edge cases", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should handle midnight reset time (00:00)", async () => { + // 2024-01-15 18:00:00 UTC = 2024-01-16 02:00:00 Shanghai + // Reset at 00:00 Shanghai, window starts at 2024-01-16 00:00 Shanghai = 2024-01-15 16:00 UTC + const utcTime = new Date("2024-01-15T18:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("daily", "00:00"); + + expect(startTime.toISOString()).toBe("2024-01-15T16:00:00.000Z"); + }); + + it("should handle late night reset time (23:59)", async () => { + // 2024-01-15 16:30:00 UTC = 2024-01-16 00:30:00 Shanghai + // Reset at 23:59 Shanghai, we're past it (just after midnight) + // Window starts at 2024-01-15 23:59 Shanghai = 2024-01-15 15:59 UTC + const utcTime = new Date("2024-01-15T16:30:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("daily", "23:59"); + + expect(startTime.toISOString()).toBe("2024-01-15T15:59:00.000Z"); + }); + + it("should handle year boundary for monthly window", async () => { + // 2024-01-05 00:00:00 UTC = 2024-01-05 08:00:00 Shanghai + // Month starts Jan 1 00:00 Shanghai = 2023-12-31 16:00 UTC + const utcTime = new Date("2024-01-05T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("monthly"); + + expect(startTime.toISOString()).toBe("2023-12-31T16:00:00.000Z"); + }); + + it("should handle week boundary crossing year", async () => { + // 2024-01-03 00:00:00 UTC = Wednesday = 2024-01-03 08:00 Shanghai + // Week started Monday 2024-01-01 00:00 Shanghai = 2023-12-31 16:00 UTC + const utcTime = new Date("2024-01-03T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const { startTime } = await getTimeRangeForPeriod("weekly"); + + expect(startTime.toISOString()).toBe("2023-12-31T16:00:00.000Z"); + }); + + it("should handle negative UTC offset timezone (America/New_York)", async () => { + // 2024-01-15 03:00:00 UTC = 2024-01-14 22:00:00 New York (EST -5) + // Reset at 08:00 New York, we're before it (still previous day in NY) + // Window starts at 2024-01-14 08:00 NY = 2024-01-14 13:00 UTC + const utcTime = new Date("2024-01-15T03:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("America/New_York"); + + const { startTime } = await getTimeRangeForPeriod("daily", "08:00"); + + expect(startTime.toISOString()).toBe("2024-01-14T13:00:00.000Z"); + }); + + it("should handle UTC timezone", async () => { + // 2024-01-15 10:00:00 UTC + // Reset at 08:00 UTC, we've passed it + // Window starts at 2024-01-15 08:00 UTC + const utcTime = new Date("2024-01-15T10:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("UTC"); + + const { startTime } = await getTimeRangeForPeriod("daily", "08:00"); + + expect(startTime.toISOString()).toBe("2024-01-15T08:00:00.000Z"); + }); + + it("should handle large positive UTC offset (Pacific/Auckland +13)", async () => { + // 2024-01-15 10:00:00 UTC = 2024-01-15 23:00:00 Auckland + // Reset at 08:00 Auckland, we've passed it + // Window starts at 2024-01-15 08:00 Auckland = 2024-01-14 19:00 UTC + const utcTime = new Date("2024-01-15T10:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Pacific/Auckland"); + + const { startTime } = await getTimeRangeForPeriod("daily", "08:00"); + + expect(startTime.toISOString()).toBe("2024-01-14T19:00:00.000Z"); + }); + + it("should calculate correct TTL at exact reset moment", async () => { + // 2024-01-15 00:00:00 UTC = 2024-01-15 08:00:00 Shanghai (exactly at reset) + // Next reset is 2024-01-16 08:00 Shanghai = 2024-01-16 00:00 UTC + // TTL = 24 hours + const utcTime = new Date("2024-01-15T00:00:00.000Z"); + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + const ttl = await getTTLForPeriod("daily", "08:00"); + + expect(ttl).toBe(24 * 3600); + }); + + it("should handle different reset times consistently", async () => { + // Test multiple reset times to ensure consistency + const utcTime = new Date("2024-01-15T12:00:00.000Z"); // 20:00 Shanghai + vi.setSystemTime(utcTime); + vi.mocked(resolveSystemTimezone).mockResolvedValue("Asia/Shanghai"); + + // 06:00 Shanghai = passed, window starts today 06:00 = 2024-01-14 22:00 UTC + const range06 = await getTimeRangeForPeriod("daily", "06:00"); + expect(range06.startTime.toISOString()).toBe("2024-01-14T22:00:00.000Z"); + + // 18:00 Shanghai = passed, window starts today 18:00 = 2024-01-15 10:00 UTC + const range18 = await getTimeRangeForPeriod("daily", "18:00"); + expect(range18.startTime.toISOString()).toBe("2024-01-15T10:00:00.000Z"); + + // 21:00 Shanghai = not yet, window starts yesterday 21:00 = 2024-01-14 13:00 UTC + const range21 = await getTimeRangeForPeriod("daily", "21:00"); + expect(range21.startTime.toISOString()).toBe("2024-01-14T13:00:00.000Z"); + }); +}); diff --git a/tests/unit/lib/timezone/system-timezone.test.ts b/tests/unit/lib/timezone/system-timezone.test.ts new file mode 100644 index 000000000..f92004206 --- /dev/null +++ b/tests/unit/lib/timezone/system-timezone.test.ts @@ -0,0 +1,119 @@ +/** + * System Timezone Tests + * + * TDD tests for the system timezone feature: + * 1. Timezone field in SystemSettings + * 2. IANA timezone validation + * 3. Timezone resolver with fallback chain + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +describe("System Timezone", () => { + describe("IANA Timezone Validation", () => { + it("should accept valid IANA timezone strings", async () => { + const { isValidIANATimezone } = await import("@/lib/utils/timezone"); + + expect(isValidIANATimezone("Asia/Shanghai")).toBe(true); + expect(isValidIANATimezone("America/New_York")).toBe(true); + expect(isValidIANATimezone("Europe/London")).toBe(true); + expect(isValidIANATimezone("UTC")).toBe(true); + expect(isValidIANATimezone("Pacific/Auckland")).toBe(true); + }); + + it("should reject invalid timezone strings", async () => { + const { isValidIANATimezone } = await import("@/lib/utils/timezone"); + + expect(isValidIANATimezone("")).toBe(false); + expect(isValidIANATimezone("Invalid/Timezone")).toBe(false); + // Note: Some abbreviations like "CST" may be valid in Intl API depending on environment + // We test clearly invalid values + expect(isValidIANATimezone("NotATimezone/AtAll")).toBe(false); + expect(isValidIANATimezone(null as unknown as string)).toBe(false); + expect(isValidIANATimezone(undefined as unknown as string)).toBe(false); + }); + }); + + describe("toSystemSettings transformer", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should map timezone field from database", async () => { + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + const result = toSystemSettings({ + id: 1, + timezone: "Europe/Paris", + }); + + expect(result.timezone).toBe("Europe/Paris"); + }); + + it("should default to null when timezone is not set", async () => { + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + const result = toSystemSettings({ + id: 1, + }); + + expect(result.timezone).toBeNull(); + }); + }); + + describe("UpdateSystemSettingsSchema", () => { + it("should accept valid IANA timezone", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const result = UpdateSystemSettingsSchema.safeParse({ + timezone: "Asia/Tokyo", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.timezone).toBe("Asia/Tokyo"); + } + }); + + it("should reject invalid timezone", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const result = UpdateSystemSettingsSchema.safeParse({ + timezone: "Invalid/Zone", + }); + + expect(result.success).toBe(false); + }); + + it("should accept undefined timezone (no update)", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const result = UpdateSystemSettingsSchema.safeParse({ + siteTitle: "Test Site", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.timezone).toBeUndefined(); + } + }); + + it("should accept null timezone (clear setting)", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const result = UpdateSystemSettingsSchema.safeParse({ + timezone: null, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.timezone).toBeNull(); + } + }); + }); +}); diff --git a/tests/unit/lib/timezone/timezone-resolver.test.ts b/tests/unit/lib/timezone/timezone-resolver.test.ts new file mode 100644 index 000000000..4650b86b7 --- /dev/null +++ b/tests/unit/lib/timezone/timezone-resolver.test.ts @@ -0,0 +1,180 @@ +/** + * Timezone Resolver Tests (Task 2) + * + * TDD tests for the system timezone resolver: + * - Fallback chain: DB timezone -> env TZ -> UTC + * - Validation of resolved timezone + * - Integration with cached system settings + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +// Mock the system settings cache +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: vi.fn(), +})); + +// Mock env config +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: vi.fn(), + isDevelopment: vi.fn(() => false), +})); + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; +import { getEnvConfig } from "@/lib/config/env.schema"; +import type { SystemSettings } from "@/types/system-config"; + +const getCachedSystemSettingsMock = vi.mocked(getCachedSystemSettings); +const getEnvConfigMock = vi.mocked(getEnvConfig); + +function createSettings(overrides: Partial = {}): SystemSettings { + return { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableCodexSessionIdCompletion: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +function mockEnvConfig(tz = "Asia/Shanghai") { + getEnvConfigMock.mockReturnValue({ + NODE_ENV: "test", + TZ: tz, + PORT: 23000, + AUTO_MIGRATE: true, + ENABLE_RATE_LIMIT: true, + ENABLE_SECURE_COOKIES: true, + SESSION_TTL: 300, + STORE_SESSION_MESSAGES: false, + DEBUG_MODE: false, + LOG_LEVEL: "info", + ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS: false, + ENABLE_PROVIDER_CACHE: true, + MAX_RETRY_ATTEMPTS_DEFAULT: 2, + FETCH_BODY_TIMEOUT: 600000, + FETCH_HEADERS_TIMEOUT: 600000, + FETCH_CONNECT_TIMEOUT: 30000, + REDIS_TLS_REJECT_UNAUTHORIZED: true, + } as ReturnType); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveSystemTimezone", () => { + it("should return DB timezone when set and valid", async () => { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + + getCachedSystemSettingsMock.mockResolvedValue(createSettings({ timezone: "America/New_York" })); + mockEnvConfig("Asia/Shanghai"); + + const result = await resolveSystemTimezone(); + expect(result).toBe("America/New_York"); + }); + + it("should fallback to env TZ when DB timezone is null", async () => { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + + getCachedSystemSettingsMock.mockResolvedValue(createSettings({ timezone: null })); + mockEnvConfig("Europe/London"); + + const result = await resolveSystemTimezone(); + expect(result).toBe("Europe/London"); + }); + + it("should fallback to env TZ when DB timezone is invalid", async () => { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + + getCachedSystemSettingsMock.mockResolvedValue( + createSettings({ timezone: "Invalid/Timezone_Zone" }) + ); + mockEnvConfig("Asia/Tokyo"); + + const result = await resolveSystemTimezone(); + expect(result).toBe("Asia/Tokyo"); + }); + + it("should fallback to UTC when both DB timezone and env TZ are invalid", async () => { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + + getCachedSystemSettingsMock.mockResolvedValue(createSettings({ timezone: "Invalid/Zone" })); + // Empty string TZ won't pass isValidIANATimezone + mockEnvConfig(""); + + const result = await resolveSystemTimezone(); + expect(result).toBe("UTC"); + }); + + it("should fallback to UTC when getCachedSystemSettings throws", async () => { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + + getCachedSystemSettingsMock.mockRejectedValue(new Error("DB connection failed")); + mockEnvConfig("Asia/Shanghai"); + + const result = await resolveSystemTimezone(); + // Should still try env TZ fallback + expect(result).toBe("Asia/Shanghai"); + }); + + it("should fallback to UTC when getCachedSystemSettings throws and env TZ is empty", async () => { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + + getCachedSystemSettingsMock.mockRejectedValue(new Error("DB connection failed")); + mockEnvConfig(""); + + const result = await resolveSystemTimezone(); + expect(result).toBe("UTC"); + }); + + it("should handle empty string DB timezone as null", async () => { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + + getCachedSystemSettingsMock.mockResolvedValue( + createSettings({ timezone: "" as unknown as null }) + ); + mockEnvConfig("Europe/Paris"); + + const result = await resolveSystemTimezone(); + expect(result).toBe("Europe/Paris"); + }); +}); diff --git a/tests/unit/lib/utils/date-input.test.ts b/tests/unit/lib/utils/date-input.test.ts new file mode 100644 index 000000000..e24358542 --- /dev/null +++ b/tests/unit/lib/utils/date-input.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; + +describe("parseDateInputAsTimezone", () => { + describe("date-only input (YYYY-MM-DD)", () => { + it("should interpret date-only as end-of-day (23:59:59) in given timezone", () => { + // Input: "2024-12-31" in Asia/Shanghai (UTC+8) + // Expected: 2024-12-31 23:59:59 in Shanghai = 2024-12-31 15:59:59 UTC + const result = parseDateInputAsTimezone("2024-12-31", "Asia/Shanghai"); + + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(11); // December = 11 + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(15); // 23:59:59 Shanghai = 15:59:59 UTC + expect(result.getUTCMinutes()).toBe(59); + expect(result.getUTCSeconds()).toBe(59); + }); + + it("should handle UTC timezone correctly", () => { + // Input: "2024-06-15" in UTC + // Expected: 2024-06-15 23:59:59 UTC + const result = parseDateInputAsTimezone("2024-06-15", "UTC"); + + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(5); // June = 5 + expect(result.getUTCDate()).toBe(15); + expect(result.getUTCHours()).toBe(23); + expect(result.getUTCMinutes()).toBe(59); + expect(result.getUTCSeconds()).toBe(59); + }); + + it("should handle negative offset timezone (America/New_York)", () => { + // Input: "2024-07-04" in America/New_York (UTC-4 during DST) + // Expected: 2024-07-04 23:59:59 in NY = 2024-07-05 03:59:59 UTC + const result = parseDateInputAsTimezone("2024-07-04", "America/New_York"); + + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(6); // July = 6 + expect(result.getUTCDate()).toBe(5); // Next day in UTC + expect(result.getUTCHours()).toBe(3); // 23:59:59 NY (UTC-4) = 03:59:59 UTC next day + expect(result.getUTCMinutes()).toBe(59); + expect(result.getUTCSeconds()).toBe(59); + }); + + it("should handle date at year boundary", () => { + // Input: "2024-01-01" in Asia/Tokyo (UTC+9) + // Expected: 2024-01-01 23:59:59 in Tokyo = 2024-01-01 14:59:59 UTC + const result = parseDateInputAsTimezone("2024-01-01", "Asia/Tokyo"); + + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(0); // January = 0 + expect(result.getUTCDate()).toBe(1); + expect(result.getUTCHours()).toBe(14); // 23:59:59 Tokyo (UTC+9) = 14:59:59 UTC + }); + }); + + describe("ISO datetime input", () => { + it("should handle ISO datetime string", () => { + // Input: "2024-12-31T10:30:00" in Asia/Shanghai + // Expected: 2024-12-31 10:30:00 in Shanghai = 2024-12-31 02:30:00 UTC + const result = parseDateInputAsTimezone("2024-12-31T10:30:00", "Asia/Shanghai"); + + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(11); + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(2); // 10:30 Shanghai = 02:30 UTC + expect(result.getUTCMinutes()).toBe(30); + }); + + it("should handle ISO datetime with Z suffix - note: behavior depends on runtime TZ", () => { + // NOTE: Z-suffixed input is not a typical use case for this function. + // User input from date pickers typically doesn't include Z suffix. + // When Z suffix is present, new Date() parses it as UTC, but fromZonedTime + // reads the LOCAL time components (which depend on runtime timezone). + // + // For this reason, we recommend NOT using Z-suffixed input with this function. + // This test documents the behavior for awareness, not for correctness assertion. + const result = parseDateInputAsTimezone("2024-12-31T10:30:00Z", "Asia/Shanghai"); + + // Just verify it doesn't throw and returns a valid date + expect(result).toBeInstanceOf(Date); + expect(Number.isNaN(result.getTime())).toBe(false); + }); + }); + + describe("error handling", () => { + it("should throw for invalid date string", () => { + expect(() => parseDateInputAsTimezone("invalid-date", "UTC")).toThrow( + "Invalid date input: invalid-date" + ); + }); + + it("should throw for empty string", () => { + expect(() => parseDateInputAsTimezone("", "UTC")).toThrow(); + }); + }); + + describe("DST edge cases", () => { + it("should handle DST transition date in spring (America/New_York)", () => { + // March 10, 2024 is when DST starts in US (clocks spring forward at 2am) + // Input: "2024-03-10" in America/New_York + // Expected: 2024-03-10 23:59:59 in NY (UTC-4 after DST) = 2024-03-11 03:59:59 UTC + const result = parseDateInputAsTimezone("2024-03-10", "America/New_York"); + + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(2); // March = 2 + expect(result.getUTCDate()).toBe(11); // Next day in UTC + expect(result.getUTCHours()).toBe(3); // UTC-4 offset after DST + }); + + it("should handle DST transition date in fall (America/New_York)", () => { + // November 3, 2024 is when DST ends in US (clocks fall back at 2am) + // Input: "2024-11-03" in America/New_York + // Expected: 2024-11-03 23:59:59 in NY (UTC-5 after DST ends) = 2024-11-04 04:59:59 UTC + const result = parseDateInputAsTimezone("2024-11-03", "America/New_York"); + + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(10); // November = 10 + expect(result.getUTCDate()).toBe(4); // Next day in UTC + expect(result.getUTCHours()).toBe(4); // UTC-5 offset after DST ends + }); + }); +}); diff --git a/tests/unit/proxy/extract-usage-metrics.test.ts b/tests/unit/proxy/extract-usage-metrics.test.ts index 8b845cb09..1318a432d 100644 --- a/tests/unit/proxy/extract-usage-metrics.test.ts +++ b/tests/unit/proxy/extract-usage-metrics.test.ts @@ -341,6 +341,170 @@ describe("extractUsageMetrics", () => { // output_tokens = candidatesTokenCount + thoughtsTokenCount expect(result.usageMetrics?.output_tokens).toBe(600); }); + + it("应从 candidatesTokensDetails 提取 IMAGE modality tokens", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 326, + candidatesTokenCount: 2340, + candidatesTokensDetails: [ + { modality: "IMAGE", tokenCount: 2000 }, + { modality: "TEXT", tokenCount: 340 }, + ], + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + expect(result.usageMetrics?.output_image_tokens).toBe(2000); + expect(result.usageMetrics?.output_tokens).toBe(340); + }); + + it("应从 promptTokensDetails 提取 IMAGE modality tokens", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 886, + candidatesTokenCount: 500, + promptTokensDetails: [ + { modality: "TEXT", tokenCount: 326 }, + { modality: "IMAGE", tokenCount: 560 }, + ], + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + expect(result.usageMetrics?.input_image_tokens).toBe(560); + expect(result.usageMetrics?.input_tokens).toBe(326); + }); + + it("应正确解析混合输入输出的完整 usage", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 357, + candidatesTokenCount: 2100, + totalTokenCount: 2580, + promptTokensDetails: [ + { modality: "TEXT", tokenCount: 99 }, + { modality: "IMAGE", tokenCount: 258 }, + ], + candidatesTokensDetails: [{ modality: "IMAGE", tokenCount: 2000 }], + thoughtsTokenCount: 123, + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + expect(result.usageMetrics?.input_tokens).toBe(99); + expect(result.usageMetrics?.input_image_tokens).toBe(258); + // output_tokens = (candidatesTokenCount - IMAGE详情) + thoughtsTokenCount + // = (2100 - 2000) + 123 = 223 + expect(result.usageMetrics?.output_tokens).toBe(223); + expect(result.usageMetrics?.output_image_tokens).toBe(2000); + }); + + it("应处理只有 IMAGE modality 的 candidatesTokensDetails", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 2000, + candidatesTokensDetails: [{ modality: "IMAGE", tokenCount: 2000 }], + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + expect(result.usageMetrics?.output_image_tokens).toBe(2000); + // candidatesTokenCount = 2000, IMAGE = 2000, 未分类 = 0 + expect(result.usageMetrics?.output_tokens).toBe(0); + }); + + it("应计算 candidatesTokenCount 与 details 的差值作为未分类 TEXT", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 326, + candidatesTokenCount: 2340, + candidatesTokensDetails: [{ modality: "IMAGE", tokenCount: 2000 }], + thoughtsTokenCount: 337, + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + // 未分类 = 2340 - 2000 = 340 + // output_tokens = 340 + 337 (thoughts) = 677 + expect(result.usageMetrics?.output_tokens).toBe(677); + expect(result.usageMetrics?.output_image_tokens).toBe(2000); + }); + + it("应处理缺失 candidatesTokensDetails 的情况(向后兼容)", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 1000, + candidatesTokenCount: 500, + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + expect(result.usageMetrics?.output_tokens).toBe(500); + expect(result.usageMetrics?.output_image_tokens).toBeUndefined(); + expect(result.usageMetrics?.input_image_tokens).toBeUndefined(); + }); + + it("应处理空的 candidatesTokensDetails 数组", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 1000, + candidatesTokenCount: 500, + candidatesTokensDetails: [], + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + expect(result.usageMetrics?.output_tokens).toBe(500); + expect(result.usageMetrics?.output_image_tokens).toBeUndefined(); + }); + + it("应处理 candidatesTokensDetails 中无效 tokenCount 的情况", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 1000, + candidatesTokenCount: 500, + candidatesTokensDetails: [ + { modality: "TEXT" }, + { modality: "IMAGE", tokenCount: null }, + { modality: "TEXT", tokenCount: -1 }, + ], + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + // 无效数据不应覆盖原始 candidatesTokenCount + expect(result.usageMetrics?.output_tokens).toBe(500); + expect(result.usageMetrics?.output_image_tokens).toBeUndefined(); + }); + + it("应处理 modality 大小写变体", () => { + const response = JSON.stringify({ + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 2340, + candidatesTokensDetails: [ + { modality: "image", tokenCount: 2000 }, + { modality: "Image", tokenCount: 100 }, + { modality: "TEXT", tokenCount: 240 }, + ], + }, + }); + + const result = parseUsageFromResponseText(response, "gemini"); + + expect(result.usageMetrics?.output_image_tokens).toBe(2100); + expect(result.usageMetrics?.output_tokens).toBe(240); + }); }); describe("OpenAI Response API 格式", () => { diff --git a/tests/unit/proxy/pricing-no-price.test.ts b/tests/unit/proxy/pricing-no-price.test.ts index 46f8d247a..2dc34301c 100644 --- a/tests/unit/proxy/pricing-no-price.test.ts +++ b/tests/unit/proxy/pricing-no-price.test.ts @@ -78,6 +78,7 @@ function makeSystemSettings( allowGlobalUsageView: false, currencyDisplay: "USD", billingModelSource, + timezone: null, enableAutoCleanup: false, cleanupRetentionDays: 30, cleanupSchedule: "0 2 * * *", diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts index b247a1415..5afe30248 100644 --- a/tests/unit/proxy/session.test.ts +++ b/tests/unit/proxy/session.test.ts @@ -25,6 +25,7 @@ function makeSystemSettings( allowGlobalUsageView: false, currencyDisplay: "USD", billingModelSource, + timezone: null, enableAutoCleanup: false, cleanupRetentionDays: 30, cleanupSchedule: "0 2 * * *", diff --git a/tests/unit/repository/key-usage-token-overflow.test.ts b/tests/unit/repository/key-usage-token-overflow.test.ts new file mode 100644 index 000000000..e3bd6e215 --- /dev/null +++ b/tests/unit/repository/key-usage-token-overflow.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test, vi } from "vitest"; + +// 禁用 tests/setup.ts 中基于 DSN/Redis 的默认同步与清理协调,避免无关依赖引入。 +process.env.DSN = ""; +process.env.AUTO_CLEANUP_TEST_DATA = "false"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery(result: T) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.where = vi.fn(() => query); + query.groupBy = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + + return query; +} + +describe("Key usage token aggregation overflow", () => { + test("findKeyUsageTodayBatch: token sum 不应使用 ::int", async () => { + vi.resetModules(); + + const selectArgs: unknown[] = []; + const selectMock = vi.fn((selection: unknown) => { + selectArgs.push(selection); + return createThenableQuery([]); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + // 给 tests/setup.ts 的 afterAll 清理逻辑一个可用的 execute + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findKeyUsageTodayBatch } = await import("@/repository/key"); + await findKeyUsageTodayBatch([1]); + + expect(selectArgs).toHaveLength(1); + const selection = selectArgs[0] as Record; + const totalTokensSql = sqlToString(selection.totalTokens).toLowerCase(); + + expect(totalTokensSql).not.toContain("::int"); + expect(totalTokensSql).not.toContain("::int4"); + expect(totalTokensSql).toContain("double precision"); + }); + + test("findKeysWithStatisticsBatch: modelStats token sum 不应使用 ::int", async () => { + vi.resetModules(); + + const selectArgs: unknown[] = []; + const selectQueue: any[] = []; + + selectQueue.push( + createThenableQuery([ + { + id: 10, + userId: 1, + key: "k", + name: "n", + isEnabled: true, + expiresAt: null, + canLoginWebUi: true, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + providerGroup: null, + cacheTtlPreference: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + deletedAt: null, + }, + ]) + ); + selectQueue.push(createThenableQuery([])); + selectQueue.push(createThenableQuery([])); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn((selection: unknown) => { + selectArgs.push(selection); + return selectQueue.shift() ?? fallbackSelect; + }); + + const selectDistinctOnMock = vi.fn(() => createThenableQuery([])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + selectDistinctOn: selectDistinctOnMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findKeysWithStatisticsBatch } = await import("@/repository/key"); + await findKeysWithStatisticsBatch([1]); + + const selection = selectArgs.find((s): s is Record => { + if (!s || typeof s !== "object") return false; + return "inputTokens" in s && "cacheReadTokens" in s; + }); + expect(selection).toBeTruthy(); + + for (const field of ["inputTokens", "outputTokens", "cacheCreationTokens", "cacheReadTokens"]) { + const tokenSql = sqlToString(selection?.[field]).toLowerCase(); + expect(tokenSql).not.toContain("::int"); + expect(tokenSql).not.toContain("::int4"); + expect(tokenSql).toContain("double precision"); + } + }); +}); diff --git a/tests/unit/repository/provider-endpoints-display-name.test.ts b/tests/unit/repository/provider-endpoints-display-name.test.ts new file mode 100644 index 000000000..86223dbad --- /dev/null +++ b/tests/unit/repository/provider-endpoints-display-name.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { deriveDisplayNameFromDomain } from "@/repository/provider-endpoints"; + +describe("deriveDisplayNameFromDomain", () => { + test("uses second-level label before suffix", async () => { + expect(await deriveDisplayNameFromDomain("co.yes.vg")).toBe("Yes"); + }); + + test("keeps api prefix handling and capitalization", async () => { + expect(await deriveDisplayNameFromDomain("api.openai.com")).toBe("Openai"); + }); + + test("falls back to first label when single part", async () => { + expect(await deriveDisplayNameFromDomain("localhost")).toBe("Localhost"); + }); + + test("handles common API prefixes correctly", async () => { + expect(await deriveDisplayNameFromDomain("v1.api.anthropic.com")).toBe("Anthropic"); + expect(await deriveDisplayNameFromDomain("www.example.com")).toBe("Example"); + expect(await deriveDisplayNameFromDomain("api.anthropic.com")).toBe("Anthropic"); + }); + + test("handles standard domains without prefixes", async () => { + expect(await deriveDisplayNameFromDomain("anthropic.com")).toBe("Anthropic"); + expect(await deriveDisplayNameFromDomain("openai.com")).toBe("Openai"); + }); +}); diff --git a/tests/unit/repository/provider-endpoints-vendor-key.test.ts b/tests/unit/repository/provider-endpoints-vendor-key.test.ts new file mode 100644 index 000000000..dc5deef3c --- /dev/null +++ b/tests/unit/repository/provider-endpoints-vendor-key.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "vitest"; +import { computeVendorKey } from "@/repository/provider-endpoints"; + +describe("computeVendorKey", () => { + describe("with websiteUrl (priority over providerUrl)", () => { + test("returns hostname only, ignoring port", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://api.example.com:8080/v1/messages", + websiteUrl: "https://example.com:3000", + }) + ).toBe("example.com"); + }); + + test("strips www prefix", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://api.example.com", + websiteUrl: "https://www.example.com", + }) + ).toBe("example.com"); + }); + + test("lowercases hostname", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://api.Example.COM", + websiteUrl: "https://WWW.EXAMPLE.COM", + }) + ).toBe("example.com"); + }); + + test("handles websiteUrl without protocol", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://api.example.com", + websiteUrl: "example.com", + }) + ).toBe("example.com"); + }); + }); + + describe("without websiteUrl (fallback to providerUrl with host:port)", () => { + test("returns host:port for IP address", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://192.168.1.1:8080/v1/messages", + websiteUrl: null, + }) + ).toBe("192.168.1.1:8080"); + }); + + test("different ports create different keys", async () => { + const key1 = await computeVendorKey({ + providerUrl: "http://192.168.1.1:8080/v1/messages", + websiteUrl: null, + }); + const key2 = await computeVendorKey({ + providerUrl: "http://192.168.1.1:9090/v1/messages", + websiteUrl: null, + }); + expect(key1).toBe("192.168.1.1:8080"); + expect(key2).toBe("192.168.1.1:9090"); + expect(key1).not.toBe(key2); + }); + + test("uses default port 443 for https without explicit port", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://api.example.com/v1/messages", + websiteUrl: null, + }) + ).toBe("api.example.com:443"); + }); + + test("uses default port 80 for http without explicit port", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://api.example.com/v1/messages", + websiteUrl: null, + }) + ).toBe("api.example.com:80"); + }); + + test("assumes https (port 443) for URL without scheme", async () => { + expect( + await computeVendorKey({ + providerUrl: "api.example.com/v1/messages", + websiteUrl: null, + }) + ).toBe("api.example.com:443"); + }); + + test("strips www prefix in host:port mode", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://www.example.com:8080/v1/messages", + websiteUrl: null, + }) + ).toBe("example.com:8080"); + }); + + test("lowercases hostname in host:port mode", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://API.EXAMPLE.COM:8080/v1/messages", + websiteUrl: null, + }) + ).toBe("api.example.com:8080"); + }); + + test("handles localhost with port", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://localhost:3000/v1/messages", + websiteUrl: null, + }) + ).toBe("localhost:3000"); + }); + + test("handles localhost without explicit port", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://localhost/v1/messages", + websiteUrl: null, + }) + ).toBe("localhost:80"); + }); + }); + + describe("IPv6 addresses", () => { + test("formats IPv6 with brackets and port", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://[::1]:8080/v1/messages", + websiteUrl: null, + }) + ).toBe("[::1]:8080"); + }); + + test("handles IPv6 without explicit port", async () => { + expect( + await computeVendorKey({ + providerUrl: "https://[::1]/v1/messages", + websiteUrl: null, + }) + ).toBe("[::1]:443"); + }); + + test("handles full IPv6 address", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://[2001:db8::1]:9000/v1/messages", + websiteUrl: null, + }) + ).toBe("[2001:db8::1]:9000"); + }); + }); + + describe("edge cases", () => { + test("returns null for empty providerUrl", async () => { + expect( + await computeVendorKey({ + providerUrl: "", + websiteUrl: null, + }) + ).toBeNull(); + }); + + test("returns null for whitespace-only providerUrl", async () => { + expect( + await computeVendorKey({ + providerUrl: " ", + websiteUrl: null, + }) + ).toBeNull(); + }); + + test("uses providerUrl when websiteUrl is empty string", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://192.168.1.1:8080/v1/messages", + websiteUrl: "", + }) + ).toBe("192.168.1.1:8080"); + }); + + test("uses providerUrl when websiteUrl is whitespace", async () => { + expect( + await computeVendorKey({ + providerUrl: "http://192.168.1.1:8080/v1/messages", + websiteUrl: " ", + }) + ).toBe("192.168.1.1:8080"); + }); + + test("returns null for truly invalid URL", async () => { + expect( + await computeVendorKey({ + providerUrl: "://invalid", + websiteUrl: null, + }) + ).toBeNull(); + }); + }); +}); diff --git a/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx index 79dbe4ce9..289db7f62 100644 --- a/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx +++ b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx @@ -9,9 +9,14 @@ import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { ProviderVendorView } from "@/app/[locale]/settings/providers/_components/provider-vendor-view"; +import type { ProviderDisplay } from "@/types/provider"; import type { User } from "@/types/user"; import enMessages from "../../../../messages/en"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + const sonnerMocks = vi.hoisted(() => ({ toast: { success: vi.fn(), @@ -24,7 +29,7 @@ const providerEndpointsActionMocks = vi.hoisted(() => ({ addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })), editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })), getProviderEndpointProbeLogs: vi.fn(async () => ({ ok: true, data: { logs: [] } })), - getProviderEndpoints: vi.fn(async () => [ + getProviderEndpointsByVendor: vi.fn(async () => [ { id: 1, vendorId: 1, @@ -51,21 +56,9 @@ const providerEndpointsActionMocks = vi.hoisted(() => ({ updatedAt: "2026-01-01", }, ]), - getVendorTypeCircuitInfo: vi.fn(async () => ({ - ok: true, - data: { - vendorId: 1, - providerType: "claude", - circuitState: "open", - circuitOpenUntil: null, - lastFailureTime: null, - manualOpen: false, - }, - })), probeProviderEndpoint: vi.fn(async () => ({ ok: true, data: { result: { ok: true } } })), removeProviderEndpoint: vi.fn(async () => ({ ok: true })), removeProviderVendor: vi.fn(async () => ({ ok: true })), - resetVendorTypeCircuit: vi.fn(async () => ({ ok: true })), })); vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks); @@ -93,6 +86,61 @@ const ADMIN_USER: User = { isEnabled: true, }; +function makeProviderDisplay(overrides: Partial = {}): ProviderDisplay { + return { + id: 1, + name: "Provider A", + url: "https://api.example.com", + maskedKey: "sk-test", + isEnabled: true, + weight: 1, + priority: 1, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + providerVendorId: 1, + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + joinClaudePool: true, + codexInstructionsStrategy: "auto", + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 1, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 1, + circuitBreakerOpenDuration: 60, + circuitBreakerHalfOpenSuccessThreshold: 1, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 0, + streamingIdleTimeoutMs: 0, + requestTimeoutNonStreamingMs: 0, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + ...overrides, + }; +} + function loadMessages() { return { common: enMessages.common, @@ -136,7 +184,7 @@ async function flushTicks(times = 3) { } } -describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关闭按钮", () => { +describe("ProviderVendorView: Endpoints table renders with type icons", () => { beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { @@ -145,25 +193,15 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关 }, }); vi.clearAllMocks(); - document.body.innerHTML = ""; + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } }); - test("circuitState=open 时显示 Close Circuit,且不显示 Manually Open Circuit", async () => { - providerEndpointsActionMocks.getVendorTypeCircuitInfo.mockResolvedValueOnce({ - ok: true, - data: { - vendorId: 1, - providerType: "claude", - circuitState: "open", - circuitOpenUntil: null, - lastFailureTime: null, - manualOpen: false, - }, - }); - + test("renders endpoint URL and latency header", async () => { const { unmount } = renderWithProviders( { - providerEndpointsActionMocks.getVendorTypeCircuitInfo.mockResolvedValueOnce({ - ok: true, - data: { - vendorId: 1, - providerType: "claude", - circuitState: "closed", - circuitOpenUntil: null, - lastFailureTime: null, - manualOpen: false, + test("renders type column header", async () => { + const { unmount } = renderWithProviders( + + ); + + await flushTicks(6); + + // Check that type column header is present + expect(document.body.textContent || "").toContain("Type"); + + unmount(); + }); +}); + +describe("ProviderVendorView vendor list", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, }, }); + vi.clearAllMocks(); + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); + + test("vendors with zero providers are hidden", async () => { + providerEndpointsActionMocks.getProviderVendors.mockResolvedValueOnce([ + { + id: 1, + displayName: "Vendor A", + websiteDomain: "vendor.example", + websiteUrl: "https://vendor.example", + faviconUrl: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ]); const { unmount } = renderWithProviders( { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); + + test("renders endpoints and toggles enabled status", async () => { + const provider = makeProviderDisplay(); + const { unmount } = renderWithProviders( + + ); + + await flushTicks(6); + + expect(document.body.textContent || "").toContain("https://api.example.com/v1"); + + const endpointRow = Array.from(document.querySelectorAll("tr")).find((row) => + row.textContent?.includes("https://api.example.com/v1") + ); + expect(endpointRow).toBeDefined(); + + const switchEl = endpointRow?.querySelector("[data-slot='switch']"); + expect(switchEl).not.toBeNull(); + switchEl?.click(); + + await flushTicks(2); + + expect(providerEndpointsActionMocks.editProviderEndpoint).toHaveBeenCalledWith( + expect.objectContaining({ endpointId: 1, isEnabled: false }) + ); unmount(); }); diff --git a/tests/unit/validation/system-settings-quota-lease.test.ts b/tests/unit/validation/system-settings-quota-lease.test.ts new file mode 100644 index 000000000..654f419d5 --- /dev/null +++ b/tests/unit/validation/system-settings-quota-lease.test.ts @@ -0,0 +1,233 @@ +/** + * System Settings Quota Lease Validation Tests + * + * TDD: RED phase - tests for quota lease settings fields + */ + +import { describe, expect, test } from "vitest"; +import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; + +describe("UpdateSystemSettingsSchema: quota lease settings", () => { + describe("quotaDbRefreshIntervalSeconds", () => { + test("accepts valid refresh interval (10)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaDbRefreshIntervalSeconds: 10, + }); + expect(parsed.quotaDbRefreshIntervalSeconds).toBe(10); + }); + + test("accepts minimum value (1)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaDbRefreshIntervalSeconds: 1, + }); + expect(parsed.quotaDbRefreshIntervalSeconds).toBe(1); + }); + + test("accepts maximum value (300)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaDbRefreshIntervalSeconds: 300, + }); + expect(parsed.quotaDbRefreshIntervalSeconds).toBe(300); + }); + + test("rejects value below minimum (0)", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + quotaDbRefreshIntervalSeconds: 0, + }) + ).toThrow(); + }); + + test("rejects value above maximum (301)", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + quotaDbRefreshIntervalSeconds: 301, + }) + ).toThrow(); + }); + + test("rejects non-integer value", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + quotaDbRefreshIntervalSeconds: 10.5, + }) + ).toThrow(); + }); + }); + + describe("quotaLeasePercent5h", () => { + test("accepts valid percent (0.05)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeasePercent5h: 0.05, + }); + expect(parsed.quotaLeasePercent5h).toBe(0.05); + }); + + test("accepts minimum value (0)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeasePercent5h: 0, + }); + expect(parsed.quotaLeasePercent5h).toBe(0); + }); + + test("accepts maximum value (1)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeasePercent5h: 1, + }); + expect(parsed.quotaLeasePercent5h).toBe(1); + }); + + test("rejects value below minimum (-0.01)", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + quotaLeasePercent5h: -0.01, + }) + ).toThrow(); + }); + + test("rejects value above maximum (1.01)", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + quotaLeasePercent5h: 1.01, + }) + ).toThrow(); + }); + }); + + describe("quotaLeasePercentDaily", () => { + test("accepts valid percent (0.05)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeasePercentDaily: 0.05, + }); + expect(parsed.quotaLeasePercentDaily).toBe(0.05); + }); + + test("accepts edge values (0 and 1)", () => { + expect( + UpdateSystemSettingsSchema.parse({ quotaLeasePercentDaily: 0 }).quotaLeasePercentDaily + ).toBe(0); + expect( + UpdateSystemSettingsSchema.parse({ quotaLeasePercentDaily: 1 }).quotaLeasePercentDaily + ).toBe(1); + }); + + test("rejects out of range values", () => { + expect(() => UpdateSystemSettingsSchema.parse({ quotaLeasePercentDaily: -0.01 })).toThrow(); + expect(() => UpdateSystemSettingsSchema.parse({ quotaLeasePercentDaily: 1.01 })).toThrow(); + }); + }); + + describe("quotaLeasePercentWeekly", () => { + test("accepts valid percent (0.02)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeasePercentWeekly: 0.02, + }); + expect(parsed.quotaLeasePercentWeekly).toBe(0.02); + }); + + test("accepts edge values (0 and 1)", () => { + expect( + UpdateSystemSettingsSchema.parse({ quotaLeasePercentWeekly: 0 }).quotaLeasePercentWeekly + ).toBe(0); + expect( + UpdateSystemSettingsSchema.parse({ quotaLeasePercentWeekly: 1 }).quotaLeasePercentWeekly + ).toBe(1); + }); + + test("rejects out of range values", () => { + expect(() => UpdateSystemSettingsSchema.parse({ quotaLeasePercentWeekly: -0.01 })).toThrow(); + expect(() => UpdateSystemSettingsSchema.parse({ quotaLeasePercentWeekly: 1.01 })).toThrow(); + }); + }); + + describe("quotaLeasePercentMonthly", () => { + test("accepts valid percent (0.01)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeasePercentMonthly: 0.01, + }); + expect(parsed.quotaLeasePercentMonthly).toBe(0.01); + }); + + test("accepts edge values (0 and 1)", () => { + expect( + UpdateSystemSettingsSchema.parse({ quotaLeasePercentMonthly: 0 }).quotaLeasePercentMonthly + ).toBe(0); + expect( + UpdateSystemSettingsSchema.parse({ quotaLeasePercentMonthly: 1 }).quotaLeasePercentMonthly + ).toBe(1); + }); + + test("rejects out of range values", () => { + expect(() => UpdateSystemSettingsSchema.parse({ quotaLeasePercentMonthly: -0.01 })).toThrow(); + expect(() => UpdateSystemSettingsSchema.parse({ quotaLeasePercentMonthly: 1.01 })).toThrow(); + }); + }); + + describe("quotaLeaseCapUsd", () => { + test("accepts valid cap (3.0)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeaseCapUsd: 3.0, + }); + expect(parsed.quotaLeaseCapUsd).toBe(3.0); + }); + + test("accepts null (no cap)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeaseCapUsd: null, + }); + expect(parsed.quotaLeaseCapUsd).toBeNull(); + }); + + test("accepts zero (disabled)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeaseCapUsd: 0, + }); + expect(parsed.quotaLeaseCapUsd).toBe(0); + }); + + test("accepts high value (1000)", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaLeaseCapUsd: 1000, + }); + expect(parsed.quotaLeaseCapUsd).toBe(1000); + }); + + test("rejects negative value", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + quotaLeaseCapUsd: -1, + }) + ).toThrow(); + }); + }); + + describe("combined fields", () => { + test("accepts all quota lease fields together", () => { + const parsed = UpdateSystemSettingsSchema.parse({ + quotaDbRefreshIntervalSeconds: 60, + quotaLeasePercent5h: 0.1, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.02, + quotaLeasePercentMonthly: 0.01, + quotaLeaseCapUsd: 3.0, + }); + + expect(parsed.quotaDbRefreshIntervalSeconds).toBe(60); + expect(parsed.quotaLeasePercent5h).toBe(0.1); + expect(parsed.quotaLeasePercentDaily).toBe(0.05); + expect(parsed.quotaLeasePercentWeekly).toBe(0.02); + expect(parsed.quotaLeasePercentMonthly).toBe(0.01); + expect(parsed.quotaLeaseCapUsd).toBe(3.0); + }); + + test("all fields are optional", () => { + const parsed = UpdateSystemSettingsSchema.parse({}); + expect(parsed.quotaDbRefreshIntervalSeconds).toBeUndefined(); + expect(parsed.quotaLeasePercent5h).toBeUndefined(); + expect(parsed.quotaLeasePercentDaily).toBeUndefined(); + expect(parsed.quotaLeasePercentWeekly).toBeUndefined(); + expect(parsed.quotaLeasePercentMonthly).toBeUndefined(); + expect(parsed.quotaLeaseCapUsd).toBeUndefined(); + }); + }); +});