diff --git a/drizzle/0088_amazing_energizer.sql b/drizzle/0088_amazing_energizer.sql new file mode 100644 index 000000000..4d0f8e6ae --- /dev/null +++ b/drizzle/0088_amazing_energizer.sql @@ -0,0 +1,4 @@ +-- Note: message_request is a high-write table. Standard CREATE INDEX may block writes during index creation. +-- Drizzle migrator does not support CREATE INDEX CONCURRENTLY. If write blocking is a concern, +-- manually pre-create this index with CONCURRENTLY before running this migration (IF NOT EXISTS prevents conflicts). +CREATE INDEX IF NOT EXISTS "idx_message_request_provider_created_at_finalized_active" ON "message_request" USING btree ("provider_id","created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND "message_request"."status_code" IS NOT NULL; diff --git a/drizzle/meta/0088_snapshot.json b/drizzle/meta/0088_snapshot.json new file mode 100644 index 000000000..54a1ac67a --- /dev/null +++ b/drizzle/meta/0088_snapshot.json @@ -0,0 +1,4045 @@ +{ + "id": "dae021f8-eb59-4ada-a30b-805aad37c932", + "prevId": "72847d01-0503-4979-a1c9-17a8e0cd93b6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "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 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT 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_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "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 + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "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": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "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'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "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 + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 549a95ad2..c3187481e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -617,6 +617,13 @@ "when": 1774678770349, "tag": "0087_nappy_lady_mastermind", "breakpoints": true + }, + { + "idx": 88, + "version": "7", + "when": 1776095902010, + "tag": "0088_amazing_energizer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index a889ac1a2..ecedb5284 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -3,17 +3,96 @@ * * GET /api/availability * Query parameters: - * - startTime: ISO string, start of query range (default: 24h ago) - * - endTime: ISO string, end of query range (default: now) + * - startTime: ISO string, start of query range (default: 24h ago, maximum span with endTime: 100 days) + * - endTime: ISO string, end of query range (default: now, maximum span with startTime: 100 days) * - providerIds: comma-separated provider IDs (default: all) - * - bucketSizeMinutes: number, time bucket size (default: auto) + * - bucketSizeMinutes: number, time bucket size (default: auto, min: 0.25, hard cap: 1440) * - includeDisabled: boolean, include disabled providers (default: false) - * - maxBuckets: number, max time buckets (default: 100) + * - maxBuckets: number, max non-empty time buckets per provider (default: 100, hard cap: 100) */ import { type NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; -import { type AvailabilityQueryOptions, queryProviderAvailability } from "@/lib/availability"; +import { + type AvailabilityQueryOptions, + AvailabilityQueryValidationError, + MAX_BUCKET_SIZE_MINUTES, + MAX_BUCKETS_HARD_LIMIT, + MIN_BUCKET_SIZE_MINUTES, + queryProviderAvailability, +} from "@/lib/availability"; + +function parseBooleanQueryParam(value: string, fieldName: string): boolean { + if (value === "true") return true; + if (value === "false") return false; + + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected true or false`); +} + +function parsePositiveIntegerQueryParam( + value: string, + fieldName: string, + maxValue?: number +): number { + const normalizedValue = value.trim(); + if (!/^\d+$/.test(normalizedValue)) { + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive integer`); + } + + const parsed = Number(normalizedValue); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive integer`); + } + + if (typeof maxValue === "number" && parsed > maxValue) { + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a positive integer not greater than ${maxValue}` + ); + } + + return parsed; +} + +function parsePositiveNumberQueryParam( + value: string, + fieldName: string, + bounds?: { + minValue?: number; + maxValue?: number; + } +): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new AvailabilityQueryValidationError(`Invalid ${fieldName}: expected a positive number`); + } + + if (typeof bounds?.minValue === "number" && parsed < bounds.minValue) { + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a positive number not less than ${bounds.minValue}` + ); + } + + if (typeof bounds?.maxValue === "number" && parsed > bounds.maxValue) { + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a positive number not greater than ${bounds.maxValue}` + ); + } + + return parsed; +} + +function parseProviderIdsQueryParam(value: string): number[] { + const tokens = value.split(",").map((token) => token.trim()); + if (tokens.some((token) => token.length === 0)) { + throw new AvailabilityQueryValidationError( + "Invalid providerIds: expected comma-separated positive integers" + ); + } + + const providerIds = tokens.map((token) => parsePositiveIntegerQueryParam(token, "providerIds")); + + return Array.from(new Set(providerIds)); +} /** * GET /api/availability @@ -32,45 +111,64 @@ export async function GET(request: NextRequest) { const options: AvailabilityQueryOptions = {}; const startTime = searchParams.get("startTime"); - if (startTime) { + if (startTime !== null) { + if (!startTime.trim()) { + throw new AvailabilityQueryValidationError( + "Invalid startTime: expected a valid Date or ISO timestamp" + ); + } options.startTime = startTime; } const endTime = searchParams.get("endTime"); - if (endTime) { + if (endTime !== null) { + if (!endTime.trim()) { + throw new AvailabilityQueryValidationError( + "Invalid endTime: expected a valid Date or ISO timestamp" + ); + } options.endTime = endTime; } const providerIds = searchParams.get("providerIds"); - if (providerIds) { - options.providerIds = providerIds - .split(",") - .map((id) => parseInt(id.trim(), 10)) - .filter((id) => !Number.isNaN(id)); + if (providerIds !== null) { + options.providerIds = parseProviderIdsQueryParam(providerIds); } const bucketSizeMinutes = searchParams.get("bucketSizeMinutes"); - if (bucketSizeMinutes) { - // Use parseFloat to support sub-minute bucket sizes (e.g., 0.25 for 15 seconds) - const parsed = parseFloat(bucketSizeMinutes); - // Ensure bucket size is valid and at least 0.25 minutes (15 seconds) to prevent division by zero - options.bucketSizeMinutes = Number.isNaN(parsed) ? 0.25 : Math.max(0.25, parsed); + if (bucketSizeMinutes !== null) { + options.bucketSizeMinutes = parsePositiveNumberQueryParam( + bucketSizeMinutes, + "bucketSizeMinutes", + { + minValue: MIN_BUCKET_SIZE_MINUTES, + maxValue: MAX_BUCKET_SIZE_MINUTES, + } + ); } const includeDisabled = searchParams.get("includeDisabled"); - if (includeDisabled) { - options.includeDisabled = includeDisabled === "true"; + if (includeDisabled !== null) { + options.includeDisabled = parseBooleanQueryParam(includeDisabled, "includeDisabled"); } const maxBuckets = searchParams.get("maxBuckets"); - if (maxBuckets) { - options.maxBuckets = parseInt(maxBuckets, 10); + if (maxBuckets !== null) { + options.maxBuckets = parsePositiveIntegerQueryParam( + maxBuckets, + "maxBuckets", + MAX_BUCKETS_HARD_LIMIT + ); } const result = await queryProviderAvailability(options); return NextResponse.json(result); } catch (error) { + if (error instanceof AvailabilityQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Availability API error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index b8887727b..f390ba631 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -511,6 +511,12 @@ export const messageRequest = pgTable('message_request', { messageRequestProviderCreatedAtActiveIdx: index('idx_message_request_provider_created_at_active') .on(table.providerId, table.createdAt) .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), + // #slow-query: availability 终态聚合热路径(provider + 时间范围 + status_code 已落库) + messageRequestProviderCreatedAtFinalizedActiveIdx: index( + 'idx_message_request_provider_created_at_finalized_active' + ) + .on(table.providerId, table.createdAt.desc()) + .where(sql`${table.deletedAt} IS NULL AND ${table.statusCode} IS NOT NULL`), // Session 查询索引(按 session 聚合查看对话) messageRequestSessionIdIdx: index('idx_message_request_session_id').on(table.sessionId).where(sql`${table.deletedAt} IS NULL`), // Session ID 前缀查询索引(LIKE 'prefix%',可稳定命中 B-tree) diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index 54e3fd658..a123d005e 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -4,10 +4,9 @@ * Simple two-tier status: success (green) or failure (red) */ -import { and, desc, eq, gte, inArray, isNull, lte } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, type SQLWrapper, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest, providers } from "@/drizzle/schema"; -import { logger } from "@/lib/logger"; import type { AvailabilityQueryOptions, AvailabilityQueryResult, @@ -17,37 +16,179 @@ import type { TimeBucketMetrics, } from "./types"; -// Maximum requests to load per query to prevent OOM -const MAX_REQUESTS_PER_QUERY = 100000; +type AggregatedAvailabilityBucketRow = { + providerId: number; + bucketStart: Date; + greenCount: number; + redCount: number; + latencyCount: number; + latencySumMs: number; + avgLatencyMs: number; + p50LatencyMs: number; + p95LatencyMs: number; + p99LatencyMs: number; + lastRequestAt: Date | null; +}; + +type AggregatedCurrentProviderStatusRow = { + providerId: number; + greenCount: number; + redCount: number; + lastRequestAt: Date | null; +}; + +export const MIN_BUCKET_SIZE_MINUTES = 0.25; +export const MAX_BUCKET_SIZE_MINUTES = 1440; +const DEFAULT_MAX_BUCKETS = 100; +const AVAILABILITY_SUCCESS_STATUS_CODE_MIN = 200; +const AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE = 400; +const FINALIZED_REQUEST_STATUS_CODE_ALIAS = "statusCode" as const; +const AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL = sql.raw( + String(AVAILABILITY_SUCCESS_STATUS_CODE_MIN) +); +const AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL = sql.raw( + String(AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE) +); +const FINALIZED_REQUEST_STATUS_CODE_SQL = sql.raw(`"${FINALIZED_REQUEST_STATUS_CODE_ALIAS}"`); +// Keep the hard cap independent from the UI/API default so future default tuning does not silently relax/tighten the guardrail. +// It intentionally equals the default today; the separation preserves distinct semantic roles for future tuning. +export const MAX_BUCKETS_HARD_LIMIT = 100; +const CURRENT_PROVIDER_STATUS_WINDOW_MINUTES = 15; +export const MAX_AVAILABILITY_QUERY_RANGE_DAYS = + (MAX_BUCKETS_HARD_LIMIT * MAX_BUCKET_SIZE_MINUTES) / (24 * 60); +const MAX_AVAILABILITY_QUERY_RANGE_MS = + MAX_BUCKETS_HARD_LIMIT * MAX_BUCKET_SIZE_MINUTES * 60 * 1000; + +export class AvailabilityQueryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "AvailabilityQueryValidationError"; + } +} /** - * Classify a single request's status - * Simple: success (2xx/3xx) = green, failure = red + * 当前版本把“已终态”收敛为 `statusCode` 已落库。 + * + * 已知限制:在当前异步写入/丢 patch 的极端场景,或未来新增了 `durationMs` / `errorMessage` + * 已落库、但 `statusCode` 仍为空且已稳定结束的写路径时,这些记录会被当前可用性统计排除。 + * 届时应引入独立的 finalized 谓词,而不是直接放宽为 `durationMs IS NOT NULL`。 */ -export function classifyRequestStatus(statusCode: number | null): RequestStatusClassification { - // No status code means network error or timeout - if (statusCode === null) { - return { - status: "red", - isSuccess: false, - isError: true, - }; +function buildAvailabilityFinalizedCondition() { + return isNotNull(messageRequest.statusCode); +} + +function assertValidDate(date: Date, fieldName: string): Date { + if (!Number.isFinite(date.getTime())) { + throw new AvailabilityQueryValidationError( + `Invalid ${fieldName}: expected a valid Date or ISO timestamp` + ); + } + + return date; +} + +function parseAvailabilityDate(value: Date | string, fieldName: string): Date { + return assertValidDate(typeof value === "string" ? new Date(value) : value, fieldName); +} + +function buildTimestampLowerBound( + column: typeof messageRequest.createdAt, + date: Date, + fieldName: string +) { + return sql`${column} >= CAST(${assertValidDate(date, fieldName).toISOString()} AS timestamptz)`; +} + +function buildTimestampUpperBound( + column: typeof messageRequest.createdAt, + date: Date, + fieldName: string +) { + return sql`${column} <= CAST(${assertValidDate(date, fieldName).toISOString()} AS timestamptz)`; +} + +function buildRelativeNowLowerBound(column: typeof messageRequest.createdAt, minutes: number) { + return sql`${column} >= NOW() - (${sql.raw(String(minutes))} * INTERVAL '1 minute')`; +} + +function buildNowUpperBound(column: typeof messageRequest.createdAt) { + return sql`${column} <= NOW()`; +} + +function buildAvailabilityRequestConditions(input: { + providerIds: number[]; + startDate: Date; + endDate?: Date; +}) { + const conditions = [ + inArray(messageRequest.providerId, input.providerIds), + buildTimestampLowerBound(messageRequest.createdAt, input.startDate, "startTime"), + isNull(messageRequest.deletedAt), + buildAvailabilityFinalizedCondition(), + ]; + + if (input.endDate) { + conditions.push(buildTimestampUpperBound(messageRequest.createdAt, input.endDate, "endTime")); } - // HTTP error (4xx/5xx) - if (statusCode >= 400) { + return and(...conditions); +} + +function toFiniteNumber(value: number | string | null | undefined): number { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toIsoString(value: Date | string | null | undefined): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function getTimeValue(value: Date | string | null | undefined): number { + if (!value) return 0; + const parsed = value instanceof Date ? value : new Date(value); + const timestamp = parsed.getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function isAvailabilitySuccessStatusCode(statusCode: number): boolean { + return ( + statusCode >= AVAILABILITY_SUCCESS_STATUS_CODE_MIN && + statusCode < AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE + ); +} + +function buildAvailabilitySuccessStatusCondition(statusCodeExpression: SQLWrapper) { + return sql`${statusCodeExpression} >= ${AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL} + AND ${statusCodeExpression} < ${AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL}`; +} + +function buildAvailabilityFailureStatusCondition(statusCodeExpression: SQLWrapper) { + return sql`(${statusCodeExpression} < ${AVAILABILITY_SUCCESS_STATUS_CODE_MIN_SQL} + OR ${statusCodeExpression} >= ${AVAILABILITY_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE_SQL})`; +} + +/** + * Classify a single finalized request's status + * Simple: success (2xx/3xx) = green, failure = red + */ +export function classifyRequestStatus(statusCode: number): RequestStatusClassification { + // 仅把 2xx/3xx 视为成功;1xx 不应在可用性里被计为绿色。 + if (isAvailabilitySuccessStatusCode(statusCode)) { return { - status: "red", - isSuccess: false, - isError: true, + status: "green", + isSuccess: true, + isError: false, }; } - // HTTP success (2xx/3xx) - all successful requests are green return { - status: "green", - isSuccess: true, - isError: false, + status: "red", + isSuccess: false, + isError: true, }; } @@ -62,21 +203,9 @@ export function calculateAvailabilityScore(greenCount: number, redCount: number) } /** - * Calculate percentile from sorted array + * Determine optimal time bucket size based on time range */ -function calculatePercentile(sortedValues: number[], percentile: number): number { - if (sortedValues.length === 0) return 0; - const index = Math.ceil((percentile / 100) * sortedValues.length) - 1; - return sortedValues[Math.max(0, Math.min(index, sortedValues.length - 1))]; -} - -/** - * Determine optimal time bucket size based on data density - */ -export function determineOptimalBucketSize( - _totalRequests: number, - timeRangeMinutes: number -): number { +export function determineOptimalBucketSize(timeRangeMinutes: number): number { // Target: 20-100 data points per time series for good visualization const targetBuckets = 50; const idealBucketMinutes = timeRangeMinutes / targetBuckets; @@ -93,6 +222,70 @@ export function determineOptimalBucketSize( return 1440; // Default to daily for very long ranges } +function sanitizeBucketSizeMinutes( + explicitBucketSize: number | undefined, + timeRangeMinutes: number, + maxBuckets: number +): number { + const fallbackBucketSize = determineOptimalBucketSize(timeRangeMinutes); + const safeFallbackBucketSize = + Number.isFinite(fallbackBucketSize) && fallbackBucketSize > 0 ? fallbackBucketSize : 60; + const minimumBudgetBucketSize = + timeRangeMinutes > 0 ? timeRangeMinutes / Math.max(1, maxBuckets) : MIN_BUCKET_SIZE_MINUTES; + const clampedMinimumBudgetBucketSize = Math.min( + MAX_BUCKET_SIZE_MINUTES, + Math.max(MIN_BUCKET_SIZE_MINUTES, minimumBudgetBucketSize) + ); + + if ( + typeof explicitBucketSize !== "number" || + !Number.isFinite(explicitBucketSize) || + explicitBucketSize <= 0 + ) { + return Math.min( + MAX_BUCKET_SIZE_MINUTES, + Math.max(MIN_BUCKET_SIZE_MINUTES, safeFallbackBucketSize, clampedMinimumBudgetBucketSize) + ); + } + + const normalizedExplicitBucketSize = Math.min( + MAX_BUCKET_SIZE_MINUTES, + Math.max(MIN_BUCKET_SIZE_MINUTES, explicitBucketSize) + ); + + if (timeRangeMinutes > normalizedExplicitBucketSize * maxBuckets) { + throw new AvailabilityQueryValidationError( + "Invalid bucket configuration: requested range exceeds the bucket budget implied by bucketSizeMinutes and maxBuckets" + ); + } + + return normalizedExplicitBucketSize; +} + +function sanitizeMaxBuckets(maxBuckets: number | undefined): number { + if (typeof maxBuckets !== "number" || !Number.isFinite(maxBuckets) || maxBuckets <= 0) { + return DEFAULT_MAX_BUCKETS; + } + + return Math.min(MAX_BUCKETS_HARD_LIMIT, Math.max(1, Math.floor(maxBuckets))); +} + +function validateAvailabilityTimeRange(startDate: Date, endDate: Date): void { + const rangeMs = endDate.getTime() - startDate.getTime(); + + if (rangeMs < 0) { + throw new AvailabilityQueryValidationError( + "Invalid time range: endTime must be greater than or equal to startTime" + ); + } + + if (rangeMs > MAX_AVAILABILITY_QUERY_RANGE_MS) { + throw new AvailabilityQueryValidationError( + `Invalid time range: requested range must not exceed ${MAX_AVAILABILITY_QUERY_RANGE_DAYS} days` + ); + } +} + /** * Query availability data for providers */ @@ -106,12 +299,22 @@ export async function queryProviderAvailability( providerIds = [], bucketSizeMinutes: explicitBucketSize, includeDisabled = false, - maxBuckets = 100, + maxBuckets = DEFAULT_MAX_BUCKETS, } = options; - const startDate = typeof startTime === "string" ? new Date(startTime) : startTime; - const endDate = typeof endTime === "string" ? new Date(endTime) : endTime; + // Apply defaults first so both implicit defaults and user-supplied values share the same parse/validation path. + const startDate = parseAvailabilityDate(startTime, "startTime"); + const endDate = parseAvailabilityDate(endTime, "endTime"); + validateAvailabilityTimeRange(startDate, endDate); const timeRangeMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); + const sanitizedMaxBuckets = sanitizeMaxBuckets(maxBuckets); + const bucketSizeMinutes = sanitizeBucketSizeMinutes( + explicitBucketSize, + timeRangeMinutes, + sanitizedMaxBuckets + ); + const bucketSizeMs = bucketSizeMinutes * 60 * 1000; + const bucketSizeSeconds = bucketSizeMinutes * 60; // Get provider list const providerConditions = [isNull(providers.deletedAt)]; @@ -137,164 +340,155 @@ export async function queryProviderAvailability( queriedAt: now.toISOString(), startTime: startDate.toISOString(), endTime: endDate.toISOString(), - bucketSizeMinutes: explicitBucketSize ?? 60, + bucketSizeMinutes, providers: [], systemAvailability: 0, }; } - const providerIdList = providerList.map((p) => p.id); - - // Query raw request data - const requestConditions = [ - inArray(messageRequest.providerId, providerIdList), - gte(messageRequest.createdAt, startDate), - lte(messageRequest.createdAt, endDate), - isNull(messageRequest.deletedAt), - ]; - - const requests = await db - .select({ - id: messageRequest.id, - providerId: messageRequest.providerId, - statusCode: messageRequest.statusCode, - durationMs: messageRequest.durationMs, - errorMessage: messageRequest.errorMessage, - createdAt: messageRequest.createdAt, - }) - .from(messageRequest) - .where(and(...requestConditions)) - .orderBy(messageRequest.createdAt) - .limit(MAX_REQUESTS_PER_QUERY); - - // Warn if query hit the limit - results may be incomplete - if (requests.length === MAX_REQUESTS_PER_QUERY) { - logger.warn("[Availability] Query hit max request limit, results may be incomplete", { - limit: MAX_REQUESTS_PER_QUERY, - startTime: startDate.toISOString(), - endTime: endDate.toISOString(), - }); - } - - // Determine bucket size if not explicitly specified - // Ensure minimum bucket size of 0.25 minutes (15 seconds) to prevent division by zero - // Handle NaN case (nullish coalescing doesn't catch NaN from invalid parseFloat input) - const rawBucketSize = - explicitBucketSize ?? determineOptimalBucketSize(requests.length, timeRangeMinutes); - const bucketSizeMinutes = Number.isNaN(rawBucketSize) - ? determineOptimalBucketSize(requests.length, timeRangeMinutes) - : Math.max(0.25, rawBucketSize); - const bucketSizeMs = bucketSizeMinutes * 60 * 1000; + const providerIdList = providerList.map((provider) => provider.id); + const requestConditions = buildAvailabilityRequestConditions({ + providerIds: providerIdList, + startDate, + endDate, + }); - // Group requests by provider and time bucket - const providerBuckets = new Map< - number, - Map< - string, - { - greenCount: number; - redCount: number; - latencies: number[]; - } - > - >(); + const availabilityAggregationCtes = sql` + finalized_requests AS ( + SELECT + ${messageRequest.providerId} AS "providerId", + ${messageRequest.createdAt} AS "createdAt", + ${messageRequest.statusCode} AS ${FINALIZED_REQUEST_STATUS_CODE_SQL}, + ${messageRequest.durationMs} AS "durationMs", + to_timestamp( + floor(extract(epoch from ${messageRequest.createdAt}) / ${bucketSizeSeconds}) * ${bucketSizeSeconds} + ) AS "bucketStart" + FROM ${messageRequest} + WHERE ${requestConditions} + ), + provider_bucket_stats AS ( + SELECT + "providerId", + "bucketStart", + COUNT(*) FILTER (WHERE ${buildAvailabilitySuccessStatusCondition(FINALIZED_REQUEST_STATUS_CODE_SQL)})::int AS "greenCount", + COUNT(*) FILTER (WHERE ${buildAvailabilityFailureStatusCondition(FINALIZED_REQUEST_STATUS_CODE_SQL)})::int AS "redCount", + COUNT("durationMs")::int AS "latencyCount", + COALESCE(SUM("durationMs")::double precision, 0) AS "latencySumMs", + COALESCE(AVG("durationMs")::double precision, 0) AS "avgLatencyMs", + COALESCE( + percentile_cont(0.5) WITHIN GROUP (ORDER BY "durationMs"::double precision) + FILTER (WHERE "durationMs" IS NOT NULL), + 0 + )::double precision AS "p50LatencyMs", + COALESCE( + percentile_cont(0.95) WITHIN GROUP (ORDER BY "durationMs"::double precision) + FILTER (WHERE "durationMs" IS NOT NULL), + 0 + )::double precision AS "p95LatencyMs", + COALESCE( + percentile_cont(0.99) WITHIN GROUP (ORDER BY "durationMs"::double precision) + FILTER (WHERE "durationMs" IS NOT NULL), + 0 + )::double precision AS "p99LatencyMs", + MAX("createdAt") AS "lastRequestAt" + FROM finalized_requests + GROUP BY "providerId", "bucketStart" + ) + `; + + const bucketQuery = sql` + WITH + ${availabilityAggregationCtes}, + limited_provider_bucket_stats AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY "providerId" ORDER BY "bucketStart" DESC) AS rn + FROM provider_bucket_stats + ) + SELECT + "providerId", + "bucketStart", + "greenCount", + "redCount", + "latencyCount", + "latencySumMs", + "avgLatencyMs", + "p50LatencyMs", + "p95LatencyMs", + "p99LatencyMs", + "lastRequestAt" + FROM limited_provider_bucket_stats + WHERE rn <= ${sanitizedMaxBuckets} + ORDER BY "providerId" ASC, "bucketStart" ASC + `; + + const bucketRows = Array.from(await db.execute(bucketQuery)) as AggregatedAvailabilityBucketRow[]; + const providerBuckets = new Map(); - // Initialize provider buckets for (const provider of providerList) { - providerBuckets.set(provider.id, new Map()); + providerBuckets.set(provider.id, []); } - // Process requests - for (const req of requests) { - if (!req.createdAt) continue; - - const bucketStart = new Date(Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs); - const bucketKey = bucketStart.toISOString(); - - const providerData = providerBuckets.get(req.providerId); - if (!providerData) continue; - - if (!providerData.has(bucketKey)) { - providerData.set(bucketKey, { - greenCount: 0, - redCount: 0, - latencies: [], - }); - } - - const bucket = providerData.get(bucketKey)!; - const classification = classifyRequestStatus(req.statusCode); - - if (classification.status === "green") { - bucket.greenCount++; - } else { - bucket.redCount++; - } - - if (req.durationMs !== null) { - bucket.latencies.push(req.durationMs); - } + for (const row of bucketRows) { + providerBuckets.get(row.providerId)?.push(row); } // Build provider summaries const providerSummaries: ProviderAvailabilitySummary[] = []; for (const provider of providerList) { - const bucketData = providerBuckets.get(provider.id)!; + const bucketRowsForProvider = providerBuckets.get(provider.id) ?? []; const timeBuckets: TimeBucketMetrics[] = []; let totalGreen = 0; let totalRed = 0; - const allLatencies: number[] = []; - let lastRequestAt: string | null = null; - - // Sort buckets by time and limit - const sortedBucketKeys = Array.from(bucketData.keys()).sort().slice(-maxBuckets); - - for (const bucketKey of sortedBucketKeys) { - const bucket = bucketData.get(bucketKey)!; - const bucketStart = new Date(bucketKey); + let totalLatencyCount = 0; + let totalLatencySumMs = 0; + let lastRequestAtTime = 0; + + for (const bucket of bucketRowsForProvider) { + const greenCount = toFiniteNumber(bucket.greenCount); + const redCount = toFiniteNumber(bucket.redCount); + const totalRequests = greenCount + redCount; + const latencyCount = toFiniteNumber(bucket.latencyCount); + const latencySumMs = toFiniteNumber(bucket.latencySumMs); + + totalGreen += greenCount; + totalRed += redCount; + totalLatencyCount += latencyCount; + totalLatencySumMs += latencySumMs; + lastRequestAtTime = Math.max(lastRequestAtTime, getTimeValue(bucket.lastRequestAt)); + + const bucketStart = new Date(bucket.bucketStart); const bucketEnd = new Date(bucketStart.getTime() + bucketSizeMs); - totalGreen += bucket.greenCount; - totalRed += bucket.redCount; - allLatencies.push(...bucket.latencies); - - const sortedLatencies = [...bucket.latencies].sort((a, b) => a - b); - const total = bucket.greenCount + bucket.redCount; - timeBuckets.push({ bucketStart: bucketStart.toISOString(), bucketEnd: bucketEnd.toISOString(), - totalRequests: total, - greenCount: bucket.greenCount, - redCount: bucket.redCount, - availabilityScore: calculateAvailabilityScore(bucket.greenCount, bucket.redCount), - avgLatencyMs: - sortedLatencies.length > 0 - ? sortedLatencies.reduce((a, b) => a + b, 0) / sortedLatencies.length - : 0, - p50LatencyMs: calculatePercentile(sortedLatencies, 50), - p95LatencyMs: calculatePercentile(sortedLatencies, 95), - p99LatencyMs: calculatePercentile(sortedLatencies, 99), + totalRequests, + greenCount, + redCount, + availabilityScore: calculateAvailabilityScore(greenCount, redCount), + avgLatencyMs: toFiniteNumber(bucket.avgLatencyMs), + p50LatencyMs: toFiniteNumber(bucket.p50LatencyMs), + p95LatencyMs: toFiniteNumber(bucket.p95LatencyMs), + p99LatencyMs: toFiniteNumber(bucket.p99LatencyMs), }); - - // Track last request time - if (bucket.latencies.length > 0) { - lastRequestAt = bucketEnd.toISOString(); - } } const totalRequests = totalGreen + totalRed; - const sortedAllLatencies = allLatencies.sort((a, b) => a - b); + const returnedBucketAvailability = calculateAvailabilityScore(totalGreen, totalRed); - // Determine current status based on last few buckets + // Determine current status from the most recent returned buckets. + // Because older non-empty buckets may already be trimmed by maxBuckets, + // this intentionally reflects the truncated tail window rather than the full query range. // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. let currentStatus: AvailabilityStatus = "unknown"; if (timeBuckets.length > 0) { const recentBuckets = timeBuckets.slice(-3); // Last 3 buckets - const recentScore = - recentBuckets.reduce((sum, b) => sum + b.availabilityScore, 0) / recentBuckets.length; + const recentGreen = recentBuckets.reduce((sum, bucket) => sum + bucket.greenCount, 0); + const recentRed = recentBuckets.reduce((sum, bucket) => sum + bucket.redCount, 0); + const recentScore = calculateAvailabilityScore(recentGreen, recentRed); // Simple: >= 50% success = green, otherwise red currentStatus = recentScore >= 0.5 ? "green" : "red"; @@ -306,24 +500,27 @@ export async function queryProviderAvailability( providerType: provider.providerType ?? "claude", isEnabled: provider.enabled ?? true, currentStatus, - currentAvailability: calculateAvailabilityScore(totalGreen, totalRed), + currentAvailability: returnedBucketAvailability, totalRequests, - successRate: totalRequests > 0 ? totalGreen / totalRequests : 0, - avgLatencyMs: - sortedAllLatencies.length > 0 - ? sortedAllLatencies.reduce((a, b) => a + b, 0) / sortedAllLatencies.length - : 0, - lastRequestAt, + // Keep `successRate` as a compatibility alias of the returned-bucket availability ratio. + successRate: returnedBucketAvailability, + avgLatencyMs: totalLatencyCount > 0 ? totalLatencySumMs / totalLatencyCount : 0, + lastRequestAt: lastRequestAtTime > 0 ? new Date(lastRequestAtTime).toISOString() : null, timeBuckets, }); } - // Calculate system-wide availability - const totalSystemRequests = providerSummaries.reduce((sum, p) => sum + p.totalRequests, 0); + // Calculate system-wide availability from the buckets returned after per-provider trimming. + const totalSystemRequests = providerSummaries.reduce( + (sum, provider) => sum + provider.totalRequests, + 0 + ); const weightedSystemAvailability = totalSystemRequests > 0 - ? providerSummaries.reduce((sum, p) => sum + p.currentAvailability * p.totalRequests, 0) / - totalSystemRequests + ? providerSummaries.reduce( + (sum, provider) => sum + provider.currentAvailability * provider.totalRequests, + 0 + ) / totalSystemRequests : 0; return { @@ -349,10 +546,6 @@ export async function getCurrentProviderStatus(): Promise< lastRequestAt: string | null; }> > { - // Query last 15 minutes of data for current status - const now = new Date(); - const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000); - // Get enabled providers const providerList = await db .select({ @@ -366,27 +559,29 @@ export async function getCurrentProviderStatus(): Promise< return []; } - const providerIdList = providerList.map((p) => p.id); - - // Query recent requests - const requests = await db - .select({ - providerId: messageRequest.providerId, - statusCode: messageRequest.statusCode, - durationMs: messageRequest.durationMs, - createdAt: messageRequest.createdAt, - }) - .from(messageRequest) - .where( - and( - inArray(messageRequest.providerId, providerIdList), - gte(messageRequest.createdAt, fifteenMinutesAgo), - isNull(messageRequest.deletedAt) - ) - ) - .orderBy(desc(messageRequest.createdAt)); - - // Aggregate by provider + const providerIdList = providerList.map((provider) => provider.id); + const requestConditions = and( + inArray(messageRequest.providerId, providerIdList), + buildRelativeNowLowerBound(messageRequest.createdAt, CURRENT_PROVIDER_STATUS_WINDOW_MINUTES), + buildNowUpperBound(messageRequest.createdAt), + isNull(messageRequest.deletedAt), + buildAvailabilityFinalizedCondition() + ); + + const aggregateQuery = sql` + SELECT + ${messageRequest.providerId} AS "providerId", + COUNT(*) FILTER (WHERE ${buildAvailabilitySuccessStatusCondition(messageRequest.statusCode)})::int AS "greenCount", + COUNT(*) FILTER (WHERE ${buildAvailabilityFailureStatusCondition(messageRequest.statusCode)})::int AS "redCount", + MAX(${messageRequest.createdAt}) AS "lastRequestAt" + FROM ${messageRequest} + WHERE ${requestConditions} + GROUP BY ${messageRequest.providerId} + `; + + const aggregateRows = Array.from( + await db.execute(aggregateQuery) + ) as AggregatedCurrentProviderStatusRow[]; const providerStats = new Map< number, { @@ -404,21 +599,12 @@ export async function getCurrentProviderStatus(): Promise< }); } - for (const req of requests) { - const stats = providerStats.get(req.providerId); - if (!stats) continue; - - const classification = classifyRequestStatus(req.statusCode); - - if (classification.status === "green") { - stats.greenCount++; - } else { - stats.redCount++; - } - - if (!stats.lastRequestAt && req.createdAt) { - stats.lastRequestAt = req.createdAt.toISOString(); - } + for (const row of aggregateRows) { + providerStats.set(row.providerId, { + greenCount: toFiniteNumber(row.greenCount), + redCount: toFiniteNumber(row.redCount), + lastRequestAt: toIsoString(row.lastRequestAt), + }); } return providerList.map((provider) => { diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts index afb65c59c..b795312ee 100644 --- a/src/lib/availability/index.ts +++ b/src/lib/availability/index.ts @@ -2,21 +2,27 @@ * Provider Availability Module * * This module provides availability monitoring based on request log data. - * Simple two-tier validation: success or failure. + * Availability is calculated only from finalized requests that already have a persisted + * `statusCode`. In-flight / intermediate records are excluded upstream. * - * 1. HTTP Status Check: 2xx/3xx = success (green), 4xx/5xx or error = failure (red) + * 1. HTTP Status Check: 2xx/3xx = success (green), other finalized HTTP status codes = failure (red) * * Availability scoring: * - GREEN (1.0): Successful requests (any HTTP 2xx/3xx) - * - RED (0.0): Failed requests (HTTP 4xx/5xx or network error) + * - RED (0.0): Failed finalized requests (non-2xx/3xx HTTP status codes) * - UNKNOWN: No data available */ export { + AvailabilityQueryValidationError, calculateAvailabilityScore, classifyRequestStatus, determineOptimalBucketSize, getCurrentProviderStatus, + MAX_AVAILABILITY_QUERY_RANGE_DAYS, + MAX_BUCKET_SIZE_MINUTES, + MAX_BUCKETS_HARD_LIMIT, + MIN_BUCKET_SIZE_MINUTES, queryProviderAvailability, } from "./availability-service"; export * from "./types"; diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts index 895c630fd..281aa9e36 100644 --- a/src/lib/availability/types.ts +++ b/src/lib/availability/types.ts @@ -6,7 +6,7 @@ /** * Status values for availability calculation * - GREEN (1.0): HTTP 2xx/3xx (all successful requests) - * - RED (0.0): HTTP 4xx/5xx or error + * - RED (0.0): finalized requests with non-2xx/3xx HTTP status codes * - UNKNOWN (-1): No data available (must be displayed honestly as "no data") */ export type AvailabilityStatus = "green" | "red" | "unknown"; @@ -57,7 +57,7 @@ export interface TimeBucketMetrics { totalRequests: number; /** Successful requests (2xx/3xx) */ greenCount: number; - /** Failed requests (4xx/5xx or error) */ + /** Failed finalized requests (non-2xx/3xx status codes) */ redCount: number; /** Weighted availability score (0.0-1.0) */ availabilityScore: number; @@ -83,15 +83,15 @@ export interface ProviderAvailabilitySummary { providerType: string; /** Whether provider is enabled */ isEnabled: boolean; - /** Current status based on recent requests */ + /** Current status based on the most recent returned buckets */ currentStatus: AvailabilityStatus; - /** Current weighted availability (0.0-1.0) */ + /** Availability ratio over the returned time buckets (currently kept equal to successRate for compatibility) */ currentAvailability: number; - /** Total request count in period */ + /** Total finalized request count represented by the returned time buckets */ totalRequests: number; - /** Success rate (green requests / total) */ + /** Compatibility alias of currentAvailability over the returned time buckets (green requests / total) */ successRate: number; - /** Average latency in ms */ + /** Average latency in ms over the returned time buckets */ avgLatencyMs: number; /** Last request timestamp */ lastRequestAt: string | null; @@ -103,17 +103,20 @@ export interface ProviderAvailabilitySummary { * Availability query options */ export interface AvailabilityQueryOptions { - /** Start time for query (ISO string or Date) */ + /** Start time for query (ISO string or Date, maximum span with endTime is 100 days) */ startTime?: string | Date; - /** End time for query (ISO string or Date) */ + /** End time for query (ISO string or Date, maximum span with startTime is 100 days) */ endTime?: string | Date; /** Provider IDs to filter (empty = all providers) */ providerIds?: number[]; - /** Time bucket size in minutes */ + /** Time bucket size in minutes (minimum 0.25, hard capped at 1440) */ bucketSizeMinutes?: number; /** Whether to include disabled providers */ includeDisabled?: boolean; - /** Maximum number of time buckets to return */ + /** + * Maximum number of non-empty time buckets to return per provider (hard capped at 100). + * Summary metrics in the response only reflect the returned buckets after this trimming. + */ maxBuckets?: number; } @@ -131,31 +134,9 @@ export interface AvailabilityQueryResult { bucketSizeMinutes: number; /** Provider summaries */ providers: ProviderAvailabilitySummary[]; - /** Overall system availability (weighted average) */ + /** + * Overall system availability weighted over the returned provider buckets. + * When maxBuckets trims older non-empty buckets, this may reflect a truncated sub-window. + */ systemAvailability: number; } - -/** - * Raw request data from database - */ -export interface RawRequestData { - id: number; - providerId: number; - statusCode: number | null; - durationMs: number | null; - errorMessage: string | null; - createdAt: Date | null; -} - -/** - * Aggregated bucket data from database - */ -export interface AggregatedBucketData { - providerId: number; - bucketStart: Date; - totalRequests: number; - greenCount: number; - redCount: number; - avgLatencyMs: number; - latencies: number[]; -} diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index d2f690189..dfa52709e 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -150,6 +150,18 @@ function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null `; } +function getPatchRetentionPriority(patch: MessageRequestUpdatePatch): number { + if (patch.statusCode !== undefined) { + return 3; + } + + if (patch.durationMs !== undefined) { + return 2; + } + + return 1; +} + class MessageRequestWriteBuffer { private readonly config: WriterConfig; private readonly pending = new Map(); @@ -176,15 +188,19 @@ class MessageRequestWriteBuffer { // 队列上限保护:DB 异常时避免无限增长导致 OOM if (this.pending.size > this.config.maxPending) { - // 优先丢弃非“终态”更新(没有 durationMs 的条目),尽量保留请求完成信息 + // 优先保留更接近终态的 patch: + // statusCode > durationMs > metadata-only + // 这样 Gemini passthrough 等 statusCode-only 终态更新不会比 duration-only 更容易被丢弃。 let droppedId: number | undefined; let droppedPatch: MessageRequestUpdatePatch | undefined; + let lowestPriority = Number.POSITIVE_INFINITY; for (const [candidateId, candidatePatch] of this.pending) { - if (candidatePatch.durationMs === undefined) { + const priority = getPatchRetentionPriority(candidatePatch); + if (priority < lowestPriority) { + lowestPriority = priority; droppedId = candidateId; droppedPatch = candidatePatch; - break; } } @@ -203,7 +219,9 @@ class MessageRequestWriteBuffer { logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { maxPending: this.config.maxPending, droppedId, + droppedPriority: lowestPriority, droppedHasDurationMs: droppedPatch?.durationMs !== undefined, + droppedHasStatusCode: droppedPatch?.statusCode !== undefined, currentPending: this.pending.size, }); } diff --git a/tests/unit/api/availability-route.test.ts b/tests/unit/api/availability-route.test.ts new file mode 100644 index 000000000..c346bfe19 --- /dev/null +++ b/tests/unit/api/availability-route.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockGetSession = vi.hoisted(() => vi.fn()); +const mockQueryProviderAvailability = vi.hoisted(() => vi.fn()); +const MockAvailabilityQueryValidationError = vi.hoisted( + () => + class AvailabilityQueryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "AvailabilityQueryValidationError"; + } + } +); + +vi.mock("@/lib/auth", () => ({ + getSession: mockGetSession, +})); + +vi.mock("@/lib/availability", () => ({ + AvailabilityQueryValidationError: MockAvailabilityQueryValidationError, + MIN_BUCKET_SIZE_MINUTES: 0.25, + MAX_BUCKETS_HARD_LIMIT: 100, + MAX_BUCKET_SIZE_MINUTES: 1440, + queryProviderAvailability: mockQueryProviderAvailability, +})); + +function makeRequest(query = ""): NextRequest { + const suffix = query ? `?${query}` : ""; + return new NextRequest(`http://localhost/api/availability${suffix}`); +} + +describe("GET /api/availability", () => { + let GET: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mockGetSession.mockResolvedValue({ + user: { + id: 1, + role: "admin", + }, + }); + mockQueryProviderAvailability.mockResolvedValue({ + queriedAt: "2026-04-13T09:00:00.000Z", + startTime: "2026-04-13T08:00:00.000Z", + endTime: "2026-04-13T09:00:00.000Z", + bucketSizeMinutes: 5, + providers: [], + systemAvailability: 0, + }); + + const mod = await import("@/app/api/availability/route"); + GET = mod.GET; + }); + + it("未认证时返回 401", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Unauthorized" }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("参数合法时将规范化后的查询参数传给 service", async () => { + const res = await GET( + makeRequest( + [ + "startTime=2026-04-13T08:00:00.000Z", + "endTime=2026-04-13T09:00:00.000Z", + "providerIds=2,1,2", + "bucketSizeMinutes=0.5", + "includeDisabled=true", + "maxBuckets=60", + ].join("&") + ) + ); + + expect(res.status).toBe(200); + expect(mockQueryProviderAvailability).toHaveBeenCalledTimes(1); + expect(mockQueryProviderAvailability).toHaveBeenCalledWith({ + startTime: "2026-04-13T08:00:00.000Z", + endTime: "2026-04-13T09:00:00.000Z", + providerIds: [2, 1], + bucketSizeMinutes: 0.5, + includeDisabled: true, + maxBuckets: 60, + }); + }); + + it("providerIds 非法时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("providerIds=1,foo")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid providerIds: expected a positive integer", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("providerIds 存在空 token 时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("providerIds=1,,2")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid providerIds: expected comma-separated positive integers", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("includeDisabled 非法时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("includeDisabled=yes")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid includeDisabled: expected true or false", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("bucketSizeMinutes 为 Infinity 时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("bucketSizeMinutes=Infinity")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid bucketSizeMinutes: expected a positive number", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("bucketSizeMinutes 低于最小值时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("bucketSizeMinutes=0.001")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid bucketSizeMinutes: expected a positive number not less than 0.25", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("bucketSizeMinutes 超过硬上限时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("bucketSizeMinutes=1441")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid bucketSizeMinutes: expected a positive number not greater than 1440", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("maxBuckets 超过硬上限时返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("maxBuckets=101")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid maxBuckets: expected a positive integer not greater than 100", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("空的 startTime 参数返回 400 且不访问 service", async () => { + const res = await GET(makeRequest("startTime=")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid startTime: expected a valid Date or ISO timestamp", + }); + expect(mockQueryProviderAvailability).not.toHaveBeenCalled(); + }); + + it("service 抛出参数校验错误时映射为 400", async () => { + mockQueryProviderAvailability.mockRejectedValueOnce( + new MockAvailabilityQueryValidationError( + "Invalid time range: endTime must be greater than or equal to startTime" + ) + ); + + const res = await GET( + makeRequest("startTime=2026-04-13T09:00:00.000Z&endTime=2026-04-13T08:00:00.000Z") + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Invalid time range: endTime must be greater than or equal to startTime", + }); + }); + + it("service 抛出非校验错误时返回 500", async () => { + mockQueryProviderAvailability.mockRejectedValueOnce(new Error("db down")); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "Internal server error" }); + }); +}); diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts new file mode 100644 index 000000000..25093a724 --- /dev/null +++ b/tests/unit/lib/availability-service.test.ts @@ -0,0 +1,760 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createThenableQuery(result: T) { + const query: { + from: ReturnType; + where: ReturnType; + orderBy: ReturnType; + limit: ReturnType; + then: Promise["then"]; + catch: Promise["catch"]; + finally: Promise["finally"]; + } & Promise = Promise.resolve(result) as never; + + query.from = vi.fn(() => query); + query.where = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + + return query; +} + +function sqlToQuery(sqlObject: unknown) { + return (sqlObject as SQL).toQuery({ + escapeName: (name: string) => `"${name}"`, + escapeParam: (num: number, _value: unknown) => `$${num}`, + escapeString: (value: string) => `'${value}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }); +} + +function sqlToString(sqlObject: unknown): string { + return sqlToQuery(sqlObject).sql; +} + +function normalizeSql(sqlObject: unknown): string { + return sqlToString(sqlObject).replace(/\s+/g, " ").trim().toLowerCase(); +} + +function extractFinalizedRequestsSql(queryText: string): string { + const start = queryText.indexOf("finalized_requests as"); + const end = queryText.indexOf("provider_bucket_stats as"); + + if (start === -1 || end === -1 || end <= start) { + throw new Error("Could not locate finalized_requests CTE in query text"); + } + + return queryText.slice(start, end); +} + +describe("availability-service", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("classifyRequestStatus 不应把 1xx 当成成功", async () => { + vi.doMock("@/drizzle/db", () => ({ + db: { + select: vi.fn(), + execute: vi.fn(), + }, + })); + + const { classifyRequestStatus } = await import("@/lib/availability/availability-service"); + + expect(classifyRequestStatus(101)).toEqual({ + status: "red", + isSuccess: false, + isError: true, + }); + }); + + it("queryProviderAvailability 在非法时间参数时抛出明确错误且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: "invalid-start-time", + }) + ).rejects.toThrow("Invalid startTime"); + + await expect( + queryProviderAvailability({ + endTime: new Date("invalid-end-time"), + }) + ).rejects.toThrow("Invalid endTime"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("queryProviderAvailability 在 endTime 早于 startTime 时抛出明确错误且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2026-04-13T09:00:00.000Z"), + endTime: new Date("2026-04-13T07:00:00.000Z"), + }) + ).rejects.toThrow("Invalid time range"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("queryProviderAvailability 在时间跨度超过 100 天时抛出明确错误且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2025-12-01T00:00:00.000Z"), + endTime: new Date("2026-04-13T00:00:00.000Z"), + }) + ).rejects.toThrow("requested range must not exceed 100 days"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("queryProviderAvailability 在时间跨度恰好等于 100 天时允许继续执行", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + const startTime = new Date("2026-01-03T00:00:00.000Z"); + const endTime = new Date(startTime.getTime() + 100 * 24 * 60 * 60 * 1000); + + await expect( + queryProviderAvailability({ + startTime, + endTime, + }) + ).resolves.toEqual({ + queriedAt: expect.any(String), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + bucketSizeMinutes: 1440, + providers: [], + systemAvailability: 0, + }); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("queryProviderAvailability 在显式 bucket 配置超出 maxBuckets 预算时直接报错且不访问数据库", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 1, + maxBuckets: 100, + }) + ).rejects.toThrow("Invalid bucket configuration"); + + expect(selectMock).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("queryProviderAvailability 在自动分桶且 maxBuckets 较小时会上调 bucket 以匹配预算", async () => { + const selectMock = vi.fn(() => createThenableQuery([])); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + + await expect( + queryProviderAvailability({ + startTime: new Date("2026-04-13T00:00:00.000Z"), + endTime: new Date("2026-04-14T00:00:00.000Z"), + maxBuckets: 10, + }) + ).resolves.toEqual({ + queriedAt: expect.any(String), + startTime: "2026-04-13T00:00:00.000Z", + endTime: "2026-04-14T00:00:00.000Z", + bucketSizeMinutes: 144, + providers: [], + systemAvailability: 0, + }); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("queryProviderAvailability 改为数据库聚合后仍只统计终态请求", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => [ + { + providerId: 1, + bucketStart: new Date("2026-04-13T08:00:00.000Z"), + greenCount: 2, + redCount: 1, + latencyCount: 2, + latencySumMs: 360, + avgLatencyMs: 180, + p50LatencyMs: 120, + p95LatencyMs: 240, + p99LatencyMs: 240, + lastRequestAt: new Date("2026-04-13T08:03:00.000Z"), + }, + ]); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.providers).toHaveLength(1); + expect(result.providers[0]).toMatchObject({ + providerId: 1, + totalRequests: 3, + currentAvailability: 2 / 3, + successRate: 2 / 3, + currentStatus: "green", + avgLatencyMs: 180, + lastRequestAt: "2026-04-13T08:03:00.000Z", + }); + expect(result.providers[0]?.timeBuckets).toHaveLength(1); + expect(result.providers[0]?.timeBuckets[0]).toMatchObject({ + totalRequests: 3, + greenCount: 2, + redCount: 1, + availabilityScore: 2 / 3, + avgLatencyMs: 180, + p50LatencyMs: 120, + p95LatencyMs: 240, + p99LatencyMs: 240, + }); + + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); + expect(finalizedRequestsSql).toMatch(/where .*status_?code.*is not null/); + expect(queryText).toContain("group by"); + expect(queryText).toContain("percentile_cont(0.95)"); + expect(queryText).toContain("row_number() over"); + }); + + it("queryProviderAvailability 计算 currentStatus 时会按最近 buckets 的请求量加权", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => [ + { + providerId: 1, + bucketStart: new Date("2026-04-13T08:00:00.000Z"), + greenCount: 1, + redCount: 0, + latencyCount: 1, + latencySumMs: 100, + avgLatencyMs: 100, + p50LatencyMs: 100, + p95LatencyMs: 100, + p99LatencyMs: 100, + lastRequestAt: new Date("2026-04-13T08:00:30.000Z"), + }, + { + providerId: 1, + bucketStart: new Date("2026-04-13T09:00:00.000Z"), + greenCount: 0, + redCount: 100, + latencyCount: 100, + latencySumMs: 20000, + avgLatencyMs: 200, + p50LatencyMs: 200, + p95LatencyMs: 250, + p99LatencyMs: 300, + lastRequestAt: new Date("2026-04-13T09:59:59.000Z"), + }, + ]); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T10:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + expect(result.providers[0]).toMatchObject({ + providerId: 1, + totalRequests: 101, + currentAvailability: 1 / 101, + currentStatus: "red", + lastRequestAt: "2026-04-13T09:59:59.000Z", + }); + }); + + it("queryProviderAvailability 在 bucketSizeMinutes 为 Infinity 时回退到自动分桶", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: Number.POSITIVE_INFINITY, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.bucketSizeMinutes).toBe(5); + expect(query.params).toContain(300); + expect(query.params).not.toContain(Number.POSITIVE_INFINITY); + }); + + it("queryProviderAvailability 在 bucketSizeMinutes 为超大有限值时钳制到 1440 分钟", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: Number.MAX_SAFE_INTEGER, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result.bucketSizeMinutes).toBe(1440); + expect(query.params).toContain(86400); + expect(query.params).not.toContain(Number.MAX_SAFE_INTEGER * 60); + }); + + it("queryProviderAvailability 会排除进行中请求(statusCode=null 且 durationMs=null)", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + const finalizedRequestsSql = extractFinalizedRequestsSql( + normalizeSql(executeMock.mock.calls[0]?.[0]) + ); + expect(finalizedRequestsSql).toMatch(/where .*status_?code.*is not null/); + }); + + it("queryProviderAvailability 会保留 Gemini passthrough 终态(statusCode!=null 且 durationMs=null)", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + const finalizedRequestsSql = extractFinalizedRequestsSql( + normalizeSql(executeMock.mock.calls[0]?.[0]) + ); + expect(finalizedRequestsSql).not.toMatch(/where .*duration_?ms.*is not null/); + }); + + it("queryProviderAvailability 当前不会把中间持久化状态(statusCode=null 且 durationMs!=null)误算为 red", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + const finalizedRequestsSql = extractFinalizedRequestsSql(queryText); + + expect(finalizedRequestsSql).toMatch(/where .*status_?code.*is not null/); + expect(queryText).toMatch( + /count\(\*\) filter \(where .*status_?code.*< 200 .*or .*status_?code.*>= 400\)/ + ); + }); + + it("queryProviderAvailability 在 maxBuckets 为 Infinity 时仍使用默认桶上限", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + maxBuckets: Number.POSITIVE_INFINITY, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(queryText).toContain("row_number() over"); + expect(queryText).toContain("where rn <="); + expect(query.params).toContain(100); + expect(query.params).not.toContain(Number.POSITIVE_INFINITY); + }); + + it("queryProviderAvailability 在 maxBuckets 为超大有限值时也会收紧到硬上限", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + maxBuckets: Number.MAX_SAFE_INTEGER, + }); + + const query = sqlToQuery(executeMock.mock.calls[0]?.[0]); + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(queryText).toContain("row_number() over"); + expect(queryText).toContain("where rn <="); + expect(query.params).toContain(100); + expect(query.params).not.toContain(Number.MAX_SAFE_INTEGER); + }); + + it("queryProviderAvailability 在无聚合数据时仍返回 unknown 提供商状态", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + providerType: "claude", + enabled: true, + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { queryProviderAvailability } = await import("@/lib/availability/availability-service"); + const result = await queryProviderAvailability({ + startTime: new Date("2026-04-13T07:00:00.000Z"), + endTime: new Date("2026-04-13T09:00:00.000Z"), + bucketSizeMinutes: 60, + }); + + expect(result.providers).toEqual([ + { + providerId: 1, + providerName: "Provider A", + providerType: "claude", + isEnabled: true, + currentStatus: "unknown", + currentAvailability: 0, + totalRequests: 0, + successRate: 0, + avgLatencyMs: 0, + lastRequestAt: null, + timeBuckets: [], + }, + ]); + }); + + it("getCurrentProviderStatus 改为数据库聚合后仍只统计终态请求", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + }, + ]) + ); + const executeMock = vi.fn(async () => [ + { + providerId: 1, + greenCount: 1, + redCount: 1, + lastRequestAt: new Date("2026-04-13T08:02:00.000Z"), + }, + ]); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { getCurrentProviderStatus } = await import("@/lib/availability/availability-service"); + const result = await getCurrentProviderStatus(); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { + providerId: 1, + providerName: "Provider A", + status: "green", + availability: 0.5, + requestCount: 2, + lastRequestAt: "2026-04-13T08:02:00.000Z", + }, + ]); + + const queryText = normalizeSql(executeMock.mock.calls[0]?.[0]); + expect(queryText).toMatch(/where .*status_?code.*is not null/); + expect(queryText).toContain(">= now() - (15 * interval '1 minute')"); + expect(queryText).toContain("<= now()"); + expect(queryText).toContain("count(*) filter"); + expect(queryText).toContain("max("); + }); + + it("getCurrentProviderStatus 在提供商无聚合数据时返回 unknown", async () => { + const selectMock = vi.fn(() => + createThenableQuery([ + { + id: 1, + name: "Provider A", + }, + ]) + ); + const executeMock = vi.fn(async () => []); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: executeMock, + }, + })); + + const { getCurrentProviderStatus } = await import("@/lib/availability/availability-service"); + const result = await getCurrentProviderStatus(); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(executeMock).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { + providerId: 1, + providerName: "Provider A", + status: "unknown", + availability: 0, + requestCount: 0, + lastRequestAt: null, + }, + ]); + }); +}); diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index 17f5ab192..a3e3adf12 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -238,7 +238,7 @@ describe("message_request 异步批量写入", () => { expect(built.sql).toContain("status_code"); }); - it("队列溢出时应优先丢弃非终态更新(尽量保留 durationMs)", async () => { + it("队列溢出时应优先保留带 statusCode 的终态 patch", async () => { process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; @@ -246,7 +246,7 @@ describe("message_request 异步批量写入", () => { "@/repository/message-write-buffer" ); - enqueueMessageRequestUpdate(1001, { statusCode: 200 }); // 非终态(无 durationMs) + enqueueMessageRequestUpdate(1001, { statusCode: 200 }); // Gemini passthrough 等 statusCode-only 终态 for (let i = 0; i < 100; i++) { enqueueMessageRequestUpdate(2000 + i, { durationMs: i }); } @@ -258,8 +258,9 @@ describe("message_request 异步批量写入", () => { const query = executeMock.mock.calls[0]?.[0]; const built = toSqlText(query); - expect(built.params).toContain(2000); + expect(built.params).toContain(1001); + expect(built.sql).toContain("status_code"); + expect(built.params).not.toContain(2000); expect(built.params).toContain(2099); - expect(built.params).not.toContain(1001); }); });