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/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/_journal.json b/drizzle/meta/_journal.json index 87303a2fd..f7a0913d7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1769008812140, "tag": "0056_tidy_quasar", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1769446927761, + "tag": "0057_conscious_quicksilver", + "breakpoints": true } ] } \ No newline at end of file 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/providers/strings.json b/messages/en/settings/providers/strings.json index 86e0cf807..52f1dc929 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", 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/providers/strings.json b/messages/ja/settings/providers/strings.json index 971f81fda..48b3615e1 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": "エンドポイントが設定されていません", 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/providers/strings.json b/messages/ru/settings/providers/strings.json index 090c117c6..866fbb074 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": "Эндпоинты не настроены", 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/providers/strings.json b/messages/zh-CN/settings/providers/strings.json index 56c3b257c..1634f5af6 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": "暂无端点配置", 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/providers/strings.json b/messages/zh-TW/settings/providers/strings.json index 3947b1d07..cf08088e1 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": "尚未設定端點", diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index b8da2b24d..a37056478 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -354,8 +354,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 +375,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( @@ -604,10 +604,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 +628,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/system-config.ts b/src/actions/system-config.ts index 02545989f..efbae74f3 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -2,7 +2,7 @@ import { revalidatePath } from "next/cache"; import { getSession } from "@/lib/auth"; -import { invalidateSystemSettingsCache } from "@/lib/config"; +import { getEnvConfig, invalidateSystemSettingsCache } from "@/lib/config"; import { logger } from "@/lib/logger"; import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; import { getSystemSettings, updateSystemSettings } from "@/repository/system-config"; @@ -24,6 +24,21 @@ export async function fetchSystemSettings(): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未授权" }; + } + + const { TZ } = getEnvConfig(); + return { ok: true, data: { timeZone: TZ } }; + } catch (error) { + logger.error("获取时区失败:", error); + return { ok: false, error: "获取时区失败" }; + } +} + export async function saveSystemSettings(formData: { // 所有字段均为可选,支持部分更新 siteTitle?: string; 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..6789ac1f5 100644 --- a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -29,7 +29,6 @@ export interface StatisticsChartCardProps { data: UserStatisticsData; onTimeRangeChange?: (timeRange: TimeRange) => void; currencyCode?: CurrencyCode; - colSpan?: 3 | 4; className?: string; } @@ -37,7 +36,6 @@ export function StatisticsChartCard({ data, onTimeRangeChange, currencyCode = "USD", - colSpan = 4, className, }: StatisticsChartCardProps) { const t = useTranslations("dashboard.statistics"); @@ -175,11 +173,7 @@ export function StatisticsChartCard({ }; 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/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/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..c6dbbe4b5 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,8 +43,8 @@ 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(() => { @@ -48,8 +56,8 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { 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 +65,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 +97,7 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { }); } }, - [displayEndClock, displayStartClock, filters, onFiltersChange] + [displayEndClock, displayStartClock, filters, onFiltersChange, serverTimeZone] ); const handleStartTimeChange = useCallback( @@ -89,14 +105,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 +121,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 +143,7 @@ export function TimeFilters({ filters, onFiltersChange }: TimeFiltersProps) { startDate={displayStartDate} endDate={displayEndDate} onDateRangeChange={handleDateRangeChange} + serverTimeZone={serverTimeZone} />
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/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/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 应用状态 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..1d50a554c 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -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`( diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index aab0a8471..9b786baad 100644 --- a/src/repository/provider-endpoints.ts +++ b/src/repository/provider-endpoints.ts @@ -231,9 +231,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); } @@ -299,7 +315,7 @@ export async function backfillProviderVendorsFromProviders(): Promise<{ } try { - const displayName = deriveDisplayNameFromDomain(domain); + const displayName = await deriveDisplayNameFromDomain(domain); const vendorId = await getOrCreateProviderVendorIdFromUrls({ providerUrl: row.url, websiteUrl: row.websiteUrl ?? null, 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..f05407668 --- /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.mockReturnValue({ + 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/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/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/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/settings/providers/provider-vendor-view-circuit-ui.test.tsx b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx index 79dbe4ce9..de7b2b396 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(), @@ -93,6 +98,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, @@ -163,7 +223,7 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关 const { unmount } = renderWithProviders( { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + 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( + + ); + + await flushTicks(6); + + expect(document.body.textContent || "").not.toContain("Vendor A"); + + unmount(); + }); +}); + +describe("ProviderVendorView endpoints table", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + 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(); + }); +});