diff --git a/.env.example b/.env.example index a9216eebb..9f39053e1 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,9 @@ STORE_SESSION_RESPONSE_BODY=true # 是否在 Redis 中存储会话响应 # - false:不存储响应体(注意:不影响本次请求处理;仅影响后续查看 response body) # 说明:该开关不影响内部统计读取响应体(tokens/费用统计、SSE 假 200 检测仍会进行) +# Dashboard 配置 +DASHBOARD_LOGS_POLL_INTERVAL_MS=5000 # 日志页自动刷新轮询间隔(毫秒,默认 5000,范围 250-60000) + # 熔断器配置 # 功能说明:控制网络错误是否计入熔断器失败计数 # - false (默认):网络错误(DNS 解析失败、连接超时、代理连接失败等)不计入熔断器,仅供应商错误(4xx/5xx HTTP 响应)计入 diff --git a/drizzle/0079_easy_zeigeist.sql b/drizzle/0079_easy_zeigeist.sql new file mode 100644 index 000000000..7de0318f1 --- /dev/null +++ b/drizzle/0079_easy_zeigeist.sql @@ -0,0 +1 @@ +ALTER TABLE "system_settings" ADD COLUMN "enable_response_input_rectifier" boolean DEFAULT true NOT NULL; diff --git a/drizzle/0080_fresh_clint_barton.sql b/drizzle/0080_fresh_clint_barton.sql new file mode 100644 index 000000000..5cf71c93a --- /dev/null +++ b/drizzle/0080_fresh_clint_barton.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "cost_reset_at" timestamp with time zone; diff --git a/drizzle/meta/0079_snapshot.json b/drizzle/meta/0079_snapshot.json new file mode 100644 index 000000000..bdea70622 --- /dev/null +++ b/drizzle/meta/0079_snapshot.json @@ -0,0 +1,3921 @@ +{ + "id": "88addf3b-363e-4abc-8809-02b7f03ca6e6", + "prevId": "aa3f3ed9-db02-48e9-b755-e2dd39b0b77a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "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/0080_snapshot.json b/drizzle/meta/0080_snapshot.json new file mode 100644 index 000000000..5142c9dde --- /dev/null +++ b/drizzle/meta/0080_snapshot.json @@ -0,0 +1,3927 @@ +{ + "id": "4fa20bbb-cba5-498d-8d26-31df76c66d25", + "prevId": "88addf3b-363e-4abc-8809-02b7f03ca6e6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_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": {} + } + }, + "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 662a127c7..48fea6a98 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -554,6 +554,20 @@ "when": 1772782546382, "tag": "0078_remarkable_lionheart", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1772994859188, + "tag": "0079_easy_zeigeist", + "breakpoints": true + }, + { + "idx": 80, + "version": "7", + "when": 1773036289279, + "tag": "0080_fresh_clint_barton", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 030082046..0590ae221 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -184,6 +184,7 @@ "title": "Request Details", "statusTitle": "Status: {status}", "inProgress": "In Progress", + "retrying": "Retrying", "unknown": "Unknown", "success": "Request completed successfully", "error": "Request failed, here are the detailed error messages and provider decision chain", @@ -456,7 +457,8 @@ "avgTtfbMs": "Avg TTFB", "avgTokensPerSecond": "Avg tok/s", "avgCostPerRequest": "Avg Cost/Req", - "avgCostPerMillionTokens": "Avg Cost/1M Tokens" + "avgCostPerMillionTokens": "Avg Cost/1M Tokens", + "unknownModel": "Unknown" }, "expandModelStats": "Expand model details", "collapseModelStats": "Collapse model details", @@ -479,6 +481,38 @@ "filters": { "userTagsPlaceholder": "Filter by user tags...", "userGroupsPlaceholder": "Filter by user groups..." + }, + "userInsights": { + "title": "User Insights", + "backToLeaderboard": "Back to Leaderboard", + "overview": "Overview", + "keyTrend": "Key Usage Trend", + "modelBreakdown": "Model Breakdown", + "todayRequests": "Today Requests", + "todayCost": "Today Cost", + "avgResponseTime": "Avg Response Time", + "errorRate": "Error Rate", + "timeRange": { + "today": "Today", + "7days": "Last 7 Days", + "30days": "Last 30 Days", + "thisMonth": "This Month" + }, + "unknownModel": "Unknown Model", + "noData": "No data available", + "dateRange": "Date Range", + "allTime": "All Time", + "providerBreakdown": "Provider Breakdown", + "unknownProvider": "Unknown Provider", + "apiKey": "API Key", + "provider": "Provider", + "model": "Model", + "allKeys": "All Keys", + "allProviders": "All Providers", + "allModels": "All Models", + "dimensions": "Dimensions", + "filters": "Filters", + "loadError": "Failed to load data" } }, "sessions": { @@ -1052,7 +1086,7 @@ "label": "Allowed Clients", "description": "Restrict which CLI/IDE clients can use this account. Leave empty for no restrictions.", "customLabel": "Custom Client Patterns", - "customPlaceholder": "Enter custom pattern (e.g., 'xcode', 'my-ide')" + "customPlaceholder": "Enter pattern (e.g., 'my-ide', 'codex-*')" }, "allowedModels": { "label": "Allowed Models", @@ -1539,6 +1573,21 @@ "deleteFailed": "Failed to delete user", "userDeleted": "User has been deleted", "saving": "Saving...", + "resetSection": { + "title": "Reset Options" + }, + "resetLimits": { + "title": "Reset Limits", + "description": "Reset accumulated cost counters for all limits. Request logs and statistics are preserved.", + "button": "Reset Limits", + "confirmTitle": "Reset Limits Only?", + "confirmDescription": "This will reset all accumulated cost counters (5h, daily, weekly, monthly, total) to zero. Request logs and usage statistics will be preserved.", + "confirm": "Yes, Reset Limits", + "loading": "Resetting...", + "error": "Failed to reset limits", + "success": "All limits have been reset", + "lastResetAt": "Last reset: {date}" + }, "resetData": { "title": "Reset Statistics", "description": "Delete all request logs and usage data for this user. This action is irreversible.", @@ -1854,15 +1903,15 @@ "label": "Client Restrictions", "description": "Restrict which CLI/IDE clients can use this account. Empty = no restriction.", "customLabel": "Custom Client Pattern", - "customPlaceholder": "Enter pattern (e.g., 'xcode', 'my-ide')", - "customHelp": "Custom patterns match User-Agent by case-insensitive substring. '-' and '_' are treated as equivalent." + "customPlaceholder": "Enter pattern (e.g., 'my-ide', 'codex-*')", + "customHelp": "Without '*': case-insensitive substring match ('-'/'_' ignored). With '*': glob pattern match from start of UA (use *foo* to match anywhere; case-insensitive, characters are literal)." }, "blockedClients": { "label": "Blocked Clients", "description": "Clients matching these patterns will be rejected, even if they match allowed clients.", "customLabel": "Custom Block Pattern", - "customPlaceholder": "Enter pattern (e.g., 'xcode', 'my-ide')", - "customHelp": "Custom patterns match User-Agent by case-insensitive substring. '-' and '_' are treated as equivalent." + "customPlaceholder": "Enter pattern (e.g., 'my-ide', 'codex-*')", + "customHelp": "Without '*': case-insensitive substring match ('-'/'_' ignored). With '*': glob pattern match from start of UA (use *foo* to match anywhere; case-insensitive, characters are literal)." }, "allowedModels": { "label": "Model Restrictions", @@ -1899,7 +1948,17 @@ "claude-code-sdk-ts": "Claude Code SDK (TypeScript)", "claude-code-sdk-py": "Claude Code SDK (Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "All", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} selected" }, "keyEditSection": { "sections": { diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index 67ba8dd64..cae28b097 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -41,7 +41,12 @@ "http2Fallback": "HTTP/2 Fallback", "clientError": "Client Error", "endpointPoolExhausted": "Endpoint Pool Exhausted", - "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout" + "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout", + "hedgeTriggered": "Hedge Triggered", + "hedgeLaunched": "Hedge Alternative Launched", + "hedgeWinner": "Hedge Winner", + "hedgeLoserCancelled": "Hedge Loser (Cancelled)", + "clientAbort": "Client Aborted" }, "reasons": { "request_success": "Success", @@ -56,7 +61,12 @@ "initial_selection": "Initial Selection", "endpoint_pool_exhausted": "Endpoint Pool Exhausted", "vendor_type_all_timeout": "Vendor-Type All Endpoints Timeout", - "client_restriction_filtered": "Client Restricted" + "client_restriction_filtered": "Client Restricted", + "hedge_triggered": "Hedge Triggered", + "hedge_launched": "Hedge Alternative Launched", + "hedge_winner": "Hedge Winner", + "hedge_loser_cancelled": "Hedge Loser (Cancelled)", + "client_abort": "Client Aborted" }, "filterReasons": { "rate_limited": "Rate Limited", @@ -222,7 +232,14 @@ "strictBlockNoEndpoints": "Strict mode: no endpoint candidates available, provider skipped without fallback", "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback", "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout (524)", - "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered." + "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered.", + "hedgeTriggered": "Hedge Threshold Exceeded (launching alternative)", + "hedgeLaunched": "Hedge Alternative Provider Launched", + "hedgeWinner": "Hedge Race Winner (first byte received first)", + "hedgeLoserCancelled": "Hedge Race Loser (request cancelled)", + "clientAbort": "Client Disconnected (request aborted)", + "hedgeRace": "Hedge Race", + "hedgeThresholdExceeded": "First-byte timeout exceeded, alternative provider launched" }, "selectionMethods": { "session_reuse": "Session Reuse", diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index bb192966d..97ff8ca99 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -55,6 +55,8 @@ "enableThinkingBudgetRectifierDesc": "When Anthropic providers return budget_tokens < 1024 errors, automatically sets thinking budget to maximum (32000) and max_tokens to 64000 if needed, then retries once (enabled by default).", "enableBillingHeaderRectifier": "Enable Billing Header Rectifier", "enableBillingHeaderRectifierDesc": "Proactively removes x-anthropic-billing-header text blocks injected by Claude Code client into the system prompt, preventing Amazon Bedrock and other non-native Anthropic upstreams from returning 400 errors (enabled by default).", + "enableResponseInputRectifier": "Enable Response Input Rectifier", + "enableResponseInputRectifierDesc": "Automatically normalizes non-array input (string shortcut or single message object) in /v1/responses requests to standard array format before processing (enabled by default).", "enableCodexSessionIdCompletion": "Enable Codex Session ID Completion", "enableCodexSessionIdCompletionDesc": "When Codex requests provide only one of session_id (header) or prompt_cache_key (body), automatically completes the other. If both are missing, generates a UUID v7 session id and reuses it stably within the same conversation.", "enableClaudeMetadataUserIdInjection": "Enable Claude metadata.user_id Injection", diff --git a/messages/en/settings/data.json b/messages/en/settings/data.json index a41516b7e..b2e7de657 100644 --- a/messages/en/settings/data.json +++ b/messages/en/settings/data.json @@ -29,7 +29,9 @@ }, "rangeLabel": "Cleanup Range", "statisticsRetained": "✓ Statistics data will be retained (for trend analysis)", + "softDeletePurged": "{count} soft-deleted records also purged", "successMessage": "Successfully cleaned {count} log records ({batches} batches, took {duration}s)", + "vacuumComplete": "Database space reclaimed", "willClean": "Will clean all log records from {range}" }, "description": "Manage database backup and recovery with full data import/export and log cleanup.", diff --git a/messages/en/settings/providers/batchEdit.json b/messages/en/settings/providers/batchEdit.json index cf192932e..0946acf05 100644 --- a/messages/en/settings/providers/batchEdit.json +++ b/messages/en/settings/providers/batchEdit.json @@ -1,37 +1,46 @@ { - "enterMode": "Batch Edit", - "exitMode": "Exit", - "selectAll": "Select All", - "invertSelection": "Invert", "selectedCount": "{count} selected", - "editSelected": "Edit Selected", - "selectByType": "Select by Type", - "selectByTypeItem": "{type} ({count})", - "selectByGroup": "Select by Group", - "selectByGroupItem": "{group} ({count})", "actions": { "edit": "Edit", - "delete": "Delete", - "resetCircuit": "Reset Circuit" + "resetCircuit": "Reset Circuit", + "delete": "Delete" }, + "enterMode": "Batch Edit", + "selectionHint": "Select providers to batch edit", + "selectAll": "Select all", + "invertSelection": "Invert", + "selectByType": "By Type", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "By Group", + "selectByGroupItem": "{group} ({count})", + "editSelected": "Edit Selected", + "exitMode": "Exit", "dialog": { "editTitle": "Batch Edit Providers", - "editDesc": "Changes will apply to {count} providers", + "editDesc": "Edit settings for {count} providers", "deleteTitle": "Delete Providers", - "deleteDesc": "Permanently delete {count} providers?", + "deleteDesc": "Are you sure you want to delete {count} providers? This action cannot be undone.", "resetCircuitTitle": "Reset Circuit Breakers", - "resetCircuitDesc": "Reset circuit breaker for {count} providers?", - "next": "Next", - "noFieldEnabled": "Please enable at least one field to update" + "resetCircuitDesc": "Reset circuit breakers for {count} providers?", + "next": "Next" }, - "sections": { - "basic": "Basic Settings", - "routing": "Group & Routing", - "anthropic": "Anthropic Settings" + "preview": { + "title": "Preview Changes", + "description": "Review changes for {count} providers", + "loading": "Loading preview...", + "noChanges": "No changes to apply", + "summary": "{providerCount} providers affected, {fieldCount} fields changed, {skipCount} skipped", + "excludeProvider": "Toggle provider inclusion", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: skipped ({reason})", + "nullValue": "(empty)", + "back": "Back", + "apply": "Apply Changes" }, "fields": { "isEnabled": { - "label": "Status", + "label": "Enabled", "noChange": "No Change", "enable": "Enable", "disable": "Disable" @@ -40,69 +49,52 @@ "weight": "Weight", "costMultiplier": "Cost Multiplier", "groupTag": { - "label": "Group Tag", - "clear": "Clear" + "label": "Group Tag" }, "modelRedirects": "Model Redirects", "allowedModels": "Allowed Models", "thinkingBudget": "Thinking Budget", - "adaptiveThinking": "Adaptive Thinking", - "activeTimeStart": "Active Start Time", - "activeTimeEnd": "Active End Time" + "adaptiveThinking": "Adaptive Thinking" }, - "affectedProviders": { - "title": "Affected Providers", - "more": "+{count} more" + "toast": { + "previewFailed": "Preview failed: {error}", + "unknownError": "Unknown error", + "updated": "{count} providers updated", + "undo": "Undo", + "undoSuccess": "{count} providers reverted", + "undoFailed": "Undo failed: {error}", + "failed": "Operation failed: {error}", + "circuitReset": "{count} circuit breakers reset" + }, + "undo": { + "batchDeleteSuccess": "{count} providers deleted", + "button": "Undo", + "batchDeleteUndone": "{count} providers restored", + "singleEditSuccess": "Provider updated", + "singleEditUndone": "Provider update reverted", + "singleDeleteSuccess": "Provider deleted", + "singleDeleteUndone": "Provider restored", + "expired": "Undo expired", + "failed": "Undo failed" }, "confirm": { - "title": "Confirm Operation", "cancel": "Cancel", - "confirm": "Confirm", "goBack": "Go Back", + "confirm": "Confirm", "processing": "Processing..." }, - "preview": { - "title": "Preview Changes", - "description": "Review changes before applying to {count} providers", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "null", - "fieldSkipped": "{field}: Skipped ({reason})", - "excludeProvider": "Exclude", - "summary": "{providerCount} providers, {fieldCount} changes, {skipCount} skipped", - "noChanges": "No changes to apply", - "apply": "Apply Changes", - "back": "Back to Edit", - "loading": "Generating preview..." + "affectedProviders": { + "title": "Affected Providers", + "more": "+{count} more" }, "batchNotes": { "codexOnly": "Codex only", "claudeOnly": "Claude only", "geminiOnly": "Gemini only" }, - "selectionHint": "Select multiple providers for batch operations", - "undo": { - "button": "Undo", - "success": "Operation undone successfully", - "expired": "Undo expired", - "batchDeleteSuccess": "Deleted {count} providers", - "batchDeleteUndone": "Restored {count} providers", - "singleDeleteSuccess": "Provider deleted", - "singleDeleteUndone": "Provider restored", - "singleEditSuccess": "Provider updated", - "singleEditUndone": "Changes reverted", - "failed": "Undo failed" - }, - "toast": { - "updated": "Updated {count} providers", - "deleted": "Deleted {count} providers", - "circuitReset": "Reset {count} circuit breakers", - "failed": "Operation failed: {error}", - "undo": "Undo", - "undoSuccess": "Reverted {count} providers", - "undoFailed": "Undo failed: {error}", - "undoExpired": "Undo window expired", - "previewFailed": "Preview failed: {error}", - "unknownError": "Unknown error" + "mixedValues": { + "label": "(mixed values)", + "tooltip": "Selected providers have different values:", + "andMore": "...and {count} more" } } diff --git a/messages/en/settings/providers/form/common.json b/messages/en/settings/providers/form/common.json index ab4c7b067..942370f83 100644 --- a/messages/en/settings/providers/form/common.json +++ b/messages/en/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "Limits", "network": "Network", "testing": "Testing", + "scheduling": "Scheduling", + "options": "Options", + "activeTime": "Active Time", + "circuitBreaker": "Circuit Breaker", + "timeout": "Timeout", "stepProgress": "Step progress" } } diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index cba4db294..ef3e9392a 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -330,19 +330,33 @@ "customAllowedPlaceholder": "e.g. my-ide, internal-tool", "customBlockedLabel": "Custom Blocked Patterns", "customBlockedPlaceholder": "e.g. legacy-client", - "customHelp": "Custom patterns use case-insensitive User-Agent contains matching. '-' and '_' are treated as equivalent.", + "customHelp": "Without '*': case-insensitive substring match ('-'/'_' ignored). With '*': glob pattern match from start of UA (use *foo* to match anywhere; case-insensitive, characters are literal).", "presetClients": { "claude-code": "Claude Code (all)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "All", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} selected" }, "preserveClientIp": { "desc": "Pass x-forwarded-for / x-real-ip to upstream providers (may expose real client IP)", "help": "Keep off by default for privacy. Enable only when upstream must see the end-user IP.", "label": "Forward client IP" }, + "options": { + "title": "Options", + "desc": "Additional provider options and overrides" + }, "providerType": { "desc": "(determines scheduling policy)", "label": "Provider Type", @@ -401,7 +415,7 @@ "disableHint": "Set to 0 to disable the timeout (for canary rollback scenarios only, not recommended)", "nonStreamingTotal": { "core": "true", - "desc": "Non-streaming request total timeout, range 60-1200 seconds, enter 0 to disable (default: no limit)", + "desc": "Non-streaming request total timeout, range 60-1800 seconds, enter 0 to disable (default: no limit)", "label": "Non-streaming Total Timeout (seconds)", "placeholder": "0" }, diff --git a/messages/en/settings/providers/list.json b/messages/en/settings/providers/list.json index 0b3c13284..8e39dab8c 100644 --- a/messages/en/settings/providers/list.json +++ b/messages/en/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "Reset Usage", "actionDelete": "Delete", "selectProvider": "Select {name}", - "schedule": "Schedule" + "schedule": "Schedule", + "proxyEnabled": "Proxy enabled" } diff --git a/messages/en/ui.json b/messages/en/ui.json index 8e07905e5..e5c6d585c 100644 --- a/messages/en/ui.json +++ b/messages/en/ui.json @@ -52,7 +52,8 @@ "maxTags": "Maximum number of tags reached", "tooLong": "Tag length cannot exceed {max} characters", "invalidFormat": "Tags can only contain letters, numbers, underscores and hyphens", - "removeTag": "Remove tag {tag}" + "removeTag": "Remove tag {tag}", + "unknownError": "Invalid input" }, "providerGroupSelect": { "placeholder": "Select provider groups", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index ec85f4650..4e3b946cd 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -184,6 +184,7 @@ "title": "リクエスト詳細", "statusTitle": "ステータス: {status}", "inProgress": "処理中", + "retrying": "Retrying", "unknown": "不明", "success": "リクエストが正常に完了しました", "error": "リクエスト失敗、詳細なエラー情報とプロバイダー決定チェーンは以下の通りです", @@ -456,7 +457,8 @@ "avgTtfbMs": "平均TTFB", "avgTokensPerSecond": "平均トークン/秒", "avgCostPerRequest": "平均リクエスト単価", - "avgCostPerMillionTokens": "100万トークンあたりコスト" + "avgCostPerMillionTokens": "100万トークンあたりコスト", + "unknownModel": "不明" }, "expandModelStats": "モデル詳細を展開", "collapseModelStats": "モデル詳細を折りたたむ", @@ -479,6 +481,38 @@ "filters": { "userTagsPlaceholder": "ユーザータグでフィルタ...", "userGroupsPlaceholder": "ユーザーグループでフィルタ..." + }, + "userInsights": { + "title": "ユーザーインサイト", + "backToLeaderboard": "ランキングに戻る", + "overview": "概要", + "keyTrend": "Key 使用トレンド", + "modelBreakdown": "モデル内訳", + "todayRequests": "本日リクエスト", + "todayCost": "本日コスト", + "avgResponseTime": "平均応答時間", + "errorRate": "エラー率", + "timeRange": { + "today": "今日", + "7days": "過去7日間", + "30days": "過去30日間", + "thisMonth": "今月" + }, + "unknownModel": "不明なモデル", + "noData": "データがありません", + "dateRange": "期間", + "allTime": "全期間", + "providerBreakdown": "プロバイダー内訳", + "unknownProvider": "不明なプロバイダー", + "apiKey": "APIキー", + "provider": "プロバイダー", + "model": "モデル", + "allKeys": "すべてのキー", + "allProviders": "すべてのプロバイダー", + "allModels": "すべてのモデル", + "dimensions": "ディメンション", + "filters": "フィルター", + "loadError": "データの読み込みに失敗しました" } }, "sessions": { @@ -1039,7 +1073,7 @@ "label": "許可されたクライアント", "description": "このアカウントを使用できるCLI/IDEクライアントを制限します。空の場合は制限なし。", "customLabel": "カスタムクライアントパターン", - "customPlaceholder": "カスタムパターンを入力(例:'xcode', 'my-ide')" + "customPlaceholder": "パターンを入力(例: 'my-ide'、'codex-*')" }, "allowedModels": { "label": "許可モデル", @@ -1517,6 +1551,21 @@ "deleteFailed": "ユーザーの削除に失敗しました", "userDeleted": "ユーザーが削除されました", "saving": "保存しています...", + "resetSection": { + "title": "リセットオプション" + }, + "resetLimits": { + "title": "制限のリセット", + "description": "全ての制限の累積コストカウンターをリセットします。リクエストログと統計データは保持されます。", + "button": "制限をリセット", + "confirmTitle": "制限のみリセットしますか?", + "confirmDescription": "全ての累積コストカウンター(5時間、日次、週次、月次、合計)がゼロにリセットされます。リクエストログと利用統計は保持されます。", + "confirm": "はい、リセットする", + "loading": "リセット中...", + "error": "制限のリセットに失敗しました", + "success": "全ての制限がリセットされました", + "lastResetAt": "前回のリセット: {date}" + }, "resetData": { "title": "統計リセット", "description": "このユーザーのすべてのリクエストログと使用データを削除します。この操作は元に戻せません。", @@ -1790,15 +1839,15 @@ "label": "クライアント制限", "description": "このアカウントを使用できるCLI/IDEクライアントを制限します。空欄は制限なし。", "customLabel": "カスタムクライアントパターン", - "customPlaceholder": "パターンを入力(例:'xcode', 'my-ide')", - "customHelp": "カスタムパターンは User-Agent の部分一致で判定されます(大文字小文字を区別しません)。'-' と '_' は同等として扱います。" + "customPlaceholder": "パターンを入力(例: 'my-ide'、'codex-*')", + "customHelp": "'*' なし:大文字小文字を区別しない部分一致('-' と '_' は無視)。'*' あり:UA の先頭からの glob パターンマッチ(*foo* で任意の位置に一致; 大文字小文字を区別しない、文字はそのまま一致)。" }, "blockedClients": { "label": "ブロックするクライアント", "description": "これらのパターンに一致するクライアントは、許可リストに一致しても拒否されます。", "customLabel": "カスタムブロックパターン", - "customPlaceholder": "パターンを入力(例: 'xcode'、'my-ide')", - "customHelp": "カスタムパターンは User-Agent の部分一致で判定されます(大文字小文字を区別しません)。'-' と '_' は同等として扱います。" + "customPlaceholder": "パターンを入力(例: 'my-ide'、'codex-*')", + "customHelp": "'*' なし:大文字小文字を区別しない部分一致('-' と '_' は無視)。'*' あり:UA の先頭からの glob パターンマッチ(*foo* で任意の位置に一致; 大文字小文字を区別しない、文字はそのまま一致)。" }, "allowedModels": { "label": "モデル制限", @@ -1835,7 +1884,17 @@ "claude-code-sdk-ts": "Claude Code SDK (TypeScript)", "claude-code-sdk-py": "Claude Code SDK (Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "すべて", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} 件選択" }, "keyEditSection": { "sections": { diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index 701a2a4b9..74326d533 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -41,7 +41,12 @@ "http2Fallback": "HTTP/2 フォールバック", "clientError": "クライアントエラー", "endpointPoolExhausted": "エンドポイントプール枯渇", - "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト" + "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト", + "hedgeTriggered": "Hedge 発動", + "hedgeLaunched": "Hedge 代替起動済み", + "hedgeWinner": "Hedge 競争勝者", + "hedgeLoserCancelled": "Hedge 競争敗者(キャンセル)", + "clientAbort": "クライアント中断" }, "reasons": { "request_success": "成功", @@ -56,7 +61,12 @@ "initial_selection": "初期選択", "endpoint_pool_exhausted": "エンドポイントプール枯渇", "vendor_type_all_timeout": "ベンダータイプ全エンドポイントタイムアウト", - "client_restriction_filtered": "クライアント制限" + "client_restriction_filtered": "クライアント制限", + "hedge_triggered": "Hedge 発動", + "hedge_launched": "Hedge 代替起動済み", + "hedge_winner": "Hedge 競争勝者", + "hedge_loser_cancelled": "Hedge 競争敗者(キャンセル)", + "client_abort": "クライアント中断" }, "filterReasons": { "rate_limited": "レート制限", @@ -222,7 +232,14 @@ "strictBlockNoEndpoints": "厳格モード:利用可能なエンドポイント候補がないため、フォールバックなしでプロバイダーをスキップ", "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ", "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト(524)", - "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。" + "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。", + "hedgeTriggered": "Hedge 閾値超過(代替プロバイダーを起動中)", + "hedgeLaunched": "Hedge 代替プロバイダー起動済み", + "hedgeWinner": "Hedge 競争勝者(最初にファーストバイトを受信)", + "hedgeLoserCancelled": "Hedge 競争敗者(リクエストキャンセル)", + "clientAbort": "クライアント切断(リクエスト中断)", + "hedgeRace": "Hedge 競争", + "hedgeThresholdExceeded": "ファーストバイトタイムアウト超過、代替プロバイダーを起動" }, "selectionMethods": { "session_reuse": "セッション再利用", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index abd1ad89a..24b9f7b72 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -55,6 +55,8 @@ "enableThinkingBudgetRectifierDesc": "Anthropic プロバイダーで budget_tokens < 1024 エラーが発生した場合、thinking 予算を最大値(32000)に設定し、必要に応じて max_tokens を 64000 に設定して1回だけ再試行します(既定で有効)。", "enableBillingHeaderRectifier": "課金ヘッダー整流を有効化", "enableBillingHeaderRectifierDesc": "Claude Code クライアントが system プロンプトに注入する x-anthropic-billing-header テキストブロックを事前に削除し、Amazon Bedrock などの非ネイティブ Anthropic 上流による 400 エラーを防止します(既定で有効)。", + "enableResponseInputRectifier": "Response Input 整流器を有効化", + "enableResponseInputRectifierDesc": "/v1/responses リクエストの非配列 input(文字列ショートカットまたは role/type 付き単一メッセージオブジェクト)を処理前に標準の配列形式に自動正規化します(既定で有効)。", "enableCodexSessionIdCompletion": "Codex セッションID補完を有効化", "enableCodexSessionIdCompletionDesc": "Codex リクエストで session_id(ヘッダー)または prompt_cache_key(ボディ)のどちらか一方しか提供されない場合に、欠けている方を自動補完します。両方ない場合は UUID v7 のセッションIDを生成し、同一対話内で安定して再利用します。", "enableClaudeMetadataUserIdInjection": "Claude metadata.user_id 注入を有効化", diff --git a/messages/ja/settings/data.json b/messages/ja/settings/data.json index b03ddbcb7..6c5d6ef6e 100644 --- a/messages/ja/settings/data.json +++ b/messages/ja/settings/data.json @@ -28,8 +28,10 @@ "default": "{days}日前" }, "rangeLabel": "クリーンアップ範囲", + "softDeletePurged": "{count}件の論理削除レコードも物理削除しました", "statisticsRetained": "✓ 統計データは保持されます(トレンド分析用)", "successMessage": "{count}件のログレコードをクリーンアップしました({batches}バッチ、所要時間{duration}秒)", + "vacuumComplete": "データベース領域を回収しました", "willClean": "{range}のすべてのログレコードをクリーンアップします" }, "description": "データベースのバックアップと復元を管理し、完全なインポート/エクスポートとログクリーンアップをサポートします。", diff --git a/messages/ja/settings/prices.json b/messages/ja/settings/prices.json index 80afbb19d..7c9ef477a 100644 --- a/messages/ja/settings/prices.json +++ b/messages/ja/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "ローカル" + "local": "ローカル", + "multi": "マルチ" }, "capabilities": { "assistantPrefill": "アシスタント事前入力", diff --git a/messages/ja/settings/providers/batchEdit.json b/messages/ja/settings/providers/batchEdit.json index 94e160152..be7f18739 100644 --- a/messages/ja/settings/providers/batchEdit.json +++ b/messages/ja/settings/providers/batchEdit.json @@ -1,108 +1,100 @@ { - "enterMode": "一括編集", - "exitMode": "終了", - "selectAll": "全選択", - "invertSelection": "反転", "selectedCount": "{count} 件選択中", - "editSelected": "選択項目を編集", - "selectByType": "タイプで選択", - "selectByTypeItem": "{type} ({count})", - "selectByGroup": "グループで選択", - "selectByGroupItem": "{group} ({count})", "actions": { "edit": "編集", - "delete": "削除", - "resetCircuit": "サーキット リセット" + "resetCircuit": "サーキットブレーカーをリセット", + "delete": "削除" }, + "enterMode": "一括編集", + "selectionHint": "プロバイダーを選択して一括編集", + "selectAll": "すべて選択", + "invertSelection": "選択を反転", + "selectByType": "タイプ別", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "グループ別", + "selectByGroupItem": "{group} ({count})", + "editSelected": "選択項目を編集", + "exitMode": "終了", "dialog": { "editTitle": "プロバイダーの一括編集", - "editDesc": "{count} 件のプロバイダーに変更が適用されます", + "editDesc": "{count} 件のプロバイダーの設定を編集", "deleteTitle": "プロバイダーの削除", - "deleteDesc": "{count} 件のプロバイダーを完全に削除しますか?", + "deleteDesc": "{count} 件のプロバイダーを削除してよろしいですか?この操作は元に戻せません。", "resetCircuitTitle": "サーキットブレーカーのリセット", - "resetCircuitDesc": "{count} 件のプロバイダーのサーキットブレーカーをリセットしますか?", - "next": "次へ", - "noFieldEnabled": "更新するフィールドを少なくとも1つ有効にしてください" + "resetCircuitDesc": "{count} 件のプロバイダーのサーキットブレーカーをリセットしますか?", + "next": "次へ" }, - "sections": { - "basic": "基本設定", - "routing": "グループとルーティング", - "anthropic": "Anthropic 設定" + "preview": { + "title": "変更のプレビュー", + "description": "{count} 件のプロバイダーの変更を確認", + "loading": "プレビューを読み込み中...", + "noChanges": "適用する変更はありません", + "summary": "{providerCount} 件のプロバイダーに影響、{fieldCount} 件のフィールドを変更、{skipCount} 件スキップ", + "excludeProvider": "プロバイダーの包含を切り替え", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: スキップ ({reason})", + "nullValue": "(空)", + "back": "戻る", + "apply": "変更を適用" }, "fields": { "isEnabled": { - "label": "ステータス", + "label": "有効状態", "noChange": "変更なし", - "enable": "有効", - "disable": "無効" + "enable": "有効にする", + "disable": "無効にする" }, "priority": "優先度", "weight": "重み", - "costMultiplier": "価格倍率", + "costMultiplier": "コスト倍率", "groupTag": { - "label": "グループタグ", - "clear": "クリア" + "label": "グループタグ" }, "modelRedirects": "モデルリダイレクト", "allowedModels": "許可モデル", "thinkingBudget": "思考バジェット", - "adaptiveThinking": "アダプティブ思考", - "activeTimeStart": "スケジュール開始時刻", - "activeTimeEnd": "スケジュール終了時刻" + "adaptiveThinking": "アダプティブ思考" }, - "affectedProviders": { - "title": "影響を受けるプロバイダー", - "more": "+{count} 件" + "toast": { + "previewFailed": "プレビュー失敗: {error}", + "unknownError": "不明なエラー", + "updated": "{count} 件のプロバイダーを更新しました", + "undo": "元に戻す", + "undoSuccess": "{count} 件のプロバイダーを元に戻しました", + "undoFailed": "元に戻す操作に失敗: {error}", + "failed": "操作に失敗: {error}", + "circuitReset": "{count} 件のサーキットブレーカーをリセットしました" + }, + "undo": { + "batchDeleteSuccess": "{count} 件のプロバイダーを削除しました", + "button": "元に戻す", + "batchDeleteUndone": "{count} 件のプロバイダーを復元しました", + "singleEditSuccess": "プロバイダーを更新しました", + "singleEditUndone": "プロバイダーの更新を元に戻しました", + "singleDeleteSuccess": "プロバイダーを削除しました", + "singleDeleteUndone": "プロバイダーを復元しました", + "expired": "元に戻す期限切れ", + "failed": "元に戻す操作に失敗" }, "confirm": { - "title": "操作の確認", "cancel": "キャンセル", - "confirm": "確認", "goBack": "戻る", + "confirm": "確認", "processing": "処理中..." }, - "preview": { - "title": "変更のプレビュー", - "description": "{count} 件のプロバイダーに適用する前に変更内容を確認してください", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "なし", - "fieldSkipped": "{field}: スキップ ({reason})", - "excludeProvider": "除外", - "summary": "{providerCount} 件のプロバイダー, {fieldCount} 件の変更, {skipCount} 件スキップ", - "noChanges": "適用する変更はありません", - "apply": "変更を適用", - "back": "編集に戻る", - "loading": "プレビューを生成中..." + "affectedProviders": { + "title": "対象プロバイダー", + "more": "他 {count} 件" }, "batchNotes": { "codexOnly": "Codex のみ", "claudeOnly": "Claude のみ", "geminiOnly": "Gemini のみ" }, - "selectionHint": "複数のプロバイダーを選択して一括操作を実行", - "undo": { - "button": "元に戻す", - "success": "操作が正常に元に戻されました", - "expired": "元に戻す期限が切れました", - "batchDeleteSuccess": "{count} 件のプロバイダーを削除しました", - "batchDeleteUndone": "{count} 件のプロバイダーを復元しました", - "singleDeleteSuccess": "プロバイダーを削除しました", - "singleDeleteUndone": "プロバイダーを復元しました", - "singleEditSuccess": "プロバイダーを更新しました", - "singleEditUndone": "変更を元に戻しました", - "failed": "元に戻すことに失敗しました" - }, - "toast": { - "updated": "{count} 件のプロバイダーを更新しました", - "deleted": "{count} 件のプロバイダーを削除しました", - "circuitReset": "{count} 件のサーキットブレーカーをリセットしました", - "failed": "操作に失敗しました: {error}", - "undo": "元に戻す", - "undoSuccess": "{count} 件のプロバイダーを復元しました", - "undoFailed": "元に戻す操作に失敗しました: {error}", - "undoExpired": "元に戻す期限が切れました", - "previewFailed": "プレビューに失敗しました: {error}", - "unknownError": "不明なエラー" + "mixedValues": { + "label": "(混合値)", + "tooltip": "選択されたプロバイダーには異なる値があります:", + "andMore": "...他 {count} 件" } } diff --git a/messages/ja/settings/providers/form/common.json b/messages/ja/settings/providers/form/common.json index fe766439c..663afad0d 100644 --- a/messages/ja/settings/providers/form/common.json +++ b/messages/ja/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "制限", "network": "ネットワーク", "testing": "テスト", + "scheduling": "スケジューリング", + "options": "オプション", + "activeTime": "アクティブ時間", + "circuitBreaker": "サーキットブレーカー", + "timeout": "タイムアウト", "stepProgress": "ステップ進捗" } } diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 7b5782156..1b63b3991 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -331,19 +331,33 @@ "customAllowedPlaceholder": "例: my-ide、internal-tool", "customBlockedLabel": "カスタムブロックパターン", "customBlockedPlaceholder": "例: legacy-client", - "customHelp": "カスタムパターンは User-Agent の部分一致で判定されます(大文字小文字を区別しません)。'-' と '_' は同等として扱います。", + "customHelp": "'*' なし:大文字小文字を区別しない部分一致('-' と '_' は無視)。'*' あり:UA の先頭からの glob パターンマッチ(*foo* で任意の位置に一致; 大文字小文字を区別しない、文字はそのまま一致)。", "presetClients": { "claude-code": "Claude Code(すべて)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "すべて", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} 件選択" }, "preserveClientIp": { "desc": "x-forwarded-for / x-real-ip を上流に渡します(実際の IP が露出する可能性)", "help": "プライバシー保護のためデフォルトはオフ。上流側で端末 IP が必要な場合のみ有効化してください。", "label": "クライアント IP を転送" }, + "options": { + "title": "オプション", + "desc": "追加のプロバイダーオプションとオーバーライド" + }, "providerType": { "desc": "(スケジューリングに影響)", "label": "プロバイダー種別", @@ -402,7 +416,7 @@ "disableHint": "0に設定するとタイムアウトを無効にします(カナリアロールバックシナリオのみ、非推奨)", "nonStreamingTotal": { "core": "true", - "desc": "非ストリーミングリクエストの総タイムアウト、範囲60~1200秒、0で無効化(デフォルト: 無制限)", + "desc": "非ストリーミングリクエストの総タイムアウト、範囲60~1800秒、0で無効化(デフォルト: 無制限)", "label": "非ストリーミング総タイムアウト(秒)", "placeholder": "0" }, diff --git a/messages/ja/settings/providers/list.json b/messages/ja/settings/providers/list.json index aeb44a4d2..3a80c9c6e 100644 --- a/messages/ja/settings/providers/list.json +++ b/messages/ja/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "使用量リセット", "actionDelete": "削除", "selectProvider": "{name} を選択", - "schedule": "スケジュール" + "schedule": "スケジュール", + "proxyEnabled": "プロキシ有効" } diff --git a/messages/ja/ui.json b/messages/ja/ui.json index 20b712f6e..1e205086d 100644 --- a/messages/ja/ui.json +++ b/messages/ja/ui.json @@ -52,7 +52,8 @@ "maxTags": "最大タグ数に達しました", "tooLong": "タグの長さは{max}文字を超えることはできません", "invalidFormat": "タグには英数字、アンダースコア、ハイフンのみ使用できます", - "removeTag": "タグ {tag} を削除" + "removeTag": "タグ {tag} を削除", + "unknownError": "無効な入力" }, "providerGroupSelect": { "placeholder": "プロバイダーグループを選択", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index a8c5688bb..1af0a96f2 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -184,6 +184,7 @@ "title": "Детали запроса", "statusTitle": "Статус: {status}", "inProgress": "В процессе", + "retrying": "Retrying", "unknown": "Неизвестно", "success": "Запрос успешно выполнен", "error": "Запрос не выполнен, ниже подробная информация об ошибке и цепочке решений поставщика", @@ -456,7 +457,8 @@ "avgTtfbMs": "Средний TTFB", "avgTokensPerSecond": "Средн. ток/с", "avgCostPerRequest": "Ср. стоимость/запрос", - "avgCostPerMillionTokens": "Ср. стоимость/1М токенов" + "avgCostPerMillionTokens": "Ср. стоимость/1М токенов", + "unknownModel": "Неизвестно" }, "expandModelStats": "Развернуть модели", "collapseModelStats": "Свернуть модели", @@ -479,6 +481,38 @@ "filters": { "userTagsPlaceholder": "Фильтр по тегам пользователей...", "userGroupsPlaceholder": "Фильтр по группам пользователей..." + }, + "userInsights": { + "title": "Аналитика пользователя", + "backToLeaderboard": "Вернуться к рейтингу", + "overview": "Обзор", + "keyTrend": "Тренд использования ключей", + "modelBreakdown": "Разбивка по моделям", + "todayRequests": "Запросы за сегодня", + "todayCost": "Стоимость за сегодня", + "avgResponseTime": "Среднее время ответа", + "errorRate": "Частота ошибок", + "timeRange": { + "today": "Сегодня", + "7days": "Последние 7 дней", + "30days": "Последние 30 дней", + "thisMonth": "Этот месяц" + }, + "unknownModel": "Неизвестная модель", + "noData": "Нет данных", + "dateRange": "Диапазон дат", + "allTime": "Всё время", + "providerBreakdown": "Разбивка по провайдерам", + "unknownProvider": "Неизвестный провайдер", + "apiKey": "API ключ", + "provider": "Провайдер", + "model": "Модель", + "allKeys": "Все ключи", + "allProviders": "Все провайдеры", + "allModels": "Все модели", + "dimensions": "Измерения", + "filters": "Фильтры", + "loadError": "Не удалось загрузить данные" } }, "sessions": { @@ -1041,7 +1075,7 @@ "label": "Разрешённые клиенты", "description": "Ограничьте, какие CLI/IDE клиенты могут использовать эту учётную запись. Пусто = без ограничений.", "customLabel": "Пользовательские шаблоны клиентов", - "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')" + "customPlaceholder": "Введите шаблон (например, 'my-ide', 'codex-*')" }, "allowedModels": { "label": "Разрешённые модели", @@ -1522,6 +1556,21 @@ "deleteFailed": "Не удалось удалить пользователя", "userDeleted": "Пользователь удален", "saving": "Сохранение...", + "resetSection": { + "title": "Параметры сброса" + }, + "resetLimits": { + "title": "Сброс лимитов", + "description": "Сбросить накопленные счетчики расходов для всех лимитов. Логи запросов и статистика сохраняются.", + "button": "Сбросить лимиты", + "confirmTitle": "Сбросить только лимиты?", + "confirmDescription": "Все накопленные счетчики расходов (5ч, дневной, недельный, месячный, общий) будут обнулены. Логи запросов и статистика использования сохранятся.", + "confirm": "Да, сбросить лимиты", + "loading": "Сброс...", + "error": "Не удалось сбросить лимиты", + "success": "Все лимиты сброшены", + "lastResetAt": "Последний сброс: {date}" + }, "resetData": { "title": "Сброс статистики", "description": "Удалить все логи запросов и данные использования для этого пользователя. Это действие необратимо.", @@ -1838,15 +1887,15 @@ "label": "Ограничения клиентов", "description": "Ограничьте, какие CLI/IDE клиенты могут использовать эту учетную запись. Пусто = без ограничений.", "customLabel": "Пользовательские шаблоны клиентов", - "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')", - "customHelp": "Пользовательские шаблоны проверяются по вхождению в User-Agent без учёта регистра. '-' и '_' считаются эквивалентными." + "customPlaceholder": "Введите шаблон (например, 'my-ide', 'codex-*')", + "customHelp": "Без '*': поиск подстроки без учёта регистра ('-' и '_' игнорируются). С '*': glob-шаблон от начала UA (используйте *foo* для поиска в любом месте; без учёта регистра, символы буквальные)." }, "blockedClients": { "label": "Заблокированные клиенты", "description": "Клиенты, соответствующие этим шаблонам, будут отклонены, даже если они соответствуют разрешённым.", "customLabel": "Пользовательский шаблон блокировки", - "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')", - "customHelp": "Пользовательские шаблоны проверяются по вхождению в User-Agent без учёта регистра. '-' и '_' считаются эквивалентными." + "customPlaceholder": "Введите шаблон (например, 'my-ide', 'codex-*')", + "customHelp": "Без '*': поиск подстроки без учёта регистра ('-' и '_' игнорируются). С '*': glob-шаблон от начала UA (используйте *foo* для поиска в любом месте; без учёта регистра, символы буквальные)." }, "allowedModels": { "label": "Ограничения моделей", @@ -1883,7 +1932,17 @@ "claude-code-sdk-ts": "Claude Code SDK (TypeScript)", "claude-code-sdk-py": "Claude Code SDK (Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "Все", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "Выбрано: {count}" }, "keyEditSection": { "sections": { diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index ebe5d8629..ff2342578 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -41,7 +41,12 @@ "http2Fallback": "Откат HTTP/2", "clientError": "Ошибка клиента", "endpointPoolExhausted": "Пул конечных точек исчерпан", - "vendorTypeAllTimeout": "Тайм-аут всех конечных точек" + "vendorTypeAllTimeout": "Тайм-аут всех конечных точек", + "hedgeTriggered": "Hedge запущен", + "hedgeLaunched": "Hedge альтернатива запущена", + "hedgeWinner": "Победитель Hedge-гонки", + "hedgeLoserCancelled": "Проигравший Hedge-гонки (отменён)", + "clientAbort": "Клиент прервал запрос" }, "reasons": { "request_success": "Успешно", @@ -56,7 +61,12 @@ "initial_selection": "Первоначальный выбор", "endpoint_pool_exhausted": "Пул конечных точек исчерпан", "vendor_type_all_timeout": "Тайм-аут всех конечных точек типа поставщика", - "client_restriction_filtered": "Клиент ограничен" + "client_restriction_filtered": "Клиент ограничен", + "hedge_triggered": "Hedge запущен", + "hedge_launched": "Hedge альтернатива запущена", + "hedge_winner": "Победитель Hedge-гонки", + "hedge_loser_cancelled": "Проигравший Hedge-гонки (отменён)", + "client_abort": "Клиент прервал запрос" }, "filterReasons": { "rate_limited": "Ограничение скорости", @@ -222,7 +232,14 @@ "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечных точек, провайдер пропущен без отката", "strictBlockSelectorError": "Строгий режим: ошибка селектора конечных точек, провайдер пропущен без отката", "vendorTypeAllTimeout": "Тайм-аут всех конечных точек типа поставщика (524)", - "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика." + "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика.", + "hedgeTriggered": "Порог Hedge превышен (запускается альтернативный провайдер)", + "hedgeLaunched": "Hedge альтернативный провайдер запущен", + "hedgeWinner": "Победитель Hedge-гонки (первый получил начальный байт)", + "hedgeLoserCancelled": "Проигравший Hedge-гонки (запрос отменён)", + "clientAbort": "Клиент отключился (запрос прерван)", + "hedgeRace": "Hedge-гонка", + "hedgeThresholdExceeded": "Тайм-аут первого байта превышен, запущен альтернативный провайдер" }, "selectionMethods": { "session_reuse": "Повторное использование сессии", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 00d6b4805..ae0f32e13 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -55,6 +55,8 @@ "enableThinkingBudgetRectifierDesc": "Если Anthropic-провайдер возвращает ошибку budget_tokens < 1024, автоматически устанавливает thinking budget на максимум (32000) и при необходимости max_tokens на 64000, затем повторяет запрос один раз (включено по умолчанию).", "enableBillingHeaderRectifier": "Включить исправление billing-заголовка", "enableBillingHeaderRectifierDesc": "Проактивно удаляет текстовые блоки x-anthropic-billing-header, добавленные клиентом Claude Code в системный промпт, предотвращая ошибки 400 от Amazon Bedrock и других не-Anthropic провайдеров (включено по умолчанию).", + "enableResponseInputRectifier": "Включить исправление Response Input", + "enableResponseInputRectifierDesc": "Автоматически нормализует не-массивные input (строковые сокращения или одиночные объекты сообщений с role/type) в запросах /v1/responses в стандартный формат массива перед обработкой (включено по умолчанию).", "enableCodexSessionIdCompletion": "Включить дополнение Session ID для Codex", "enableCodexSessionIdCompletionDesc": "Если в Codex-запросе присутствует только session_id (в заголовках) или prompt_cache_key (в теле), автоматически дополняет отсутствующее поле. Если оба отсутствуют, генерирует UUID v7 и стабильно переиспользует его в рамках одного диалога.", "enableClaudeMetadataUserIdInjection": "Включить инъекцию Claude metadata.user_id", diff --git a/messages/ru/settings/data.json b/messages/ru/settings/data.json index b7cc324fc..1e3a8d576 100644 --- a/messages/ru/settings/data.json +++ b/messages/ru/settings/data.json @@ -28,8 +28,10 @@ "default": "{days} дней назад" }, "rangeLabel": "Диапазон очистки", + "softDeletePurged": "Также удалено {count} мягко удаленных записей", "statisticsRetained": "✓ Статистические данные будут сохранены (для анализа трендов)", "successMessage": "Успешно очищено {count} записей логов ({batches} пакетов, заняло {duration}с)", + "vacuumComplete": "Дисковое пространство БД освобождено", "willClean": "Будут очищены все записи логов с {range}" }, "description": "Управление резервной копией и восстановлением БД с полным импортом/экспортом и очисткой логов.", diff --git a/messages/ru/settings/prices.json b/messages/ru/settings/prices.json index 70a8d8cd2..261a598c4 100644 --- a/messages/ru/settings/prices.json +++ b/messages/ru/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "Локальная" + "local": "Локальная", + "multi": "Мульти" }, "capabilities": { "assistantPrefill": "Предзаполнение ассистента", diff --git a/messages/ru/settings/providers/batchEdit.json b/messages/ru/settings/providers/batchEdit.json index 026e2275f..986226327 100644 --- a/messages/ru/settings/providers/batchEdit.json +++ b/messages/ru/settings/providers/batchEdit.json @@ -1,37 +1,46 @@ { + "selectedCount": "Выбрано: {count}", + "actions": { + "edit": "Редактировать", + "resetCircuit": "Сбросить предохранитель", + "delete": "Удалить" + }, "enterMode": "Массовое редактирование", - "exitMode": "Выход", + "selectionHint": "Выберите провайдеров для массового редактирования", "selectAll": "Выбрать все", "invertSelection": "Инвертировать", - "selectedCount": "Выбрано: {count}", - "editSelected": "Редактировать выбранные", - "selectByType": "Выбрать по типу", + "selectByType": "По типу", "selectByTypeItem": "{type} ({count})", - "selectByGroup": "Выбрать по группе", + "selectByGroup": "По группе", "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "Редактировать", - "delete": "Удалить", - "resetCircuit": "Сбросить прерыватель" - }, + "editSelected": "Редактировать выбранные", + "exitMode": "Выход", "dialog": { - "editTitle": "Массовое редактирование поставщиков", - "editDesc": "Изменения будут применены к {count} поставщикам", - "deleteTitle": "Удалить поставщиков", - "deleteDesc": "Удалить {count} поставщиков навсегда?", - "resetCircuitTitle": "Сбросить прерыватели", - "resetCircuitDesc": "Сбросить прерыватель для {count} поставщиков?", - "next": "Далее", - "noFieldEnabled": "Пожалуйста, включите хотя бы одно поле для обновления" + "editTitle": "Массовое редактирование провайдеров", + "editDesc": "Редактирование настроек для {count} провайдеров", + "deleteTitle": "Удаление провайдеров", + "deleteDesc": "Вы уверены, что хотите удалить {count} провайдеров? Это действие нельзя отменить.", + "resetCircuitTitle": "Сброс предохранителей", + "resetCircuitDesc": "Сбросить предохранители для {count} провайдеров?", + "next": "Далее" }, - "sections": { - "basic": "Основные настройки", - "routing": "Группы и маршрутизация", - "anthropic": "Настройки Anthropic" + "preview": { + "title": "Предпросмотр изменений", + "description": "Проверка изменений для {count} провайдеров", + "loading": "Загрузка предпросмотра...", + "noChanges": "Нет изменений для применения", + "summary": "Затронуто {providerCount} провайдеров, изменено {fieldCount} полей, пропущено {skipCount}", + "excludeProvider": "Переключить включение провайдера", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: пропущено ({reason})", + "nullValue": "(пусто)", + "back": "Назад", + "apply": "Применить изменения" }, "fields": { "isEnabled": { - "label": "Статус", + "label": "Включен", "noChange": "Без изменений", "enable": "Включить", "disable": "Отключить" @@ -40,69 +49,52 @@ "weight": "Вес", "costMultiplier": "Множитель стоимости", "groupTag": { - "label": "Тег группы", - "clear": "Очистить" + "label": "Тег группы" }, "modelRedirects": "Перенаправление моделей", - "allowedModels": "Разрешённые модели", - "thinkingBudget": "Бюджет мышления", - "adaptiveThinking": "Адаптивное мышление", - "activeTimeStart": "Время начала расписания", - "activeTimeEnd": "Время окончания расписания" + "allowedModels": "Разрешенные модели", + "thinkingBudget": "Бюджет размышлений", + "adaptiveThinking": "Адаптивное мышление" }, - "affectedProviders": { - "title": "Затронутые поставщики", - "more": "+{count} ещё" + "toast": { + "previewFailed": "Ошибка предпросмотра: {error}", + "unknownError": "Неизвестная ошибка", + "updated": "Обновлено провайдеров: {count}", + "undo": "Отменить", + "undoSuccess": "Восстановлено провайдеров: {count}", + "undoFailed": "Ошибка отмены: {error}", + "failed": "Ошибка операции: {error}", + "circuitReset": "Сброшено предохранителей: {count}" + }, + "undo": { + "batchDeleteSuccess": "Удалено провайдеров: {count}", + "button": "Отменить", + "batchDeleteUndone": "Восстановлено провайдеров: {count}", + "singleEditSuccess": "Провайдер обновлен", + "singleEditUndone": "Обновление провайдера отменено", + "singleDeleteSuccess": "Провайдер удален", + "singleDeleteUndone": "Провайдер восстановлен", + "expired": "Отмена истекла", + "failed": "Ошибка отмены" }, "confirm": { - "title": "Подтвердите операцию", "cancel": "Отмена", - "confirm": "Подтвердить", "goBack": "Назад", + "confirm": "Подтвердить", "processing": "Обработка..." }, - "preview": { - "title": "Предпросмотр изменений", - "description": "Проверьте изменения перед применением к {count} поставщикам", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "null", - "fieldSkipped": "{field}: Пропущено ({reason})", - "excludeProvider": "Исключить", - "summary": "{providerCount} поставщиков, {fieldCount} изменений, {skipCount} пропущено", - "noChanges": "Нет изменений для применения", - "apply": "Применить изменения", - "back": "Вернуться к редактированию", - "loading": "Генерация предпросмотра..." + "affectedProviders": { + "title": "Затронутые провайдеры", + "more": "и еще {count}" }, "batchNotes": { "codexOnly": "Только Codex", "claudeOnly": "Только Claude", "geminiOnly": "Только Gemini" }, - "selectionHint": "Выберите нескольких поставщиков для массовых операций", - "undo": { - "button": "Отменить", - "success": "Операция успешно отменена", - "expired": "Время отмены истекло", - "batchDeleteSuccess": "Удалено поставщиков: {count}", - "batchDeleteUndone": "Восстановлено поставщиков: {count}", - "singleDeleteSuccess": "Поставщик удалён", - "singleDeleteUndone": "Поставщик восстановлен", - "singleEditSuccess": "Поставщик обновлён", - "singleEditUndone": "Изменения отменены", - "failed": "Ошибка отмены" - }, - "toast": { - "updated": "Обновлено поставщиков: {count}", - "deleted": "Удалено поставщиков: {count}", - "circuitReset": "Сброшено прерывателей: {count}", - "failed": "Операция не удалась: {error}", - "undo": "Отменить", - "undoSuccess": "Восстановлено поставщиков: {count}", - "undoFailed": "Отмена не удалась: {error}", - "undoExpired": "Время отмены истекло", - "previewFailed": "Предпросмотр не удался: {error}", - "unknownError": "Неизвестная ошибка" + "mixedValues": { + "label": "(смешанные значения)", + "tooltip": "Выбранные провайдеры имеют разные значения:", + "andMore": "...и еще {count}" } } diff --git a/messages/ru/settings/providers/form/common.json b/messages/ru/settings/providers/form/common.json index a294a3792..0a61209ed 100644 --- a/messages/ru/settings/providers/form/common.json +++ b/messages/ru/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "Лимиты", "network": "Сеть", "testing": "Тестирование", + "scheduling": "Планирование", + "options": "Параметры", + "activeTime": "Активное время", + "circuitBreaker": "Автовыключатель", + "timeout": "Таймаут", "stepProgress": "Прогресс шагов" } } diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index da7e25dc5..588f7771e 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -331,19 +331,33 @@ "customAllowedPlaceholder": "напр. my-ide, internal-tool", "customBlockedLabel": "Пользовательские шаблоны блокировки", "customBlockedPlaceholder": "напр. legacy-client", - "customHelp": "Пользовательские шаблоны проверяются по вхождению в User-Agent без учёта регистра. '-' и '_' считаются эквивалентными.", + "customHelp": "Без '*': поиск подстроки без учёта регистра ('-' и '_' игнорируются). С '*': glob-шаблон от начала UA (используйте *foo* для поиска в любом месте; без учёта регистра, символы буквальные).", "presetClients": { "claude-code": "Claude Code (все)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "Все", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "Выбрано: {count}" }, "preserveClientIp": { "desc": "Передавать x-forwarded-for / x-real-ip в апстрим (может раскрыть реальный IP клиента)", "help": "По умолчанию выключено для приватности. Включайте только если апстриму нужен IP пользователя.", "label": "Пробрасывать IP клиента" }, + "options": { + "title": "Параметры", + "desc": "Дополнительные параметры и переопределения провайдера" + }, "providerType": { "desc": "(определяет политику выбора)", "label": "Тип провайдера", @@ -402,7 +416,7 @@ "disableHint": "Установите 0 для отключения тайм-аута (только для сценариев отката канарейки, не рекомендуется)", "nonStreamingTotal": { "core": "true", - "desc": "Полный тайм-аут непотоковой передачи, диапазон 60-1200 секунд, 0 для отключения (по умолчанию: без ограничений)", + "desc": "Полный тайм-аут непотоковой передачи, диапазон 60-1800 секунд, 0 для отключения (по умолчанию: без ограничений)", "label": "Полный тайм-аут непотоковой передачи (секунды)", "placeholder": "0" }, diff --git a/messages/ru/settings/providers/list.json b/messages/ru/settings/providers/list.json index 8de44f407..1265c8bd5 100644 --- a/messages/ru/settings/providers/list.json +++ b/messages/ru/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "Сбросить использование", "actionDelete": "Удалить", "selectProvider": "Выбрать {name}", - "schedule": "Расписание" + "schedule": "Расписание", + "proxyEnabled": "Прокси включен" } diff --git a/messages/ru/ui.json b/messages/ru/ui.json index 1558a800a..a622829b8 100644 --- a/messages/ru/ui.json +++ b/messages/ru/ui.json @@ -52,7 +52,8 @@ "maxTags": "Достигнуто максимальное количество тегов", "tooLong": "Длина тега не может превышать {max} символов", "invalidFormat": "Теги могут содержать только буквы, цифры, подчеркивания и дефисы", - "removeTag": "Удалить тег {tag}" + "removeTag": "Удалить тег {tag}", + "unknownError": "Некорректный ввод" }, "providerGroupSelect": { "placeholder": "Выберите группы провайдеров", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 5b172f782..b4cea4419 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -184,6 +184,7 @@ "title": "请求详情", "statusTitle": "状态: {status}", "inProgress": "请求中", + "retrying": "Retrying", "unknown": "未知", "success": "请求成功完成", "error": "请求失败,以下是详细的错误信息和供应商决策链", @@ -456,7 +457,8 @@ "avgTtfbMs": "平均 TTFB", "avgTokensPerSecond": "平均输出速率", "avgCostPerRequest": "平均单次请求成本", - "avgCostPerMillionTokens": "平均百万 Token 成本" + "avgCostPerMillionTokens": "平均百万 Token 成本", + "unknownModel": "未知" }, "expandModelStats": "展开模型详情", "collapseModelStats": "收起模型详情", @@ -479,6 +481,38 @@ "filters": { "userTagsPlaceholder": "按用户标签筛选...", "userGroupsPlaceholder": "按用户分组筛选..." + }, + "userInsights": { + "title": "用户洞察", + "backToLeaderboard": "返回排行榜", + "overview": "概览", + "keyTrend": "Key 使用趋势", + "modelBreakdown": "模型明细", + "todayRequests": "今日请求", + "todayCost": "今日费用", + "avgResponseTime": "平均响应时间", + "errorRate": "错误率", + "timeRange": { + "today": "今日", + "7days": "近 7 天", + "30days": "近 30 天", + "thisMonth": "本月" + }, + "unknownModel": "未知模型", + "noData": "暂无数据", + "dateRange": "日期范围", + "allTime": "全部时间", + "providerBreakdown": "供应商明细", + "unknownProvider": "未知供应商", + "apiKey": "API 密钥", + "provider": "供应商", + "model": "模型", + "allKeys": "全部密钥", + "allProviders": "全部供应商", + "allModels": "全部模型", + "dimensions": "维度", + "filters": "筛选", + "loadError": "数据加载失败" } }, "sessions": { @@ -1053,7 +1087,7 @@ "label": "允许的客户端", "description": "限制哪些 CLI/IDE 客户端可以使用此账户。留空表示无限制。", "customLabel": "自定义客户端模式", - "customPlaceholder": "输入自定义模式(如:'xcode', 'my-ide')" + "customPlaceholder": "输入模式(如 'my-ide'、'codex-*')" }, "allowedModels": { "label": "允许的模型", @@ -1540,6 +1574,21 @@ "deleteFailed": "删除用户失败", "userDeleted": "用户已删除", "saving": "保存中...", + "resetSection": { + "title": "重置选项" + }, + "resetLimits": { + "title": "重置限额", + "description": "重置所有限额的累计消费计数器。请求日志和统计数据将被保留。", + "button": "重置限额", + "confirmTitle": "仅重置限额?", + "confirmDescription": "这将把所有累计消费计数器(5小时、每日、每周、每月、总计)归零。请求日志和使用统计将被保留。", + "confirm": "是的,重置限额", + "loading": "正在重置...", + "error": "重置限额失败", + "success": "所有限额已重置", + "lastResetAt": "上次重置: {date}" + }, "resetData": { "title": "重置统计", "description": "删除该用户的所有请求日志和使用数据。此操作不可逆。", @@ -1813,15 +1862,15 @@ "label": "客户端限制", "description": "限制哪些 CLI/IDE 客户端可以使用此账户。留空表示无限制。", "customLabel": "自定义客户端模式", - "customPlaceholder": "输入自定义模式(如:'xcode', 'my-ide')", - "customHelp": "自定义模式按 User-Agent 包含匹配(不区分大小写),并将 '-' 与 '_' 视为等价。" + "customPlaceholder": "输入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不区分大小写的子串匹配(忽略 '-' 和 '_')。含 '*':从 UA 开头的 glob 通配符匹配(用 *foo* 匹配任意位置;不区分大小写,字符按原样匹配)。" }, "blockedClients": { "label": "黑名单客户端", "description": "匹配这些模式的客户端将被拒绝,即使它们也匹配白名单。", "customLabel": "自定义黑名单模式", - "customPlaceholder": "输入模式(如 'xcode'、'my-ide')", - "customHelp": "自定义模式按 User-Agent 包含匹配(不区分大小写),并将 '-' 与 '_' 视为等价。" + "customPlaceholder": "输入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不区分大小写的子串匹配(忽略 '-' 和 '_')。含 '*':从 UA 开头的 glob 通配符匹配(用 *foo* 匹配任意位置;不区分大小写,字符按原样匹配)。" }, "allowedModels": { "label": "模型限制", @@ -1858,7 +1907,17 @@ "claude-code-sdk-ts": "Claude Code SDK(TypeScript)", "claude-code-sdk-py": "Claude Code SDK(Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已选 {count} 项" }, "keyEditSection": { "sections": { diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index eecf293af..a5cb68851 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -41,7 +41,12 @@ "http2Fallback": "HTTP/2 回退", "clientError": "客户端错误", "endpointPoolExhausted": "端点池耗尽", - "vendorTypeAllTimeout": "供应商类型全端点超时" + "vendorTypeAllTimeout": "供应商类型全端点超时", + "hedgeTriggered": "Hedge 已触发", + "hedgeLaunched": "Hedge 备选已启动", + "hedgeWinner": "Hedge 竞速赢家", + "hedgeLoserCancelled": "Hedge 竞速输家(已取消)", + "clientAbort": "客户端中断" }, "reasons": { "request_success": "成功", @@ -56,7 +61,12 @@ "initial_selection": "首次选择", "endpoint_pool_exhausted": "端点池耗尽", "vendor_type_all_timeout": "供应商类型全端点超时", - "client_restriction_filtered": "客户端受限" + "client_restriction_filtered": "客户端受限", + "hedge_triggered": "Hedge 已触发", + "hedge_launched": "Hedge 备选已启动", + "hedge_winner": "Hedge 竞速赢家", + "hedge_loser_cancelled": "Hedge 竞速输家(已取消)", + "client_abort": "客户端中断" }, "filterReasons": { "rate_limited": "速率限制", @@ -222,7 +232,14 @@ "strictBlockNoEndpoints": "严格模式:无可用端点候选,跳过该供应商且不降级", "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级", "vendorTypeAllTimeout": "供应商类型全端点超时(524)", - "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。" + "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。", + "hedgeTriggered": "Hedge 阈值超出(正在启动备选供应商)", + "hedgeLaunched": "Hedge 备选供应商已启动", + "hedgeWinner": "Hedge 竞速赢家(最先收到首字节)", + "hedgeLoserCancelled": "Hedge 竞速输家(请求已取消)", + "clientAbort": "客户端已断开连接(请求中断)", + "hedgeRace": "Hedge 竞速", + "hedgeThresholdExceeded": "首字节超时,已启动备选供应商" }, "selectionMethods": { "session_reuse": "会话复用", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 981b1fd34..53ac66081 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -44,6 +44,8 @@ "enableThinkingBudgetRectifierDesc": "当 Anthropic 类型供应商返回 budget_tokens < 1024 错误时,自动将 thinking 预算设为最大值(32000),并在需要时将 max_tokens 设为 64000,然后重试一次(默认开启)。", "enableBillingHeaderRectifier": "启用计费标头整流器", "enableBillingHeaderRectifierDesc": "主动移除 Claude Code 客户端注入到 system 提示中的 x-anthropic-billing-header 文本块,防止 Amazon Bedrock 等非原生 Anthropic 上游返回 400 错误(默认开启)。", + "enableResponseInputRectifier": "启用 Response Input 整流器", + "enableResponseInputRectifierDesc": "自动将 /v1/responses 请求中的非数组 input(字符串简写或带 role/type 的单消息对象)规范化为标准数组格式后再处理(默认开启)。", "enableCodexSessionIdCompletion": "启用 Codex Session ID 补全", "enableCodexSessionIdCompletionDesc": "当 Codex 请求仅提供 session_id(请求头)或 prompt_cache_key(请求体)之一时,自动补全另一个;若两者均缺失,则生成 UUID v7 会话 ID,并在同一对话内稳定复用。", "enableClaudeMetadataUserIdInjection": "启用 Claude metadata.user_id 注入", diff --git a/messages/zh-CN/settings/data.json b/messages/zh-CN/settings/data.json index 84ebc0a66..d7219348f 100644 --- a/messages/zh-CN/settings/data.json +++ b/messages/zh-CN/settings/data.json @@ -40,7 +40,9 @@ "cancel": "取消", "confirm": "确认清理", "cleaning": "正在清理...", + "softDeletePurged": "另外清除了 {count} 条软删除记录", "successMessage": "成功清理 {count} 条日志记录({batches} 批次,耗时 {duration}s)", + "vacuumComplete": "数据库空间已回收", "failed": "清理失败", "error": "清理日志失败", "descriptionWarning": "清理历史日志数据以释放数据库存储空间。注意:统计数据将被保留,但日志详情将被永久删除。" diff --git a/messages/zh-CN/settings/providers/batchEdit.json b/messages/zh-CN/settings/providers/batchEdit.json index dff49239a..f3978ef7d 100644 --- a/messages/zh-CN/settings/providers/batchEdit.json +++ b/messages/zh-CN/settings/providers/batchEdit.json @@ -1,108 +1,100 @@ { + "selectedCount": "已选择 {count} 个", + "actions": { + "edit": "编辑", + "resetCircuit": "重置熔断器", + "delete": "删除" + }, "enterMode": "批量编辑", - "exitMode": "退出", + "selectionHint": "选择供应商进行批量编辑", "selectAll": "全选", "invertSelection": "反选", - "selectedCount": "已选 {count} 项", - "editSelected": "编辑选中项", - "selectByType": "按类型选择", + "selectByType": "按类型", "selectByTypeItem": "{type} ({count})", - "selectByGroup": "按分组选择", + "selectByGroup": "按分组", "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "编辑", - "delete": "删除", - "resetCircuit": "重置熔断" - }, + "editSelected": "编辑所选", + "exitMode": "退出", "dialog": { "editTitle": "批量编辑供应商", - "editDesc": "修改将应用于 {count} 个供应商", + "editDesc": "编辑 {count} 个供应商的设置", "deleteTitle": "删除供应商", - "deleteDesc": "确定永久删除 {count} 个供应商?", + "deleteDesc": "确定要删除 {count} 个供应商吗?此操作无法撤销。", "resetCircuitTitle": "重置熔断器", - "resetCircuitDesc": "确定重置 {count} 个供应商的熔断器?", - "next": "下一步", - "noFieldEnabled": "请至少启用一个要更新的字段" + "resetCircuitDesc": "确定要重置 {count} 个供应商的熔断器吗?", + "next": "下一步" }, - "sections": { - "basic": "基本设置", - "routing": "分组与路由", - "anthropic": "Anthropic 设置" + "preview": { + "title": "预览变更", + "description": "检查 {count} 个供应商的变更", + "loading": "正在加载预览...", + "noChanges": "没有需要应用的变更", + "summary": "影响 {providerCount} 个供应商,变更 {fieldCount} 个字段,跳过 {skipCount} 个", + "excludeProvider": "切换供应商包含状态", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: 已跳过 ({reason})", + "nullValue": "(空)", + "back": "返回", + "apply": "应用变更" }, "fields": { "isEnabled": { - "label": "状态", + "label": "启用状态", "noChange": "不修改", "enable": "启用", "disable": "禁用" }, "priority": "优先级", "weight": "权重", - "costMultiplier": "价格倍率", + "costMultiplier": "成本倍数", "groupTag": { - "label": "分组标签", - "clear": "清除" + "label": "分组标签" }, "modelRedirects": "模型重定向", "allowedModels": "允许的模型", - "thinkingBudget": "思维预算", - "adaptiveThinking": "自适应思维", - "activeTimeStart": "调度开始时间", - "activeTimeEnd": "调度结束时间" + "thinkingBudget": "思考预算", + "adaptiveThinking": "自适应思考" }, - "affectedProviders": { - "title": "受影响的供应商", - "more": "+{count} 更多" + "toast": { + "previewFailed": "预览失败: {error}", + "unknownError": "未知错误", + "updated": "已更新 {count} 个供应商", + "undo": "撤销", + "undoSuccess": "已恢复 {count} 个供应商", + "undoFailed": "撤销失败: {error}", + "failed": "操作失败: {error}", + "circuitReset": "已重置 {count} 个熔断器" + }, + "undo": { + "batchDeleteSuccess": "已删除 {count} 个供应商", + "button": "撤销", + "batchDeleteUndone": "已恢复 {count} 个供应商", + "singleEditSuccess": "供应商已更新", + "singleEditUndone": "已撤销供应商更新", + "singleDeleteSuccess": "供应商已删除", + "singleDeleteUndone": "供应商已恢复", + "expired": "撤销已过期", + "failed": "撤销失败" }, "confirm": { - "title": "确认操作", "cancel": "取消", - "confirm": "确认", "goBack": "返回", + "confirm": "确认", "processing": "处理中..." }, - "preview": { - "title": "预览变更", - "description": "将变更应用到 {count} 个供应商前请先确认", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "空", - "fieldSkipped": "{field}: 已跳过 ({reason})", - "excludeProvider": "排除", - "summary": "{providerCount} 个供应商, {fieldCount} 项变更, {skipCount} 项跳过", - "noChanges": "没有可应用的变更", - "apply": "应用变更", - "back": "返回编辑", - "loading": "正在生成预览..." + "affectedProviders": { + "title": "受影响的供应商", + "more": "还有 {count} 个" }, "batchNotes": { - "codexOnly": "仅 Codex", - "claudeOnly": "仅 Claude", - "geminiOnly": "仅 Gemini" + "codexOnly": "仅限 Codex", + "claudeOnly": "仅限 Claude", + "geminiOnly": "仅限 Gemini" }, - "selectionHint": "选择多个服务商后可进行批量操作", - "undo": { - "button": "撤销", - "success": "操作已成功撤销", - "expired": "撤销窗口已过期", - "batchDeleteSuccess": "已删除 {count} 个供应商", - "batchDeleteUndone": "已恢复 {count} 个供应商", - "singleDeleteSuccess": "供应商已删除", - "singleDeleteUndone": "供应商已恢复", - "singleEditSuccess": "供应商已更新", - "singleEditUndone": "更改已回退", - "failed": "撤销失败" - }, - "toast": { - "updated": "已更新 {count} 个供应商", - "deleted": "已删除 {count} 个供应商", - "circuitReset": "已重置 {count} 个熔断器", - "failed": "操作失败: {error}", - "undo": "撤销", - "undoSuccess": "已还原 {count} 个供应商", - "undoFailed": "撤销失败: {error}", - "undoExpired": "撤销窗口已过期", - "previewFailed": "预览失败: {error}", - "unknownError": "未知错误" + "mixedValues": { + "label": "(混合值)", + "tooltip": "选中的供应商有不同的值:", + "andMore": "...还有 {count} 个" } } diff --git a/messages/zh-CN/settings/providers/form/common.json b/messages/zh-CN/settings/providers/form/common.json index ea5277908..fd5e10d2f 100644 --- a/messages/zh-CN/settings/providers/form/common.json +++ b/messages/zh-CN/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "限制", "network": "网络", "testing": "测试", + "scheduling": "调度", + "options": "选项", + "activeTime": "活跃时间", + "circuitBreaker": "断路器", + "timeout": "超时", "stepProgress": "步骤进度" } } diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index adc1d8060..3d4eef05f 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -40,6 +40,10 @@ "desc": "向上游转发 x-forwarded-for / x-real-ip,可能暴露真实来源 IP", "help": "默认关闭以保护隐私;仅在需要上游感知终端 IP 时开启。" }, + "options": { + "title": "选项", + "desc": "附加供应商选项和覆写设置" + }, "modelWhitelist": { "title": "模型白名单", "desc": "限制此供应商可以处理的模型。默认情况下,供应商可以处理该类型下的所有模型。", @@ -63,13 +67,23 @@ "customAllowedPlaceholder": "例如 my-ide、internal-tool", "customBlockedLabel": "自定义黑名单模式", "customBlockedPlaceholder": "例如 legacy-client", - "customHelp": "自定义模式使用 User-Agent 包含匹配(不区分大小写),并将 '-' 与 '_' 视为等价。", + "customHelp": "不含 '*':不区分大小写的子串匹配(忽略 '-' 和 '_')。含 '*':从 UA 开头的 glob 通配符匹配(用 *foo* 匹配任意位置;不区分大小写,字符按原样匹配)。", "presetClients": { "claude-code": "Claude Code(全部)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已选 {count} 项" }, "scheduleParams": { "title": "调度参数", @@ -374,7 +388,7 @@ "nonStreamingTotal": { "label": "非流式总超时(秒)", "placeholder": "0", - "desc": "非流式请求总超时,范围 60-1200 秒,填 0 禁用(默认不限制)", + "desc": "非流式请求总超时,范围 60-1800 秒,填 0 禁用(默认不限制)", "core": "true" }, "disableHint": "设为 0 表示禁用该超时(仅用于灰度回退场景,不推荐)" diff --git a/messages/zh-CN/settings/providers/list.json b/messages/zh-CN/settings/providers/list.json index 04331a7d9..dcb54db1f 100644 --- a/messages/zh-CN/settings/providers/list.json +++ b/messages/zh-CN/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "重置用量", "actionDelete": "删除", "selectProvider": "选择 {name}", - "schedule": "调度" + "schedule": "调度", + "proxyEnabled": "已启用代理" } diff --git a/messages/zh-CN/ui.json b/messages/zh-CN/ui.json index 2a092714a..2377fb69d 100644 --- a/messages/zh-CN/ui.json +++ b/messages/zh-CN/ui.json @@ -52,7 +52,8 @@ "maxTags": "已达到最大标签数量", "tooLong": "标签长度不能超过 {max} 个字符", "invalidFormat": "标签只能包含字母、数字、下划线和连字符", - "removeTag": "移除标签 {tag}" + "removeTag": "移除标签 {tag}", + "unknownError": "输入无效" }, "providerGroupSelect": { "placeholder": "选择供应商分组", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index ab0075317..cecc8daa7 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -184,6 +184,7 @@ "title": "請求詳情", "statusTitle": "狀態: {status}", "inProgress": "請求中", + "retrying": "Retrying", "unknown": "未知狀態", "success": "請求成功完成", "error": "請求失敗,以下是詳細的錯誤訊息和供應商決策鏈", @@ -456,7 +457,8 @@ "avgTtfbMs": "平均 TTFB(ms)", "avgTokensPerSecond": "平均輸出速率", "avgCostPerRequest": "平均每次請求成本", - "avgCostPerMillionTokens": "平均每百萬 Token 成本" + "avgCostPerMillionTokens": "平均每百萬 Token 成本", + "unknownModel": "未知" }, "expandModelStats": "展開模型詳情", "collapseModelStats": "收起模型詳情", @@ -479,6 +481,38 @@ "filters": { "userTagsPlaceholder": "按使用者標籤篩選...", "userGroupsPlaceholder": "按使用者群組篩選..." + }, + "userInsights": { + "title": "使用者洞察", + "backToLeaderboard": "返回排行榜", + "overview": "概覽", + "keyTrend": "Key 使用趨勢", + "modelBreakdown": "模型明細", + "todayRequests": "今日請求", + "todayCost": "今日費用", + "avgResponseTime": "平均回應時間", + "errorRate": "錯誤率", + "timeRange": { + "today": "今日", + "7days": "近 7 天", + "30days": "近 30 天", + "thisMonth": "本月" + }, + "unknownModel": "未知模型", + "noData": "暫無資料", + "dateRange": "日期範圍", + "allTime": "全部時間", + "providerBreakdown": "供應商明細", + "unknownProvider": "未知供應商", + "apiKey": "API 金鑰", + "provider": "供應商", + "model": "模型", + "allKeys": "全部金鑰", + "allProviders": "全部供應商", + "allModels": "全部模型", + "dimensions": "維度", + "filters": "篩選", + "loadError": "資料載入失敗" } }, "sessions": { @@ -1038,7 +1072,7 @@ "label": "允許的用戶端", "description": "限制哪些 CLI/IDE 用戶端可以使用此帳戶。留空表示無限制。", "customLabel": "自訂用戶端模式", - "customPlaceholder": "輸入自訂模式(如:'xcode', 'my-ide')" + "customPlaceholder": "輸入模式(如 'my-ide'、'codex-*')" }, "allowedModels": { "label": "允許的模型", @@ -1525,6 +1559,21 @@ "deleteFailed": "刪除使用者失敗", "userDeleted": "使用者已刪除", "saving": "儲存中...", + "resetSection": { + "title": "重置選項" + }, + "resetLimits": { + "title": "重設限額", + "description": "重設所有限額的累計消費計數器。請求日誌和統計資料將被保留。", + "button": "重設限額", + "confirmTitle": "僅重設限額?", + "confirmDescription": "這將把所有累計消費計數器(5小時、每日、每週、每月、總計)歸零。請求日誌和使用統計將被保留。", + "confirm": "是的,重設限額", + "loading": "正在重設...", + "error": "重設限額失敗", + "success": "所有限額已重設", + "lastResetAt": "上次重設: {date}" + }, "resetData": { "title": "重置統計", "description": "刪除該使用者的所有請求日誌和使用資料。此操作不可逆。", @@ -1798,15 +1847,15 @@ "label": "用戶端限制", "description": "限制哪些 CLI/IDE 用戶端可以使用此帳戶。留空表示無限制。", "customLabel": "自訂用戶端模式", - "customPlaceholder": "輸入自訂模式(如:'xcode', 'my-ide')", - "customHelp": "自訂模式會以 User-Agent 包含比對(不區分大小寫),並將 '-' 與 '_' 視為等價。" + "customPlaceholder": "輸入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不區分大小寫的子串比對(忽略 '-' 和 '_')。含 '*':從 UA 開頭的 glob 萬用字元比對(用 *foo* 比對任意位置;不區分大小寫,字元按原樣比對)。" }, "blockedClients": { "label": "黑名單客戶端", "description": "符合這些模式的客戶端將被拒絕,即使它們也符合白名單。", "customLabel": "自訂黑名單模式", - "customPlaceholder": "輸入模式(如 'xcode'、'my-ide')", - "customHelp": "自訂模式會以 User-Agent 包含比對(不區分大小寫),並將 '-' 與 '_' 視為等價。" + "customPlaceholder": "輸入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不區分大小寫的子串比對(忽略 '-' 和 '_')。含 '*':從 UA 開頭的 glob 萬用字元比對(用 *foo* 比對任意位置;不區分大小寫,字元按原樣比對)。" }, "allowedModels": { "label": "Model 限制", @@ -1843,7 +1892,17 @@ "claude-code-sdk-ts": "Claude Code SDK(TypeScript)", "claude-code-sdk-py": "Claude Code SDK(Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK(TypeScript)", + "sdk-py": "SDK(Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已選 {count} 項" }, "keyEditSection": { "sections": { diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 9ce531b7e..654327fae 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -41,7 +41,12 @@ "http2Fallback": "HTTP/2 回退", "clientError": "客戶端錯誤", "endpointPoolExhausted": "端點池耗盡", - "vendorTypeAllTimeout": "供應商類型全端點逾時" + "vendorTypeAllTimeout": "供應商類型全端點逾時", + "hedgeTriggered": "Hedge 已觸發", + "hedgeLaunched": "Hedge 備選已啟動", + "hedgeWinner": "Hedge 競速贏家", + "hedgeLoserCancelled": "Hedge 競速輸家(已取消)", + "clientAbort": "客戶端中斷" }, "reasons": { "request_success": "成功", @@ -56,7 +61,12 @@ "initial_selection": "首次選擇", "endpoint_pool_exhausted": "端點池耗盡", "vendor_type_all_timeout": "供應商類型全端點逾時", - "client_restriction_filtered": "客戶端受限" + "client_restriction_filtered": "客戶端受限", + "hedge_triggered": "Hedge 已觸發", + "hedge_launched": "Hedge 備選已啟動", + "hedge_winner": "Hedge 競速贏家", + "hedge_loser_cancelled": "Hedge 競速輸家(已取消)", + "client_abort": "客戶端中斷" }, "filterReasons": { "rate_limited": "速率限制", @@ -222,7 +232,14 @@ "strictBlockNoEndpoints": "嚴格模式:無可用端點候選,跳過該供應商且不降級", "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級", "vendorTypeAllTimeout": "供應商類型全端點逾時(524)", - "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。" + "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。", + "hedgeTriggered": "Hedge 閾值超出(正在啟動備選供應商)", + "hedgeLaunched": "Hedge 備選供應商已啟動", + "hedgeWinner": "Hedge 競速贏家(最先收到首位元組)", + "hedgeLoserCancelled": "Hedge 競速輸家(請求已取消)", + "clientAbort": "客戶端已斷開連接(請求中斷)", + "hedgeRace": "Hedge 競速", + "hedgeThresholdExceeded": "首位元組逾時,已啟動備選供應商" }, "selectionMethods": { "session_reuse": "會話複用", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index d2df54956..d7bdc6e6c 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -55,6 +55,8 @@ "enableThinkingBudgetRectifierDesc": "當 Anthropic 類型供應商返回 budget_tokens < 1024 錯誤時,自動將 thinking 預算設為最大值(32000),並在需要時將 max_tokens 設為 64000,然後重試一次(預設開啟)。", "enableBillingHeaderRectifier": "啟用計費標頭整流器", "enableBillingHeaderRectifierDesc": "主動移除 Claude Code 客戶端注入到 system 提示中的 x-anthropic-billing-header 文字區塊,防止 Amazon Bedrock 等非原生 Anthropic 上游回傳 400 錯誤(預設開啟)。", + "enableResponseInputRectifier": "啟用 Response Input 整流器", + "enableResponseInputRectifierDesc": "自動將 /v1/responses 請求中的非陣列 input(字串簡寫或帶 role/type 的單訊息物件)規範化為標準陣列格式後再處理(預設開啟)。", "enableCodexSessionIdCompletion": "啟用 Codex Session ID 補全", "enableCodexSessionIdCompletionDesc": "當 Codex 請求僅提供 session_id(請求頭)或 prompt_cache_key(請求體)之一時,自動補全另一個;若兩者皆缺失,則產生 UUID v7 會話 ID,並在同一對話內穩定複用。", "enableClaudeMetadataUserIdInjection": "啟用 Claude metadata.user_id 注入", diff --git a/messages/zh-TW/settings/data.json b/messages/zh-TW/settings/data.json index f01011524..49d8a5adf 100644 --- a/messages/zh-TW/settings/data.json +++ b/messages/zh-TW/settings/data.json @@ -28,8 +28,10 @@ "default": "約 {days} 天前" }, "rangeLabel": "清理範圍", + "softDeletePurged": "另外清除了 {count} 筆軟刪除記錄", "statisticsRetained": "✓ 統計資料將被保留(用於趨勢分析)", "successMessage": "成功清理 {count} 筆日誌記錄({batches} 批次,耗時 {duration}秒)", + "vacuumComplete": "資料庫空間已回收", "willClean": "將清理 {range} 的所有日誌記錄" }, "description": "管理資料庫的備份與恢復,支援完整資料匯入匯出和日誌清理。", diff --git a/messages/zh-TW/settings/prices.json b/messages/zh-TW/settings/prices.json index 6618a0659..bb972bfd2 100644 --- a/messages/zh-TW/settings/prices.json +++ b/messages/zh-TW/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "本機" + "local": "本機", + "multi": "多供應商" }, "capabilities": { "assistantPrefill": "助手預填充", diff --git a/messages/zh-TW/settings/providers/batchEdit.json b/messages/zh-TW/settings/providers/batchEdit.json index 2e4541364..eede51fab 100644 --- a/messages/zh-TW/settings/providers/batchEdit.json +++ b/messages/zh-TW/settings/providers/batchEdit.json @@ -1,108 +1,100 @@ { + "selectedCount": "已選擇 {count} 個", + "actions": { + "edit": "編輯", + "resetCircuit": "重設熔斷器", + "delete": "刪除" + }, "enterMode": "批次編輯", - "exitMode": "退出", + "selectionHint": "選擇供應商進行批次編輯", "selectAll": "全選", "invertSelection": "反選", - "selectedCount": "已選 {count} 項", - "editSelected": "編輯選中項", - "selectByType": "按類型選擇", + "selectByType": "依類型", "selectByTypeItem": "{type} ({count})", - "selectByGroup": "按分組選擇", + "selectByGroup": "依群組", "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "編輯", - "delete": "刪除", - "resetCircuit": "重置熔斷" - }, + "editSelected": "編輯所選", + "exitMode": "退出", "dialog": { "editTitle": "批次編輯供應商", - "editDesc": "修改將應用於 {count} 個供應商", + "editDesc": "編輯 {count} 個供應商的設定", "deleteTitle": "刪除供應商", - "deleteDesc": "確定永久刪除 {count} 個供應商?", - "resetCircuitTitle": "重置熔斷器", - "resetCircuitDesc": "確定重置 {count} 個供應商的熔斷器?", - "next": "下一步", - "noFieldEnabled": "請至少啟用一個要更新的欄位" + "deleteDesc": "確定要刪除 {count} 個供應商嗎?此操作無法復原。", + "resetCircuitTitle": "重設熔斷器", + "resetCircuitDesc": "確定要重設 {count} 個供應商的熔斷器嗎?", + "next": "下一步" }, - "sections": { - "basic": "基本設定", - "routing": "分組與路由", - "anthropic": "Anthropic 設定" + "preview": { + "title": "預覽變更", + "description": "檢查 {count} 個供應商的變更", + "loading": "正在載入預覽...", + "noChanges": "沒有需要套用的變更", + "summary": "影響 {providerCount} 個供應商,變更 {fieldCount} 個欄位,跳過 {skipCount} 個", + "excludeProvider": "切換供應商包含狀態", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: 已跳過 ({reason})", + "nullValue": "(空)", + "back": "返回", + "apply": "套用變更" }, "fields": { "isEnabled": { - "label": "狀態", + "label": "啟用狀態", "noChange": "不修改", "enable": "啟用", "disable": "停用" }, - "priority": "優先級", + "priority": "優先順序", "weight": "權重", - "costMultiplier": "價格倍率", + "costMultiplier": "成本倍數", "groupTag": { - "label": "分組標籤", - "clear": "清除" + "label": "群組標籤" }, "modelRedirects": "模型重新導向", "allowedModels": "允許的模型", - "thinkingBudget": "思維預算", - "adaptiveThinking": "自適應思維", - "activeTimeStart": "排程開始時間", - "activeTimeEnd": "排程結束時間" + "thinkingBudget": "思考預算", + "adaptiveThinking": "自適應思考" }, - "affectedProviders": { - "title": "受影響的供應商", - "more": "+{count} 更多" + "toast": { + "previewFailed": "預覽失敗: {error}", + "unknownError": "未知錯誤", + "updated": "已更新 {count} 個供應商", + "undo": "復原", + "undoSuccess": "已還原 {count} 個供應商", + "undoFailed": "復原失敗: {error}", + "failed": "操作失敗: {error}", + "circuitReset": "已重設 {count} 個熔斷器" + }, + "undo": { + "batchDeleteSuccess": "已刪除 {count} 個供應商", + "button": "復原", + "batchDeleteUndone": "已還原 {count} 個供應商", + "singleEditSuccess": "供應商已更新", + "singleEditUndone": "已復原供應商更新", + "singleDeleteSuccess": "供應商已刪除", + "singleDeleteUndone": "供應商已還原", + "expired": "復原已過期", + "failed": "復原失敗" }, "confirm": { - "title": "確認操作", "cancel": "取消", - "confirm": "確認", "goBack": "返回", + "confirm": "確認", "processing": "處理中..." }, - "preview": { - "title": "預覽變更", - "description": "將變更應用到 {count} 個供應商前請先確認", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "空", - "fieldSkipped": "{field}: 已跳過 ({reason})", - "excludeProvider": "排除", - "summary": "{providerCount} 個供應商, {fieldCount} 項變更, {skipCount} 項跳過", - "noChanges": "沒有可應用的變更", - "apply": "應用變更", - "back": "返回編輯", - "loading": "正在產生預覽..." + "affectedProviders": { + "title": "受影響的供應商", + "more": "還有 {count} 個" }, "batchNotes": { - "codexOnly": "僅 Codex", - "claudeOnly": "僅 Claude", - "geminiOnly": "僅 Gemini" + "codexOnly": "僅限 Codex", + "claudeOnly": "僅限 Claude", + "geminiOnly": "僅限 Gemini" }, - "selectionHint": "選擇多個供應商以進行批次操作", - "undo": { - "button": "復原", - "success": "操作已成功復原", - "expired": "復原時限已過期", - "batchDeleteSuccess": "已刪除 {count} 個供應商", - "batchDeleteUndone": "已還原 {count} 個供應商", - "singleDeleteSuccess": "供應商已刪除", - "singleDeleteUndone": "供應商已恢復", - "singleEditSuccess": "供應商已更新", - "singleEditUndone": "變更已還原", - "failed": "復原失敗" - }, - "toast": { - "updated": "已更新 {count} 個供應商", - "deleted": "已刪除 {count} 個供應商", - "circuitReset": "已重置 {count} 個熔斷器", - "failed": "操作失敗: {error}", - "undo": "復原", - "undoSuccess": "已還原 {count} 個供應商", - "undoFailed": "復原失敗: {error}", - "undoExpired": "復原時限已過期", - "previewFailed": "預覽失敗: {error}", - "unknownError": "未知錯誤" + "mixedValues": { + "label": "(混合值)", + "tooltip": "選中的供應商有不同的值:", + "andMore": "...還有 {count} 個" } } diff --git a/messages/zh-TW/settings/providers/form/common.json b/messages/zh-TW/settings/providers/form/common.json index 246f543fd..91057fb02 100644 --- a/messages/zh-TW/settings/providers/form/common.json +++ b/messages/zh-TW/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "限制", "network": "網路", "testing": "測試", + "scheduling": "排程", + "options": "選項", + "activeTime": "活躍時間", + "circuitBreaker": "斷路器", + "timeout": "逾時", "stepProgress": "步驟進度" } } diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 0b323c03c..913f30244 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -331,19 +331,33 @@ "customAllowedPlaceholder": "例如 my-ide、internal-tool", "customBlockedLabel": "自訂黑名單模式", "customBlockedPlaceholder": "例如 legacy-client", - "customHelp": "自訂模式使用 User-Agent 包含比對(不區分大小寫),並將 '-' 與 '_' 視為等價。", + "customHelp": "不含 '*':不區分大小寫的子串比對(忽略 '-' 和 '_')。含 '*':從 UA 開頭的 glob 萬用字元比對(用 *foo* 比對任意位置;不區分大小寫,字元按原樣比對)。", "presetClients": { "claude-code": "Claude Code(全部)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已選 {count} 項" }, "preserveClientIp": { "desc": "向上游轉發 x-forwarded-for / x-real-ip,可能暴露真實來源 IP", "help": "預設關閉以保護隱私;僅在需要上游感知終端 IP 時開啟。", "label": "透傳客戶端 IP" }, + "options": { + "title": "選項", + "desc": "附加供應商選項和覆寫設定" + }, "providerType": { "desc": "(決定調度策略)", "label": "供應商類型", @@ -402,7 +416,7 @@ "disableHint": "設為 0 表示禁用該超時(僅用於灰度回退場景,不推薦)", "nonStreamingTotal": { "core": "true", - "desc": "非串流請求總超時,範圍 60-1200 秒,填 0 禁用(預設不限制)", + "desc": "非串流請求總超時,範圍 60-1800 秒,填 0 禁用(預設不限制)", "label": "非串流總超時(秒)", "placeholder": "0" }, diff --git a/messages/zh-TW/settings/providers/list.json b/messages/zh-TW/settings/providers/list.json index 00ed50510..00dbcff38 100644 --- a/messages/zh-TW/settings/providers/list.json +++ b/messages/zh-TW/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "重設用量", "actionDelete": "刪除", "selectProvider": "選擇 {name}", - "schedule": "排程" + "schedule": "排程", + "proxyEnabled": "已啟用代理" } diff --git a/messages/zh-TW/ui.json b/messages/zh-TW/ui.json index 008c4edb2..4781dc343 100644 --- a/messages/zh-TW/ui.json +++ b/messages/zh-TW/ui.json @@ -52,7 +52,8 @@ "maxTags": "已達到最大標籤數量", "tooLong": "標籤長度不能超過 {max} 個字元", "invalidFormat": "標籤只能包含字母、數字、底線和連字符", - "removeTag": "移除標籤 {tag}" + "removeTag": "移除標籤 {tag}", + "unknownError": "輸入無效" }, "providerGroupSelect": { "placeholder": "選擇供應商分組", diff --git a/src/actions/admin-user-insights.ts b/src/actions/admin-user-insights.ts new file mode 100644 index 000000000..10ff8f7bb --- /dev/null +++ b/src/actions/admin-user-insights.ts @@ -0,0 +1,181 @@ +"use server"; + +import { getSession } from "@/lib/auth"; +import { getOverviewWithCache } from "@/lib/redis/overview-cache"; +import { getStatisticsWithCache } from "@/lib/redis/statistics-cache"; +import { + type AdminUserModelBreakdownItem, + type AdminUserProviderBreakdownItem, + getUserModelBreakdown, + getUserProviderBreakdown, +} from "@/repository/admin-user-insights"; +import type { OverviewMetricsWithComparison } from "@/repository/overview"; +import { getSystemSettings } from "@/repository/system-config"; +import { findUserById } from "@/repository/user"; +import type { DatabaseKeyStatRow } from "@/types/statistics"; +import type { User } from "@/types/user"; +import type { ActionResult } from "./types"; + +const VALID_TIME_RANGES = ["today", "7days", "30days", "thisMonth"] as const; +type ValidTimeRange = (typeof VALID_TIME_RANGES)[number]; + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +function isValidTimeRange(value: string): value is ValidTimeRange { + return (VALID_TIME_RANGES as readonly string[]).includes(value); +} + +/** + * Get overview metrics for a specific user (admin only). + */ +export async function getUserInsightsOverview(targetUserId: number): Promise< + ActionResult<{ + user: User; + overview: OverviewMetricsWithComparison; + currencyCode: string; + }> +> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; + } + + const user = await findUserById(targetUserId); + if (!user) { + return { ok: false, error: "User not found" }; + } + + const [overview, settings] = await Promise.all([ + getOverviewWithCache(targetUserId), + getSystemSettings(), + ]); + + return { + ok: true, + data: { + user, + overview, + currencyCode: settings.currencyDisplay, + }, + }; +} + +/** + * Get key-level trend statistics for a specific user (admin only). + */ +export async function getUserInsightsKeyTrend( + targetUserId: number, + timeRange: string +): Promise> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; + } + + if (!isValidTimeRange(timeRange)) { + return { + ok: false, + error: `Invalid timeRange: must be one of ${VALID_TIME_RANGES.join(", ")}`, + }; + } + + const statistics = await getStatisticsWithCache(timeRange, "keys", targetUserId); + + const normalized = (statistics as DatabaseKeyStatRow[]).map((row) => ({ + ...row, + date: typeof row.date === "string" ? row.date : new Date(row.date).toISOString(), + })); + + return { ok: true, data: normalized }; +} + +/** + * Get model-level usage breakdown for a specific user (admin only). + */ +function validateDateRange( + startDate?: string, + endDate?: string +): { ok: false; error: string } | null { + if (startDate && !DATE_REGEX.test(startDate)) { + return { ok: false, error: "Invalid startDate format: use YYYY-MM-DD" }; + } + if (endDate && !DATE_REGEX.test(endDate)) { + return { ok: false, error: "Invalid endDate format: use YYYY-MM-DD" }; + } + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return { ok: false, error: "startDate must not be after endDate" }; + } + return null; +} + +/** + * Get model-level usage breakdown for a specific user (admin only). + */ +export async function getUserInsightsModelBreakdown( + targetUserId: number, + startDate?: string, + endDate?: string, + filters?: { keyId?: number; providerId?: number } +): Promise< + ActionResult<{ + breakdown: AdminUserModelBreakdownItem[]; + currencyCode: string; + }> +> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; + } + + const dateError = validateDateRange(startDate, endDate); + if (dateError) return dateError; + + const [breakdown, settings] = await Promise.all([ + getUserModelBreakdown(targetUserId, startDate, endDate, filters), + getSystemSettings(), + ]); + + return { + ok: true, + data: { + breakdown, + currencyCode: settings.currencyDisplay, + }, + }; +} + +/** + * Get provider-level usage breakdown for a specific user (admin only). + */ +export async function getUserInsightsProviderBreakdown( + targetUserId: number, + startDate?: string, + endDate?: string, + filters?: { keyId?: number; model?: string } +): Promise< + ActionResult<{ + breakdown: AdminUserProviderBreakdownItem[]; + currencyCode: string; + }> +> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; + } + + const dateError = validateDateRange(startDate, endDate); + if (dateError) return dateError; + + const [breakdown, settings] = await Promise.all([ + getUserProviderBreakdown(targetUserId, startDate, endDate, filters), + getSystemSettings(), + ]); + + return { + ok: true, + data: { + breakdown, + currencyCode: settings.currencyDisplay, + }, + }; +} diff --git a/src/actions/dashboard-realtime.ts b/src/actions/dashboard-realtime.ts index 274ab376b..32b182ba6 100644 --- a/src/actions/dashboard-realtime.ts +++ b/src/actions/dashboard-realtime.ts @@ -207,14 +207,17 @@ export async function getDashboardRealtimeData(): Promise + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; + // Use DB direct queries for consistency with my-usage.ts (not Redis-first) const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] = await Promise.all([ - sumKeyCostInTimeRange(keyId, range5h.startTime, range5h.endTime), - sumKeyCostInTimeRange(keyId, keyDailyTimeRange.startTime, keyDailyTimeRange.endTime), - sumKeyCostInTimeRange(keyId, rangeWeekly.startTime, rangeWeekly.endTime), - sumKeyCostInTimeRange(keyId, rangeMonthly.startTime, rangeMonthly.endTime), - getTotalUsageForKey(keyRow.key), + sumKeyCostInTimeRange(keyId, clipStart(range5h.startTime), range5h.endTime), + sumKeyCostInTimeRange( + keyId, + clipStart(keyDailyTimeRange.startTime), + keyDailyTimeRange.endTime + ), + sumKeyCostInTimeRange(keyId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumKeyCostInTimeRange(keyId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumKeyTotalCost(keyRow.key, Infinity, costResetAt), SessionTracker.getKeySessionCount(keyId), ]); diff --git a/src/actions/keys.ts b/src/actions/keys.ts index d329c138f..4e3ff3781 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -702,6 +702,7 @@ export async function getKeyLimitUsage(keyId: number): Promise< .select({ key: keysTable, userLimitConcurrentSessions: usersTable.limitConcurrentSessions, + userCostResetAt: usersTable.costResetAt, }) .from(keysTable) .leftJoin(usersTable, and(eq(keysTable.userId, usersTable.id), isNull(usersTable.deletedAt))) @@ -733,6 +734,11 @@ export async function getKeyLimitUsage(keyId: number): Promise< result.userLimitConcurrentSessions ?? null ); + // Clip time range start by costResetAt (for limits-only reset) + const costResetAt = result.userCostResetAt ?? null; + const clipStart = (start: Date): Date => + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; + // Calculate time ranges using Key's dailyResetTime/dailyResetMode configuration const keyDailyTimeRange = await getTimeRangeForPeriodWithMode( "daily", @@ -748,11 +754,15 @@ export async function getKeyLimitUsage(keyId: number): Promise< // 获取金额消费(使用 DB direct,与 my-usage.ts 保持一致) const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] = await Promise.all([ - sumKeyCostInTimeRange(keyId, range5h.startTime, range5h.endTime), - sumKeyCostInTimeRange(keyId, keyDailyTimeRange.startTime, keyDailyTimeRange.endTime), - sumKeyCostInTimeRange(keyId, rangeWeekly.startTime, rangeWeekly.endTime), - sumKeyCostInTimeRange(keyId, rangeMonthly.startTime, rangeMonthly.endTime), - sumKeyTotalCost(key.key), + sumKeyCostInTimeRange(keyId, clipStart(range5h.startTime), range5h.endTime), + sumKeyCostInTimeRange( + keyId, + clipStart(keyDailyTimeRange.startTime), + keyDailyTimeRange.endTime + ), + sumKeyCostInTimeRange(keyId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumKeyCostInTimeRange(keyId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumKeyTotalCost(key.key, Infinity, costResetAt), SessionTracker.getKeySessionCount(keyId), ]); diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a450f7603..9b5f7e439 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -242,6 +242,29 @@ export async function getMyQuota(): Promise> { const rangeWeekly = await getTimeRangeForPeriod("weekly"); const rangeMonthly = await getTimeRangeForPeriod("monthly"); + // Clip time range starts by costResetAt (for limits-only reset) + const costResetAt = user.costResetAt ?? null; + const clipStart = (start: Date): Date => + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; + + const clippedRange5h = { startTime: clipStart(range5h.startTime), endTime: range5h.endTime }; + const clippedRangeWeekly = { + startTime: clipStart(rangeWeekly.startTime), + endTime: rangeWeekly.endTime, + }; + const clippedRangeMonthly = { + startTime: clipStart(rangeMonthly.startTime), + endTime: rangeMonthly.endTime, + }; + const clippedKeyDaily = { + startTime: clipStart(keyDailyTimeRange.startTime), + endTime: keyDailyTimeRange.endTime, + }; + const clippedUserDaily = { + startTime: clipStart(userDailyTimeRange.startTime), + endTime: userDailyTimeRange.endTime, + }; + const effectiveKeyConcurrentLimit = resolveKeyConcurrentSessionLimit( key.limitConcurrentSessions ?? 0, user.limitConcurrentSessions ?? null @@ -252,24 +275,26 @@ export async function getMyQuota(): Promise> { sumKeyQuotaCostsById( key.id, { - range5h, - rangeDaily: keyDailyTimeRange, - rangeWeekly, - rangeMonthly, + range5h: clippedRange5h, + rangeDaily: clippedKeyDaily, + rangeWeekly: clippedRangeWeekly, + rangeMonthly: clippedRangeMonthly, }, - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + costResetAt ), SessionTracker.getKeySessionCount(key.id), // User 配额:直接查 DB sumUserQuotaCosts( user.id, { - range5h, - rangeDaily: userDailyTimeRange, - rangeWeekly, - rangeMonthly, + range5h: clippedRange5h, + rangeDaily: clippedUserDaily, + rangeWeekly: clippedRangeWeekly, + rangeMonthly: clippedRangeMonthly, }, - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + costResetAt ), getUserConcurrentSessions(user.id), ]); diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index e1ed4be88..4e6f8a1c6 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -59,6 +59,7 @@ export async function saveSystemSettings(formData: { enableThinkingSignatureRectifier?: boolean; enableThinkingBudgetRectifier?: boolean; enableBillingHeaderRectifier?: boolean; + enableResponseInputRectifier?: boolean; enableCodexSessionIdCompletion?: boolean; enableClaudeMetadataUserIdInjection?: boolean; enableResponseFixer?: boolean; @@ -95,6 +96,7 @@ export async function saveSystemSettings(formData: { enableThinkingSignatureRectifier: validated.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: validated.enableThinkingBudgetRectifier, enableBillingHeaderRectifier: validated.enableBillingHeaderRectifier, + enableResponseInputRectifier: validated.enableResponseInputRectifier, enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: validated.enableClaudeMetadataUserIdInjection, enableResponseFixer: validated.enableResponseFixer, diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index b0d612b0a..7a9643aab 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -7,6 +7,8 @@ import { SESSION_ID_SUGGESTION_MIN_LEN, } from "@/lib/constants/usage-logs.constants"; import { logger } from "@/lib/logger"; +import { readLiveChainBatch } from "@/lib/redis/live-chain-store"; +import { isProviderFinalized } from "@/lib/utils/provider-display"; import { findUsageLogSessionIdSuggestions, findUsageLogsBatch, @@ -385,6 +387,28 @@ export async function getUsageLogsBatch( const result = await findUsageLogsBatch(finalFilters); + // Merge Redis live chain data for unfinalised rows + const unfinalisedRows = result.logs.filter( + (row) => !isProviderFinalized(row) && row.sessionId && row.requestSequence != null + ); + + if (unfinalisedRows.length > 0) { + const liveData = await readLiveChainBatch( + unfinalisedRows.map((r) => ({ + sessionId: r.sessionId!, + requestSequence: r.requestSequence!, + })) + ); + + for (const row of unfinalisedRows) { + const key = `${row.sessionId}:${row.requestSequence}`; + const snapshot = liveData.get(key); + if (snapshot) { + row._liveChain = snapshot; + } + } + } + return { ok: true, data: result }; } catch (error) { logger.error("获取使用日志批量数据失败:", error); diff --git a/src/actions/users.ts b/src/actions/users.ts index 768e7ec62..04039e3fa 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -10,6 +10,7 @@ import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions"; +import { invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { normalizeProviderGroup } from "@/lib/utils/provider-group"; @@ -32,6 +33,7 @@ import { findUserListBatch, getAllUserProviderGroups as getAllUserProviderGroupsRepository, getAllUserTags as getAllUserTagsRepository, + resetUserCostResetAt, searchUsersForFilter as searchUsersForFilterRepository, updateUser, } from "@/repository/user"; @@ -272,6 +274,7 @@ export async function getUsers(): Promise { limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -339,6 +342,7 @@ export async function getUsers(): Promise { limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -543,6 +547,7 @@ export async function getUsersBatch( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -606,6 +611,7 @@ export async function getUsersBatch( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -693,6 +699,7 @@ export async function getUsersBatchCore( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -1552,7 +1559,11 @@ export async function getUserLimitUsage(userId: number): Promise< resetTime, resetMode ); - const dailyCost = await sumUserCostInTimeRange(userId, startTime, endTime); + const effectiveStart = + user.costResetAt instanceof Date && user.costResetAt > startTime + ? user.costResetAt + : startTime; + const dailyCost = await sumUserCostInTimeRange(userId, effectiveStart, endTime); const resetInfo = await getResetInfoWithMode("daily", resetTime, resetMode); const resetAt = resetInfo.resetAt; @@ -1758,14 +1769,18 @@ export async function getUserAllLimitUsage(userId: number): Promise< const rangeWeekly = await getTimeRangeForPeriod("weekly"); const rangeMonthly = await getTimeRangeForPeriod("monthly"); + // Clip time range start by costResetAt (for limits-only reset) + const clipStart = (start: Date): Date => + user.costResetAt instanceof Date && user.costResetAt > start ? user.costResetAt : start; + // 并行查询各时间范围的消费 // Note: sumUserTotalCost uses ALL_TIME_MAX_AGE_DAYS for all-time semantics const [usage5h, usageDaily, usageWeekly, usageMonthly, usageTotal] = await Promise.all([ - sumUserCostInTimeRange(userId, range5h.startTime, range5h.endTime), - sumUserCostInTimeRange(userId, rangeDaily.startTime, rangeDaily.endTime), - sumUserCostInTimeRange(userId, rangeWeekly.startTime, rangeWeekly.endTime), - sumUserCostInTimeRange(userId, rangeMonthly.startTime, rangeMonthly.endTime), - sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS), + sumUserCostInTimeRange(userId, clipStart(range5h.startTime), range5h.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeDaily.startTime), rangeDaily.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS, user.costResetAt), ]); return { @@ -1787,12 +1802,13 @@ export async function getUserAllLimitUsage(userId: number): Promise< } /** - * Reset ALL user statistics (logs + Redis cache + sessions) - * This is IRREVERSIBLE - deletes all messageRequest logs for the user + * Reset user cost limits only (without deleting logs or statistics). + * Sets costResetAt = NOW() so all cost calculations start fresh. + * Logs, statistics, and usage_ledger remain intact. * * Admin only. */ -export async function resetUserAllStatistics(userId: number): Promise { +export async function resetUserLimitsOnly(userId: number): Promise { try { const tError = await getTranslations("errors"); @@ -1813,81 +1829,113 @@ export async function resetUserAllStatistics(userId: number): Promise k.id); + const keyHashes = keys.map((k) => k.key); - // 1. Delete all messageRequest logs for this user - await db.delete(messageRequest).where(eq(messageRequest.userId, userId)); - - // Also clear ledger rows -- the ONLY legitimate DELETE path for usage_ledger - await db.delete(usageLedger).where(eq(usageLedger.userId, userId)); - - // 2. Clear Redis cache - const { getRedisClient } = await import("@/lib/redis"); - const { scanPattern } = await import("@/lib/redis/scan-helper"); - const { getKeyActiveSessionsKey, getUserActiveSessionsKey } = await import( - "@/lib/redis/active-session-keys" - ); - const redis = getRedisClient(); + // Set costResetAt on user so all cost calculations start fresh + // Uses repo function which also sets updatedAt and invalidates auth cache + const updated = await resetUserCostResetAt(userId, new Date()); + if (!updated) { + return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + } - if (redis && redis.status === "ready") { - try { - const startTime = Date.now(); - - // Scan all patterns in parallel - const scanResults = await Promise.all([ - ...keyIds.map((keyId) => - scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { - logger.warn("Failed to scan key cost pattern", { keyId, error: err }); - return []; - }) - ), - scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { - logger.warn("Failed to scan user cost pattern", { userId, error: err }); - return []; - }), - ]); + // Clear Redis cost cache (but NOT active sessions, NOT DB logs) + try { + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const cacheResult = await clearUserCostCache({ userId, keyIds, keyHashes }); + if (cacheResult) { + logger.info("Reset user limits only - Redis cost cache cleared", { + userId, + keyCount: keyIds.length, + ...cacheResult, + }); + } + } catch (error) { + logger.error("Failed to clear Redis cache during user limits reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - costResetAt already set in DB + } - const allCostKeys = scanResults.flat(); + logger.info("Reset user limits only (costResetAt set)", { userId, keyCount: keyIds.length }); + revalidatePath("/dashboard/users"); - // Batch delete via pipeline - const pipeline = redis.pipeline(); + return { ok: true }; + } catch (error) { + logger.error("Failed to reset user limits:", error); + const tError = await getTranslations("errors"); + return { + ok: false, + error: tError("OPERATION_FAILED"), + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } +} - // Active sessions - for (const keyId of keyIds) { - pipeline.del(getKeyActiveSessionsKey(keyId)); - } - pipeline.del(getUserActiveSessionsKey(userId)); +/** + * Reset ALL user statistics (logs + Redis cache + sessions) + * This is IRREVERSIBLE - deletes all messageRequest logs for the user + * + * Admin only. + */ +export async function resetUserAllStatistics(userId: number): Promise { + try { + const tError = await getTranslations("errors"); - // Cost keys - for (const key of allCostKeys) { - pipeline.del(key); - } + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } - const results = await pipeline.exec(); + const user = await findUserById(userId); + if (!user) { + return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + } - // Check for errors - const errors = results?.filter(([err]) => err); - if (errors && errors.length > 0) { - logger.warn("Some Redis deletes failed during user statistics reset", { - errorCount: errors.length, - userId, - }); - } + // Get user's keys + const keys = await findKeyList(userId); + const keyIds = keys.map((k) => k.id); + const keyHashes = keys.map((k) => k.key); - const duration = Date.now() - startTime; + // 1. Delete all messageRequest logs for this user + // Atomic: delete logs + ledger + clear costResetAt in a single transaction + await db.transaction(async (tx) => { + await tx.delete(messageRequest).where(eq(messageRequest.userId, userId)); + await tx.delete(usageLedger).where(eq(usageLedger.userId, userId)); + await tx + .update(usersTable) + .set({ costResetAt: null, updatedAt: new Date() }) + .where(and(eq(usersTable.id, userId), isNull(usersTable.deletedAt))); + }); + // Invalidate auth cache outside transaction (Redis, not DB) + await invalidateCachedUser(userId).catch(() => {}); + + // 2. Clear Redis cache (cost keys + active sessions) + try { + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const cacheResult = await clearUserCostCache({ + userId, + keyIds, + keyHashes, + includeActiveSessions: true, + }); + if (cacheResult) { logger.info("Reset user statistics - Redis cache cleared", { userId, keyCount: keyIds.length, - costKeysDeleted: allCostKeys.length, - activeSessionsDeleted: keyIds.length + 1, - durationMs: duration, - }); - } catch (error) { - logger.error("Failed to clear Redis cache during user statistics reset", { - userId, - error: error instanceof Error ? error.message : String(error), + ...cacheResult, }); - // Continue execution - DB logs already deleted } + } catch (error) { + logger.error("Failed to clear Redis cache during user statistics reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - DB logs already deleted } logger.info("Reset all user statistics", { userId, keyCount: keyIds.length }); diff --git a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx index 93df8eb89..7ef41aafc 100644 --- a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Loader2, UserPlus } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useMemo, useState, useTransition } from "react"; +import { useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { addKey } from "@/actions/keys"; @@ -134,6 +134,8 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp const keyEditTranslations = useKeyTranslations(); const defaultValues = useMemo(() => buildDefaultValues(), []); + const latestUserRef = useRef(defaultValues.user); + const latestKeyRef = useRef(defaultValues.key); const form = useZodForm({ schema: CreateFormSchema, @@ -224,9 +226,11 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp const currentUserDraft = form.values.user || defaultValues.user; const currentKeyDraft = form.values.key || defaultValues.key; + latestUserRef.current = form.values.user || defaultValues.user; + latestKeyRef.current = form.values.key || defaultValues.key; const handleUserChange = (field: string | Record, value?: any) => { - const prev = form.values.user || defaultValues.user; + const prev = latestUserRef.current; const next = { ...prev }; if (typeof field === "object") { @@ -244,11 +248,12 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp } // 直接替换整个 user 对象,因为 useZodForm.setValue 不支持嵌套路径 + latestUserRef.current = next; form.setValue("user" as any, next); }; const handleKeyChange = (field: string | Record, value?: any) => { - const prev = form.values.key || defaultValues.key; + const prev = latestKeyRef.current; const next = { ...prev }; if (typeof field === "object") { @@ -264,6 +269,7 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp } // 直接替换整个 key 对象,因为 useZodForm.setValue 不支持嵌套路径 + latestKeyRef.current = next; form.setValue("key" as any, next); }; diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 5a0fcf9bb..463016ea4 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -1,13 +1,19 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Trash2, UserCog } from "lucide-react"; +import { Loader2, RotateCcw, Trash2, UserCog } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; -import { editUser, removeUser, resetUserAllStatistics, toggleUserEnabled } from "@/actions/users"; +import { + editUser, + removeUser, + resetUserAllStatistics, + resetUserLimitsOnly, + toggleUserEnabled, +} from "@/actions/users"; import { AlertDialog, AlertDialogAction, @@ -84,9 +90,12 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const queryClient = useQueryClient(); const t = useTranslations("dashboard.userManagement"); const tCommon = useTranslations("common"); + const locale = useLocale(); const [isPending, startTransition] = useTransition(); const [isResettingAll, setIsResettingAll] = useState(false); const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); + const [isResettingLimits, setIsResettingLimits] = useState(false); + const [resetLimitsDialogOpen, setResetLimitsDialogOpen] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -243,6 +252,25 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr } }; + const handleResetLimitsOnly = async () => { + setIsResettingLimits(true); + try { + const res = await resetUserLimitsOnly(user.id); + if (!res.ok) { + toast.error(res.error || t("editDialog.resetLimits.error")); + return; + } + toast.success(t("editDialog.resetLimits.success")); + setResetLimitsDialogOpen(false); + window.location.reload(); + } catch (error) { + console.error("[EditUserDialog] reset limits only failed", error); + toast.error(t("editDialog.resetLimits.error")); + } finally { + setIsResettingLimits(false); + } + }; + return (
@@ -291,55 +319,129 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr /> {/* Reset Data Section - Admin Only */} -
-
-
-

- {t("editDialog.resetData.title")} -

-

- {t("editDialog.resetData.description")} -

-
+
+

{t("editDialog.resetSection.title")}

- - - - - - - {t("editDialog.resetData.confirmTitle")} - - {t("editDialog.resetData.confirmDescription")} - - - - - {tCommon("cancel")} - - { - e.preventDefault(); - handleResetAllStatistics(); - }} - disabled={isResettingAll} - className={cn(buttonVariants({ variant: "destructive" }))} + {/* Reset Limits Only - Less destructive (amber) */} +
+
+
+

+ {t("editDialog.resetLimits.title")} +

+

+ {t("editDialog.resetLimits.description")} +

+ {user.costResetAt && ( +

+ {t("editDialog.resetLimits.lastResetAt", { + date: new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(user.costResetAt)), + })} +

+ )} +
+ + + + + + + + + {t("editDialog.resetLimits.confirmTitle")} + + + {t("editDialog.resetLimits.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetLimitsOnly(); + }} + disabled={isResettingLimits} + className="bg-amber-600 text-white hover:bg-amber-700" + > + {isResettingLimits ? ( + <> + + {t("editDialog.resetLimits.loading")} + + ) : ( + t("editDialog.resetLimits.confirm") + )} + + + + +
+
+ + {/* Reset All Statistics - Destructive (red) */} +
+
+
+

+ {t("editDialog.resetData.title")} +

+

+ {t("editDialog.resetData.description")} +

+
+ + + + + + + + {t("editDialog.resetData.confirmTitle")} + + {t("editDialog.resetData.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetAllStatistics(); + }} + disabled={isResettingAll} + className={cn(buttonVariants({ variant: "destructive" }))} + > + {isResettingAll ? ( + <> + + {t("editDialog.resetData.loading")} + + ) : ( + t("editDialog.resetData.confirm") + )} + + + + +
diff --git a/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx index 4b771001b..8cea18dd2 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx @@ -1,18 +1,9 @@ "use client"; import { Shield } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; +import { ClientRestrictionsEditor } from "@/components/form/client-restrictions-editor"; import { ArrayTagInputField } from "@/components/form/form-field"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { - CLIENT_RESTRICTION_PRESET_OPTIONS, - isPresetSelected, - mergePresetAndCustomClients, - removePresetValues, - splitPresetAndCustomClients, - togglePresetSelection, -} from "@/lib/client-restrictions/client-presets"; // Model name validation pattern const MODEL_NAME_PATTERN = /^[a-zA-Z0-9._:/-]+$/; @@ -53,6 +44,8 @@ export interface AccessRestrictionsSectionProps { block: string; }; presetClients: Record; + subClients: Record; + nSelected: string; }; } @@ -67,38 +60,6 @@ export function AccessRestrictionsSection({ const allowed = allowedClients || []; const blocked = blockedClients || []; - const { customValues: customAllowed } = useMemo( - () => splitPresetAndCustomClients(allowed), - [allowed] - ); - - const { customValues: customBlocked } = useMemo( - () => splitPresetAndCustomClients(blocked), - [blocked] - ); - - const handleAllowToggle = (presetValue: string, checked: boolean) => { - onChange("allowedClients", togglePresetSelection(allowed, presetValue, checked)); - if (checked) { - onChange("blockedClients", removePresetValues(blocked, presetValue)); - } - }; - - const handleBlockToggle = (presetValue: string, checked: boolean) => { - onChange("blockedClients", togglePresetSelection(blocked, presetValue, checked)); - if (checked) { - onChange("allowedClients", removePresetValues(allowed, presetValue)); - } - }; - - const handleCustomAllowedChange = (newCustom: string[]) => { - onChange("allowedClients", mergePresetAndCustomClients(allowed, newCustom)); - }; - - const handleCustomBlockedChange = (newCustom: string[]) => { - onChange("blockedClients", mergePresetAndCustomClients(blocked, newCustom)); - }; - const validateModelTag = useCallback( (tag: string): boolean => { if (!tag || tag.trim().length === 0) return false; @@ -111,46 +72,6 @@ export function AccessRestrictionsSection({ [allowedModels] ); - const renderPresetRow = (value: string) => { - const isAllowed = isPresetSelected(allowed, value); - const isBlocked = isPresetSelected(blocked, value); - const displayLabel = translations.presetClients[value] ?? value; - - return ( -
- {displayLabel} -
-
- handleAllowToggle(value, checked === true)} - /> - -
-
- handleBlockToggle(value, checked === true)} - /> - -
-
-
- ); - }; - return (
@@ -167,30 +88,23 @@ export function AccessRestrictionsSection({

-
- {CLIENT_RESTRICTION_PRESET_OPTIONS.map((client) => renderPresetRow(client.value))} -
- - {/* Custom allowed patterns */} - - - {/* Custom blocked patterns */} - onChange("allowedClients", next)} + onBlockedChange={(next) => onChange("blockedClients", next)} + translations={{ + allowAction: translations.actions.allow, + blockAction: translations.actions.block, + customAllowedLabel: translations.fields.allowedClients.customLabel, + customAllowedPlaceholder: translations.fields.allowedClients.customPlaceholder, + customBlockedLabel: translations.fields.blockedClients.customLabel, + customBlockedPlaceholder: translations.fields.blockedClients.customPlaceholder, + customHelp: translations.fields.allowedClients.customHelp, + presetClients: translations.presetClients, + subClients: translations.subClients, + nSelected: translations.nSelected, + }} />
diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx index 20f10ba68..f42dc6102 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx @@ -120,6 +120,8 @@ export interface UserEditSectionProps { block: string; }; presetClients: Record; + subClients: Record; + nSelected: string; limitRules: { addRule: string; ruleTypes: Record; @@ -509,6 +511,8 @@ export function UserEditSection({ }, actions: translations.actions, presetClients: translations.presetClients, + subClients: translations.subClients, + nSelected: translations.nSelected, }} /> diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index d94c426d8..04c394a93 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -400,6 +400,16 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { "factory-cli": tUserEdit("presetClients.factory-cli"), "codex-cli": tUserEdit("presetClients.codex-cli"), }, + subClients: { + all: tUserEdit("subClients.all"), + cli: tUserEdit("subClients.cli"), + vscode: tUserEdit("subClients.vscode"), + "sdk-ts": tUserEdit("subClients.sdk-ts"), + "sdk-py": tUserEdit("subClients.sdk-py"), + "cli-sdk": tUserEdit("subClients.cli-sdk"), + "gh-action": tUserEdit("subClients.gh-action"), + }, + nSelected: tUserEdit("nSelected", { count: "{count}" }), }} /> diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts index ecd630c41..4ccb24550 100644 --- a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts +++ b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts @@ -76,6 +76,8 @@ export interface UserEditTranslations { block: string; }; presetClients: Record; + subClients: Record; + nSelected: string; limitRules: { addRule: string; ruleTypes: { @@ -203,6 +205,16 @@ export function useUserTranslations( "factory-cli": t("userEditSection.presetClients.factory-cli"), "codex-cli": t("userEditSection.presetClients.codex-cli"), }, + subClients: { + all: t("userEditSection.subClients.all"), + cli: t("userEditSection.subClients.cli"), + vscode: t("userEditSection.subClients.vscode"), + "sdk-ts": t("userEditSection.subClients.sdk-ts"), + "sdk-py": t("userEditSection.subClients.sdk-py"), + "cli-sdk": t("userEditSection.subClients.cli-sdk"), + "gh-action": t("userEditSection.subClients.gh-action"), + }, + nSelected: t("userEditSection.nSelected", { count: "{count}" }), limitRules: { addRule: t("limitRules.addRule"), ruleTypes: { diff --git a/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx b/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx index a70d1836c..c803da6a9 100644 --- a/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx @@ -1,9 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -import { getUserAllLimitUsage } from "@/actions/users"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { + getSharedUserLimitUsage, + type LimitUsageData, + peekCachedUserLimitUsage, +} from "@/lib/dashboard/user-limit-usage-cache"; import { cn } from "@/lib/utils"; export type LimitType = "5h" | "daily" | "weekly" | "monthly" | "total"; @@ -16,26 +20,6 @@ export interface UserLimitBadgeProps { unit?: string; } -interface LimitUsageData { - limit5h: { usage: number; limit: number | null }; - limitDaily: { usage: number; limit: number | null }; - limitWeekly: { usage: number; limit: number | null }; - limitMonthly: { usage: number; limit: number | null }; - limitTotal: { usage: number; limit: number | null }; -} - -// Global cache for user limit usage data -const usageCache = new Map(); -const CACHE_TTL = 60 * 1000; // 1 minute - -export function clearUsageCache(userId?: number): void { - if (userId !== undefined) { - usageCache.delete(userId); - } else { - usageCache.clear(); - } -} - function formatPercentage(usage: number, limit: number): string { const percentage = Math.min(Math.round((usage / limit) * 100), 999); return `${percentage}%`; @@ -77,39 +61,52 @@ export function UserLimitBadge({ const [error, setError] = useState(false); useEffect(() => { + let isCancelled = false; + // If no limit is set, don't fetch usage data if (limit === null || limit === undefined) { return; } // Check cache first - const cached = usageCache.get(userId); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + const cached = peekCachedUserLimitUsage(userId); + if (cached) { // Reset error/loading state when using cached data setError(false); setIsLoading(false); - setUsageData((prev) => (prev === cached.data ? prev : cached.data)); + setUsageData((prev) => (prev === cached ? prev : cached)); return; } setIsLoading(true); setError(false); - getUserAllLimitUsage(userId) - .then((res) => { - if (res.ok && res.data) { - usageCache.set(userId, { data: res.data, timestamp: Date.now() }); - setUsageData(res.data); + getSharedUserLimitUsage(userId) + .then((data) => { + if (isCancelled) { + return; + } + + if (data) { + setUsageData(data); } else { setError(true); } }) .catch(() => { - setError(true); + if (!isCancelled) { + setError(true); + } }) .finally(() => { - setIsLoading(false); + if (!isCancelled) { + setIsLoading(false); + } }); + + return () => { + isCancelled = true; + }; }, [userId, limit]); // No limit set - show "-" diff --git a/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx b/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx index 0beb7958b..53eb8e083 100644 --- a/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx +++ b/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx @@ -151,14 +151,14 @@ export function LatencyChart({ providers, className }: LatencyChartProps) {
{formatTime(label as string)}
{payload.map((item) => ( -
+
- {chartConfig[item.dataKey as keyof typeof chartConfig]?.label || - item.dataKey} + {chartConfig[String(item.dataKey) as keyof typeof chartConfig]?.label || + String(item.dataKey)} : {formatLatency(item.value as number)} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 66baac510..441db5d6b 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -9,6 +9,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagInput } from "@/components/ui/tag-input"; +import { Link } from "@/i18n/routing"; import { formatTokenAmount } from "@/lib/utils"; import type { DateRangeParams, @@ -19,6 +20,7 @@ import type { ModelProviderStat, ProviderCacheHitRateLeaderboardEntry, ProviderLeaderboardEntry, + UserModelStat, } from "@/repository/leaderboard"; import type { ProviderType } from "@/types/provider"; import { DateRangePicker } from "./date-range-picker"; @@ -36,7 +38,12 @@ type ProviderCostFormattedFields = { avgCostPerRequestFormatted?: string | null; avgCostPerMillionTokensFormatted?: string | null; }; -type UserEntry = LeaderboardEntry & TotalCostFormattedFields; +type UserEntry = LeaderboardEntry & + TotalCostFormattedFields & { + modelStats?: UserModelStatClient[]; + }; +type UserModelStatClient = UserModelStat & TotalCostFormattedFields; +type UserTableRow = UserEntry | UserModelStatClient; type ModelEntry = ModelLeaderboardEntry & TotalCostFormattedFields; type ModelProviderStatClient = ModelProviderStat & ProviderCostFormattedFields; type ProviderEntry = Omit & @@ -135,6 +142,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { if (scope === "provider") { url += "&includeModelStats=1"; } + if (scope === "user" && isAdmin) { + url += "&includeUserModelStats=1"; + } if (scope === "user") { if (userTagFilters.length > 0) { url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; @@ -167,7 +177,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return () => { cancelled = true; }; - }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, t]); + }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, isAdmin, t]); const handlePeriodChange = useCallback( (newPeriod: LeaderboardPeriod, newDateRange?: DateRangeParams) => { @@ -196,12 +206,27 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
); - const userColumns: ColumnDef[] = [ + const userColumns: ColumnDef[] = [ { header: t("columns.user"), - cell: (row) => row.userName, + cell: (row) => { + if ("userName" in row) { + return isAdmin ? ( + + {row.userName} + + ) : ( + row.userName + ); + } + return renderSubModelLabel(row.model ?? t("columns.unknownModel")); + }, sortKey: "userName", - getValue: (row) => row.userName, + getValue: (row) => ("userName" in row ? row.userName : (row.model ?? "")), }, { header: t("columns.requests"), @@ -396,11 +421,17 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ]; const renderUserTable = () => ( - + data={data as UserEntry[]} period={period} columns={userColumns} getRowKey={(row) => row.userId} + {...(isAdmin + ? { + getSubRows: (row) => row.modelStats, + getSubRowKey: (subRow) => subRow.model ?? "__null__", + } + : {})} /> ); diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts new file mode 100644 index 000000000..e4e301405 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts @@ -0,0 +1,49 @@ +export type TimeRangePreset = "today" | "7days" | "30days" | "thisMonth"; + +export interface UserInsightsFilters { + timeRange: TimeRangePreset; + keyId?: number; + providerId?: number; + model?: string; +} + +export const DEFAULT_FILTERS: UserInsightsFilters = { + timeRange: "7days", +}; + +/** + * Convert a time range preset to start/end dates for breakdown queries. + */ +export function resolveTimePresetDates(preset: TimeRangePreset): { + startDate?: string; + endDate?: string; +} { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const today = `${yyyy}-${mm}-${dd}`; + + switch (preset) { + case "today": + return { startDate: today, endDate: today }; + case "7days": { + const start = new Date(now); + start.setDate(start.getDate() - 6); + const sy = start.getFullYear(); + const sm = String(start.getMonth() + 1).padStart(2, "0"); + const sd = String(start.getDate()).padStart(2, "0"); + return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; + } + case "30days": { + const start = new Date(now); + start.setDate(start.getDate() - 29); + const sy = start.getFullYear(); + const sm = String(start.getMonth() + 1).padStart(2, "0"); + const sd = String(start.getDate()).padStart(2, "0"); + return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; + } + case "thisMonth": + return { startDate: `${yyyy}-${mm}-01`, endDate: today }; + } +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/user-insights-filter-bar.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/user-insights-filter-bar.tsx new file mode 100644 index 000000000..943547026 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/user-insights-filter-bar.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Filter, Key, Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getKeys } from "@/actions/keys"; +import { getProviders } from "@/actions/providers"; +import { useLazyModels } from "@/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { TimeRangePreset, UserInsightsFilters } from "./types"; + +interface UserInsightsFilterBarProps { + userId: number; + filters: UserInsightsFilters; + onFiltersChange: (filters: UserInsightsFilters) => void; +} + +const TIME_RANGE_OPTIONS: { key: TimeRangePreset; labelKey: string }[] = [ + { key: "today", labelKey: "timeRange.today" }, + { key: "7days", labelKey: "timeRange.7days" }, + { key: "30days", labelKey: "timeRange.30days" }, + { key: "thisMonth", labelKey: "timeRange.thisMonth" }, +]; + +export function UserInsightsFilterBar({ + userId, + filters, + onFiltersChange, +}: UserInsightsFilterBarProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + + const { data: keysData } = useQuery({ + queryKey: ["user-insights-keys", userId], + queryFn: async () => { + const result = await getKeys(userId); + if (!result.ok) return []; + return result.data; + }, + staleTime: 60_000, + }); + + const { data: providersData } = useQuery({ + queryKey: ["user-insights-providers"], + queryFn: async () => { + const result = await getProviders(); + return result; + }, + staleTime: 60_000, + }); + + const { data: models, onOpenChange: onModelsOpenChange } = useLazyModels(); + + const hasActiveFilters = filters.keyId || filters.providerId || filters.model; + + return ( +
+ {/* Time range preset buttons */} +
+
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Dimension filters */} +
+
+ + {t("filters")} +
+ + {/* Key filter */} + + + {/* Provider filter */} + + + {/* Model filter */} + + + {/* Clear filters */} + {hasActiveFilters && ( + + )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx new file mode 100644 index 000000000..a2be5293e --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "@/i18n/routing"; +import { DEFAULT_FILTERS, resolveTimePresetDates, type UserInsightsFilters } from "./filters/types"; +import { UserInsightsFilterBar } from "./filters/user-insights-filter-bar"; +import { UserKeyTrendChart } from "./user-key-trend-chart"; +import { UserModelBreakdown } from "./user-model-breakdown"; +import { UserOverviewCards } from "./user-overview-cards"; +import { UserProviderBreakdown } from "./user-provider-breakdown"; + +interface UserInsightsViewProps { + userId: number; + userName: string; +} + +export function UserInsightsView({ userId, userName }: UserInsightsViewProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const router = useRouter(); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + + const { startDate, endDate } = resolveTimePresetDates(filters.timeRange); + + return ( +
+
+ +
+

+ {t("title")} - {userName} +

+
+
+ + + + + + + +
+ + +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx new file mode 100644 index 000000000..a1f61131e --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { getUserInsightsKeyTrend } from "@/actions/admin-user-insights"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { DatabaseKeyStatRow } from "@/types/statistics"; +import type { TimeRangePreset } from "./filters/types"; + +interface UserKeyTrendChartProps { + userId: number; + timeRange: TimeRangePreset; + keyId?: number; +} + +const CHART_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", + "#8b5cf6", + "#ec4899", + "#f97316", +]; + +interface ChartKey { + id: number; + name: string; + dataKey: string; +} + +export function UserKeyTrendChart({ userId, timeRange, keyId }: UserKeyTrendChartProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const tStats = useTranslations("dashboard.stats"); + + const { + data: rawData, + isLoading, + isError, + } = useQuery({ + queryKey: ["user-insights-key-trend", userId, timeRange], + queryFn: async () => { + const result = await getUserInsightsKeyTrend(userId, timeRange); + if (!result.ok) throw new Error(result.error); + return result.data as DatabaseKeyStatRow[]; + }, + }); + + const { chartData, keys, chartConfig } = useMemo(() => { + if (!rawData || rawData.length === 0) { + return { chartData: [], keys: [] as ChartKey[], chartConfig: {} as ChartConfig }; + } + + // Client-side filter by keyId if specified + const filtered = keyId ? rawData.filter((row) => row.key_id === keyId) : rawData; + + // Extract unique keys + const keyMap = new Map(); + for (const row of filtered) { + if (!keyMap.has(row.key_id)) { + keyMap.set(row.key_id, row.key_name); + } + } + + const uniqueKeys: ChartKey[] = Array.from(keyMap.entries()).map(([id, name]) => ({ + id, + name, + dataKey: `key-${id}`, + })); + + // Build chart data grouped by date + const dataByDate = new Map>(); + for (const row of filtered) { + const dateStr = + timeRange === "today" ? new Date(row.date).toISOString() : row.date.split("T")[0]; + + if (!dataByDate.has(dateStr)) { + dataByDate.set(dateStr, { date: dateStr }); + } + const entry = dataByDate.get(dateStr)!; + const dk = `key-${row.key_id}`; + entry[`${dk}_calls`] = row.api_calls || 0; + const cost = row.total_cost; + entry[`${dk}_cost`] = + typeof cost === "number" ? cost : cost != null ? Number.parseFloat(cost) || 0 : 0; + } + + // Build chart config + const config: ChartConfig = { + calls: { label: tStats("requests") }, + }; + for (let i = 0; i < uniqueKeys.length; i++) { + const key = uniqueKeys[i]; + config[key.dataKey] = { + label: key.name, + color: CHART_COLORS[i % CHART_COLORS.length], + }; + } + + return { + chartData: Array.from(dataByDate.values()).sort((a, b) => + (a.date as string).localeCompare(b.date as string) + ), + keys: uniqueKeys, + chartConfig: config, + }; + }, [rawData, timeRange, keyId, tStats]); + + return ( + + + {t("keyTrend")} + + + {isLoading ? ( + + ) : isError ? ( +
+ + {t("loadError")} +
+ ) : chartData.length === 0 ? ( +
+ {t("noData")} +
+ ) : ( + + + + {keys.map((key, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + return ( + + + + + ); + })} + + + { + if (timeRange === "today") { + const d = new Date(value); + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; + } + const parts = value.split("-"); + return `${parts[1]}/${parts[2]}`; + }} + /> + + + {keys.map((key, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + return ( + + ); + })} + + + )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx new file mode 100644 index 000000000..477d9e2c5 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle, BarChart3 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getUserInsightsModelBreakdown } from "@/actions/admin-user-insights"; +import { + ModelBreakdownColumn, + type ModelBreakdownItem, + type ModelBreakdownLabels, +} from "@/components/analytics/model-breakdown-column"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { CurrencyCode } from "@/lib/utils/currency"; + +interface UserModelBreakdownProps { + userId: number; + startDate?: string; + endDate?: string; + keyId?: number; + providerId?: number; +} + +export function UserModelBreakdown({ + userId, + startDate, + endDate, + keyId, + providerId, +}: UserModelBreakdownProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const tStats = useTranslations("myUsage.stats"); + + const filters = keyId || providerId ? { keyId, providerId } : undefined; + + const { data, isLoading, isError } = useQuery({ + queryKey: ["user-insights-model-breakdown", userId, startDate, endDate, keyId, providerId], + queryFn: async () => { + const result = await getUserInsightsModelBreakdown(userId, startDate, endDate, filters); + if (!result.ok) throw new Error(result.error); + return result.data; + }, + }); + + const labels: ModelBreakdownLabels = { + unknownModel: t("unknownModel"), + modal: { + requests: tStats("modal.requests"), + cost: tStats("modal.cost"), + inputTokens: tStats("modal.inputTokens"), + outputTokens: tStats("modal.outputTokens"), + cacheCreationTokens: tStats("modal.cacheWrite"), + cacheReadTokens: tStats("modal.cacheRead"), + totalTokens: tStats("modal.totalTokens"), + costPercentage: tStats("modal.cost"), + cacheHitRate: tStats("modal.cacheHitRate"), + cacheTokens: tStats("modal.cacheTokens"), + performanceHigh: tStats("modal.performanceHigh"), + performanceMedium: tStats("modal.performanceMedium"), + performanceLow: tStats("modal.performanceLow"), + }, + }; + + const items: ModelBreakdownItem[] = data + ? data.breakdown.map((item) => ({ + model: item.model, + requests: item.requests, + cost: item.cost, + inputTokens: item.inputTokens, + outputTokens: item.outputTokens, + cacheCreationTokens: item.cacheCreationTokens, + cacheReadTokens: item.cacheReadTokens, + })) + : []; + + const totalCost = items.reduce((sum, item) => sum + item.cost, 0); + const currencyCode = (data?.currencyCode ?? "USD") as CurrencyCode; + + return ( + + + + {t("modelBreakdown")} + + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : isError ? ( +
+ + {t("loadError")} +
+ ) : items.length === 0 ? ( +
+ {t("noData")} +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx new file mode 100644 index 000000000..681850de8 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Activity, AlertCircle, Clock, DollarSign, TrendingUp } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getUserInsightsOverview } from "@/actions/admin-user-insights"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { type CurrencyCode, formatCurrency } from "@/lib/utils"; + +interface UserOverviewCardsProps { + userId: number; +} + +function formatResponseTime(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function UserOverviewCards({ userId }: UserOverviewCardsProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["user-insights-overview", userId], + queryFn: async () => { + const result = await getUserInsightsOverview(userId); + if (!result.ok) throw new Error(result.error); + return result.data; + }, + }); + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ ); + } + + if (isError) { + return ( + + +
+ + {t("loadError")} +
+
+
+ ); + } + + if (!data) return null; + + const { overview, currencyCode } = data; + const cc = currencyCode as CurrencyCode; + + const metrics = [ + { + key: "todayRequests", + label: t("todayRequests"), + value: overview.todayRequests.toLocaleString(), + icon: TrendingUp, + }, + { + key: "todayCost", + label: t("todayCost"), + value: formatCurrency(overview.todayCost, cc), + icon: DollarSign, + }, + { + key: "avgResponseTime", + label: t("avgResponseTime"), + value: formatResponseTime(overview.avgResponseTime), + icon: Clock, + }, + { + key: "errorRate", + label: t("errorRate"), + value: `${overview.todayErrorRate.toFixed(1)}%`, + icon: Activity, + }, + ]; + + return ( +
+ {metrics.map((metric) => ( + + +
+ + {metric.label} +
+
{metric.value}
+
+
+ ))} +
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx new file mode 100644 index 000000000..5c046a763 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle, Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getUserInsightsProviderBreakdown } from "@/actions/admin-user-insights"; +import { + ModelBreakdownColumn, + type ModelBreakdownItem, + type ModelBreakdownLabels, +} from "@/components/analytics/model-breakdown-column"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { CurrencyCode } from "@/lib/utils/currency"; + +interface UserProviderBreakdownProps { + userId: number; + startDate?: string; + endDate?: string; + keyId?: number; + model?: string; +} + +export function UserProviderBreakdown({ + userId, + startDate, + endDate, + keyId, + model, +}: UserProviderBreakdownProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const tStats = useTranslations("myUsage.stats"); + + const filters = keyId || model ? { keyId, model } : undefined; + + const { data, isLoading, isError } = useQuery({ + queryKey: ["user-insights-provider-breakdown", userId, startDate, endDate, keyId, model], + queryFn: async () => { + const result = await getUserInsightsProviderBreakdown(userId, startDate, endDate, filters); + if (!result.ok) throw new Error(result.error); + return result.data; + }, + }); + + const labels: ModelBreakdownLabels = { + unknownModel: t("unknownProvider"), + modal: { + requests: tStats("modal.requests"), + cost: tStats("modal.cost"), + inputTokens: tStats("modal.inputTokens"), + outputTokens: tStats("modal.outputTokens"), + cacheCreationTokens: tStats("modal.cacheWrite"), + cacheReadTokens: tStats("modal.cacheRead"), + totalTokens: tStats("modal.totalTokens"), + costPercentage: tStats("modal.cost"), + cacheHitRate: tStats("modal.cacheHitRate"), + cacheTokens: tStats("modal.cacheTokens"), + performanceHigh: tStats("modal.performanceHigh"), + performanceMedium: tStats("modal.performanceMedium"), + performanceLow: tStats("modal.performanceLow"), + }, + }; + + const items: ModelBreakdownItem[] = data + ? data.breakdown.map((item) => ({ + model: item.providerName, + requests: item.requests, + cost: item.cost, + inputTokens: item.inputTokens, + outputTokens: item.outputTokens, + cacheCreationTokens: item.cacheCreationTokens, + cacheReadTokens: item.cacheReadTokens, + })) + : []; + + const totalCost = items.reduce((sum, item) => sum + item.cost, 0); + const currencyCode = (data?.currencyCode ?? "USD") as CurrencyCode; + + return ( + + + + {t("providerBreakdown")} + + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : isError ? ( +
+ + {t("loadError")} +
+ ) : items.length === 0 ? ( +
+ {t("noData")} +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx new file mode 100644 index 000000000..9f71cc066 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "@/i18n/routing"; +import { getSession } from "@/lib/auth"; +import { findUserById } from "@/repository/user"; +import { UserInsightsView } from "./_components/user-insights-view"; + +export const dynamic = "force-dynamic"; + +export default async function UserInsightsPage({ + params, +}: { + params: Promise<{ locale: string; userId: string }>; +}) { + const { locale, userId: userIdStr } = await params; + const session = await getSession(); + + if (!session || session.user.role !== "admin") { + return redirect({ href: "/dashboard/leaderboard", locale }); + } + + const userId = Number(userIdStr); + if (!Number.isInteger(userId) || userId <= 0) { + return redirect({ href: "/dashboard/leaderboard", locale }); + } + + const user = await findUserById(userId); + if (!user) { + return redirect({ href: "/dashboard/leaderboard", locale }); + } + + return ; +} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index 0a27ca05b..a2fccc2e2 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -34,7 +34,11 @@ function getRequestStatus(item: ProviderChainItem): StepStatus { if (item.reason === "session_reuse" || item.selectionMethod === "session_reuse") { return "session_reuse"; } - if (item.reason === "request_success" || item.reason === "retry_success") { + if ( + item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner" + ) { return "success"; } if ( @@ -43,11 +47,13 @@ function getRequestStatus(item: ProviderChainItem): StepStatus { item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" || - item.reason === "concurrent_limit_failed" + item.reason === "concurrent_limit_failed" || + item.reason === "hedge_loser_cancelled" || + item.reason === "client_abort" ) { return "failure"; } - // http2_fallback and other retry-related reasons are treated as pending/in-progress + // hedge_triggered, http2_fallback and other retry-related reasons are treated as pending/in-progress return "pending"; } @@ -710,23 +716,38 @@ export function LogicTraceTab({ item.reason === "session_reuse" || item.selectionMethod === "session_reuse"; // Determine icon based on type + const isHedgeTriggered = item.reason === "hedge_triggered"; + const isHedgeLoser = item.reason === "hedge_loser_cancelled"; + const isClientAbort = item.reason === "client_abort"; const stepIcon = isSessionReuse ? Link2 - : isRetry - ? RefreshCw - : status === "success" - ? CheckCircle - : status === "failure" - ? XCircle - : Server; + : isHedgeTriggered + ? GitBranch + : isHedgeLoser || isClientAbort + ? XCircle + : isRetry + ? RefreshCw + : status === "success" + ? CheckCircle + : status === "failure" + ? XCircle + : Server; // Determine title based on type // For session reuse flow, show simplified "Execute Request" title for the first item const stepTitle = isSessionReuse ? t("logicTrace.executeRequest") - : isRetry - ? t("logicTrace.retryAttempt", { number: item.attemptNumber ?? 1 }) - : t("logicTrace.attemptProvider", { provider: item.name }); + : isHedgeTriggered + ? tChain("timeline.hedgeTriggered") + : isHedgeLoser + ? tChain("timeline.hedgeLoserCancelled") + : isClientAbort + ? tChain("timeline.clientAbort") + : isRetry + ? t("logicTrace.retryAttempt", { number: item.attemptNumber ?? 1 }) + : item.reason === "hedge_winner" + ? tChain("timeline.hedgeWinner") + : t("logicTrace.attemptProvider", { provider: item.name }); return ( { expect(countBadge).not.toBeUndefined(); }); }); + +describe("provider-chain-popover hedge/abort reason handling", () => { + test("hedge_triggered is not counted as actual request", () => { + const html = renderWithIntl( + + ); + + // hedge_triggered is informational, not an actual request + // so the request count should be 2 (winner + loser), not 3 + const document = parseHtml(html); + const countBadge = Array.from(document.querySelectorAll('[data-slot="badge"]')).find((node) => + (node.textContent ?? "").includes("times") + ); + expect(countBadge?.textContent).toContain("2"); + }); + + test("hedge_winner is treated as successful provider", () => { + const html = renderWithIntl( + + ); + + // Should render without error + expect(html).toContain("p2"); + }); + + test("client_abort is counted as actual request", () => { + const html = renderWithIntl( + + ); + + // client_abort should be counted as actual request (requestCount=1 -> single view) + expect(html).toContain("p1"); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index 4f27ca0ae..633e617ba 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, CheckCircle, ChevronRight, + GitBranch, InfoIcon, Link2, MinusCircle, @@ -17,7 +18,12 @@ import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { formatProbabilityCompact } from "@/lib/utils/provider-chain-formatter"; +import { + formatProbabilityCompact, + getRetryCount, + isActualRequest, + isHedgeRace, +} from "@/lib/utils/provider-chain-formatter"; import type { ProviderChainItem } from "@/types/message"; import { getFake200ReasonKey } from "./fake200-reason"; @@ -30,25 +36,6 @@ interface ProviderChainPopoverProps { onChainItemClick?: (chainIndex: number) => void; } -/** - * Determine if this is an actual request record (excluding intermediate states) - */ -function isActualRequest(item: ProviderChainItem): boolean { - if (item.reason === "client_restriction_filtered") return false; - - if (item.reason === "concurrent_limit_failed") return true; - - if (item.reason === "retry_failed" || item.reason === "system_error") return true; - if (item.reason === "resource_not_found") return true; - if (item.reason === "endpoint_pool_exhausted") return true; - if (item.reason === "vendor_type_all_timeout") return true; - if (item.reason === "client_error_non_retryable") return true; - if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { - return true; - } - return false; -} - function parseGroupTags(groupTag?: string | null): string[] { if (!groupTag) return []; const seen = new Set(); @@ -70,7 +57,12 @@ function getItemStatus(item: ProviderChainItem): { color: string; bgColor: string; } { - if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { + if ( + (item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner") && + item.statusCode + ) { return { icon: CheckCircle, color: "text-emerald-600", @@ -111,6 +103,27 @@ function getItemStatus(item: ProviderChainItem): { bgColor: "bg-muted/30", }; } + if (item.reason === "hedge_triggered") { + return { + icon: GitBranch, + color: "text-indigo-600", + bgColor: "bg-indigo-50 dark:bg-indigo-950/30", + }; + } + if (item.reason === "hedge_loser_cancelled") { + return { + icon: XCircle, + color: "text-slate-500", + bgColor: "bg-slate-50 dark:bg-slate-800/50", + }; + } + if (item.reason === "client_abort") { + return { + icon: MinusCircle, + color: "text-amber-600", + bgColor: "bg-amber-50 dark:bg-amber-950/30", + }; + } return { icon: RefreshCw, color: "text-slate-500", @@ -139,6 +152,8 @@ export function ProviderChainPopover({ // Calculate actual request count (excluding intermediate states) const requestCount = chain.filter(isActualRequest).length; + const retryCount = getRetryCount(chain); + const isHedge = isHedgeRace(chain); // Fallback for empty string const displayName = finalProvider || "-"; @@ -154,8 +169,8 @@ export function ProviderChainPopover({ const initialSelection = chain.find((item) => item.reason === "initial_selection"); const selectionContext = initialSelection?.decisionContext; - // Single request: show name with icon and compact tooltip - if (requestCount <= 1) { + // Single request (no retry and no hedge): show name with icon and compact tooltip + if (retryCount === 0 && !isHedge) { // Get session reuse context for detailed tooltip const sessionReuseItem = chain.find( (item) => item.reason === "session_reuse" || item.selectionMethod === "session_reuse" @@ -378,7 +393,12 @@ export function ProviderChainPopover({ // Get the successful provider's costMultiplier and groupTag const successfulProvider = [...chain] .reverse() - .find((item) => item.reason === "request_success" || item.reason === "retry_success"); + .find( + (item) => + item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner" + ); const finalCostMultiplier = successfulProvider?.costMultiplier; const finalGroupTag = successfulProvider?.groupTag; const finalGroupTags = parseGroupTags(finalGroupTag); @@ -395,14 +415,18 @@ export function ProviderChainPopover({ type="button" variant="ghost" className="h-auto p-0 font-normal hover:bg-transparent w-full min-w-0" - aria-label={`${displayName} - ${requestCount}${t("logs.table.times")}`} + aria-label={`${displayName} - ${isHedge ? tChain("timeline.hedgeRace") : `${requestCount}${t("logs.table.times")}`}`} > {/* Request count badge */} - - {requestCount} - {t("logs.table.times")} - + {isHedge ? ( + + ) : ( + + {requestCount} + {t("logs.table.times")} + + )} {/* Provider name */} {displayName} @@ -448,7 +472,7 @@ export function ProviderChainPopover({

{t("logs.providerChain.decisionChain")}

- {requestCount} {t("logs.table.times")} + {isHedge ? tChain("timeline.hedgeRace") : `${requestCount} ${t("logs.table.times")}`}
diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx new file mode 100644 index 000000000..bad3d3431 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx @@ -0,0 +1,84 @@ +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +// Mock dependencies before imports +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn().mockResolvedValue("UTC"), +})); + +vi.mock("./usage-logs-view-virtualized", () => ({ + UsageLogsViewVirtualized: () => null, +})); + +vi.mock("@/components/customs/active-sessions-list", () => ({ + ActiveSessionsList: () => null, +})); + +import { getSystemSettings } from "@/repository/system-config"; +import { UsageLogsDataSection } from "./usage-logs-sections"; + +describe("UsageLogsDataSection", () => { + it("passes billingModelSource and currencyCode from system settings", async () => { + vi.mocked(getSystemSettings).mockResolvedValue({ + billingModelSource: "redirected", + currencyDisplay: "CNY", + } as Awaited>); + + const searchParams = Promise.resolve({}); + const element = (await UsageLogsDataSection({ + isAdmin: true, + userId: 1, + searchParams, + })) as ReactElement; + + expect(element.props).toMatchObject({ + billingModelSource: "redirected", + currencyCode: "CNY", + }); + }); + + it("passes billingModelSource as original when configured", async () => { + vi.mocked(getSystemSettings).mockResolvedValue({ + billingModelSource: "original", + currencyDisplay: "USD", + } as Awaited>); + + const searchParams = Promise.resolve({}); + const element = (await UsageLogsDataSection({ + isAdmin: false, + userId: 42, + searchParams, + })) as ReactElement; + + expect(element.props).toMatchObject({ + billingModelSource: "original", + currencyCode: "USD", + isAdmin: false, + userId: 42, + }); + }); + + it("passes logsRefreshIntervalMs from env config", async () => { + vi.mocked(getSystemSettings).mockResolvedValue({ + billingModelSource: "redirected", + currencyDisplay: "USD", + } as Awaited>); + + const searchParams = Promise.resolve({}); + const element = (await UsageLogsDataSection({ + isAdmin: true, + userId: 1, + searchParams, + })) as ReactElement; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props = (element as any).props; + expect(props).toHaveProperty("logsRefreshIntervalMs"); + expect(typeof props.logsRefreshIntervalMs).toBe("number"); + expect(props.logsRefreshIntervalMs).toBeGreaterThanOrEqual(250); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx index 0c1ef1d5c..b15b4717a 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx @@ -1,5 +1,6 @@ import { cache } from "react"; import { ActiveSessionsList } from "@/components/customs/active-sessions-list"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { getSystemSettings } from "@/repository/system-config"; import { UsageLogsViewVirtualized } from "./usage-logs-view-virtualized"; @@ -30,6 +31,7 @@ export async function UsageLogsDataSection({ }: UsageLogsDataSectionProps) { const resolvedSearchParams = await searchParams; const serverTimeZone = await resolveSystemTimezone(); + const systemSettings = await getCachedSystemSettings(); return ( ); } diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index 7b87a0f56..dd9065476 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -25,7 +25,8 @@ import { NON_BILLING_ENDPOINT, shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; -import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter"; +import { shouldShowCostBadgeInCell } from "@/lib/utils/provider-chain-display"; +import { formatProviderSummary, getFinalProviderName } from "@/lib/utils/provider-chain-formatter"; import { getPricingResolutionSpecialSetting, hasPriorityServiceTierSpecialSetting, @@ -125,7 +126,9 @@ export function UsageLogsTable({ .reverse() .find( (item) => - item.reason === "request_success" || item.reason === "retry_success" + item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner" ) : null; @@ -199,9 +202,7 @@ export function UsageLogsTable({ 0 - ? log.providerChain[log.providerChain.length - 1].name - : null) || + getFinalProviderName(log.providerChain ?? []) || log.providerName || tChain("circuit.unknown") } @@ -243,16 +244,16 @@ export function UsageLogsTable({ )}
{/* 显示供应商倍率 Badge(不为 1.0 时) */} - {hasCostBadge && multiplier != null ? ( + {shouldShowCostBadgeInCell(log.providerChain, multiplier) ? ( 1 + multiplier! > 1 ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0" : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0" } > - ×{multiplier.toFixed(2)} + ×{multiplier!.toFixed(2)} ) : null}
@@ -467,11 +468,11 @@ export function UsageLogsTable({ {formatTokenAmount(log.cacheReadInputTokens)} tokens (0.1x) )} - {hasCostBadge && multiplier != null ? ( + {hasCostBadge && multiplier != null && (
{t("logs.billingDetails.multiplier")}: {multiplier.toFixed(2)}x
- ) : null} + )} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index ffaadca05..edabca40a 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -38,6 +38,7 @@ interface UsageLogsViewVirtualizedProps { currencyCode?: CurrencyCode; billingModelSource?: BillingModelSource; serverTimeZone?: string; + logsRefreshIntervalMs?: number; } async function fetchSystemSettings(): Promise { @@ -65,6 +66,7 @@ function UsageLogsViewContent({ currencyCode = "USD", billingModelSource = "original", serverTimeZone, + logsRefreshIntervalMs, }: UsageLogsViewVirtualizedProps) { const t = useTranslations("dashboard"); const tc = useTranslations("customs"); @@ -384,7 +386,7 @@ function UsageLogsViewContent({ currencyCode={resolvedCurrencyCode} billingModelSource={resolvedBillingModelSource} autoRefreshEnabled={!isFullscreenOpen && isAutoRefresh} - autoRefreshIntervalMs={5000} + autoRefreshIntervalMs={logsRefreshIntervalMs ?? 5000} hiddenColumns={hiddenColumns} /> diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index 404501fa6..0a3f3ecb1 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -53,6 +53,10 @@ vi.mock("@/hooks/use-virtualizer", () => ({ vi.mock("@/lib/utils/provider-chain-formatter", () => ({ formatProviderSummary: () => "provider summary", + getFinalProviderName: () => "mock-provider", + getRetryCount: () => 0, + isHedgeRace: () => false, + isActualRequest: () => true, })); vi.mock("@/actions/usage-logs", () => ({ @@ -94,6 +98,11 @@ vi.mock("./error-details-dialog", () => ({ ErrorDetailsDialog: () =>
, })); +let mockIsProviderFinalized = true; +vi.mock("@/lib/utils/provider-display", () => ({ + isProviderFinalized: () => mockIsProviderFinalized, +})); + import { VirtualizedLogsTable } from "./virtualized-logs-table"; function makeLog(overrides: Partial): UsageLogRow { @@ -295,9 +304,9 @@ describe("virtualized-logs-table multiplier badge", () => { const html = renderToStaticMarkup( ); - // VirtualizedLogsTable uses ProviderChainPopover which renders the provider name directly, - // not via formatProviderSummary (which is only used in other contexts) - expect(html).toContain("p1"); + // VirtualizedLogsTable uses ProviderChainPopover which renders the provider name + // via getFinalProviderName (mocked to return "mock-provider") + expect(html).toContain("mock-provider"); expect(html).toContain("logs.table.loadingMore"); }); @@ -375,3 +384,116 @@ describe("virtualized-logs-table multiplier badge", () => { expect(html).not.toContain("bg-amber-50"); }); }); + +describe("virtualized-logs-table live chain display", () => { + function setupLiveChainDefaults() { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + mockIsProviderFinalized = false; + } + + test("renders provider name from live chain when unfinalised", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [{ id: 1, name: "openai-east", reason: "initial_selection" }], + phase: "provider_selected", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("openai-east"); + expect(html).toContain("animate-spin"); + }); + + test("renders retrying badge when phase is retrying", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [ + { id: 1, name: "p1", reason: "initial_selection" }, + { id: 2, name: "p2", reason: "retry_failed" }, + ], + phase: "retrying", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("logs.details.retrying"); + expect(html).toContain("text-amber-500"); + }); + + test("renders GitBranch icon when phase is hedge_racing", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [{ id: 1, name: "p1", reason: "hedge_triggered" }], + phase: "hedge_racing", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("text-indigo-500"); + }); + + test("renders generic in-progress when live chain is empty", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [], + phase: "queued", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("logs.details.inProgress"); + }); + + test("renders generic spinner when no live chain data", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: undefined, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("logs.details.inProgress"); + expect(html).toContain("animate-spin"); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 8a20b905a..10288f03a 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -1,7 +1,7 @@ "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { ArrowUp, Loader2 } from "lucide-react"; +import { ArrowUp, GitBranch, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -22,11 +22,13 @@ import { NON_BILLING_ENDPOINT, shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; +import { shouldShowCostBadgeInCell } from "@/lib/utils/provider-chain-display"; +import { getFinalProviderName } from "@/lib/utils/provider-chain-formatter"; +import { isProviderFinalized } from "@/lib/utils/provider-display"; import { getPricingResolutionSpecialSetting, hasPriorityServiceTierSpecialSetting, } from "@/lib/utils/special-settings"; -import type { ProviderChainItem } from "@/types/message"; import type { BillingModelSource } from "@/types/system-config"; import { ErrorDetailsDialog } from "./error-details-dialog"; import { ModelDisplayWithRedirect } from "./model-display-with-redirect"; @@ -75,7 +77,8 @@ export function VirtualizedLogsTable({ serverTimeZone: _serverTimeZone, }: VirtualizedLogsTableProps) { const t = useTranslations("dashboard"); - const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); + const getPricingSourceLabel = (source: string) => + t(`logs.billingDetails.pricingSource.${source}`); const tChain = useTranslations("provider-chain"); const parentRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); @@ -129,7 +132,11 @@ export function VirtualizedLogsTable({ initialPageParam: undefined as { createdAt: string; id: number } | undefined, staleTime: 30000, // 30 seconds refetchOnWindowFocus: false, - refetchInterval: shouldPoll ? autoRefreshIntervalMs : false, + refetchInterval: (query) => { + if (!shouldPoll) return false; + if (query.state.fetchStatus !== "idle") return false; + return autoRefreshIntervalMs; + }, maxPages: 5, }); @@ -419,6 +426,33 @@ export function VirtualizedLogsTable({ {t("logs.table.blocked")} + ) : !isProviderFinalized(log) ? ( + log._liveChain ? ( +
+ + + {log._liveChain.chain.length > 0 + ? log._liveChain.chain[log._liveChain.chain.length - 1].name + : t("logs.details.inProgress")} + + {log._liveChain.phase === "retrying" && ( + + {t("logs.details.retrying")} + + )} + {log._liveChain.phase === "hedge_racing" && ( + + )} +
+ ) : ( + + + {t("logs.details.inProgress")} + + ) ) : (
@@ -431,39 +465,25 @@ export function VirtualizedLogsTable({ .find( (item) => item.reason === "request_success" || - item.reason === "retry_success" + item.reason === "retry_success" || + item.reason === "hedge_winner" ) : null; const actualCostMultiplier = successfulProvider?.costMultiplier ?? log.costMultiplier; - const multiplier = Number(actualCostMultiplier); + const multiplier = + actualCostMultiplier === "" || actualCostMultiplier == null + ? null + : Number(actualCostMultiplier); const hasCostBadge = actualCostMultiplier !== "" && actualCostMultiplier != null && Number.isFinite(multiplier) && multiplier !== 1; - - // Calculate actual request count (same logic as ProviderChainPopover) - const isActualRequest = (item: ProviderChainItem) => { - if (item.reason === "concurrent_limit_failed") return true; - if ( - item.reason === "retry_failed" || - item.reason === "system_error" - ) - return true; - if ( - (item.reason === "request_success" || - item.reason === "retry_success") && - item.statusCode - ) { - return true; - } - return false; - }; - const actualRequestCount = - log.providerChain?.filter(isActualRequest).length ?? 0; - // Only show badge in table when no retry (Popover shows badge when retry) - const showBadgeInTable = hasCostBadge && actualRequestCount <= 1; + const showBadgeInTable = shouldShowCostBadgeInCell( + log.providerChain, + multiplier + ); return ( <> @@ -471,9 +491,7 @@ export function VirtualizedLogsTable({ 0 - ? log.providerChain[log.providerChain.length - 1].name - : null) || + getFinalProviderName(log.providerChain ?? []) || log.providerName || tChain("circuit.unknown") } @@ -493,12 +511,12 @@ export function VirtualizedLogsTable({ 1 + multiplier! > 1 ? "text-[10px] px-1 py-0 bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0" : "text-[10px] px-1 py-0 bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0" } > - x{multiplier.toFixed(2)} + x{multiplier!.toFixed(2)} )} diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx index 5a85248b1..eb170138c 100644 --- a/src/app/[locale]/dashboard/quotas/users/page.tsx +++ b/src/app/[locale]/dashboard/quotas/users/page.tsx @@ -21,11 +21,31 @@ async function getUsersWithQuotas(): Promise { const allUserIds = users.map((u) => u.id); const allKeyIds = users.flatMap((u) => u.keys.map((k) => k.id)); + // Build resetAt maps for users with cost reset timestamps + const userResetAtMap = new Map(); + const keyResetAtMap = new Map(); + for (const u of users) { + if (u.costResetAt instanceof Date) { + userResetAtMap.set(u.id, u.costResetAt); + for (const k of u.keys) { + keyResetAtMap.set(k.id, u.costResetAt); + } + } + } + // 3 queries total instead of N+M individual SUM queries const [quotaResults, userCostMap, keyCostMap] = await Promise.all([ Promise.all(users.map((u) => getUserLimitUsage(u.id))), - sumUserTotalCostBatch(allUserIds), - sumKeyTotalCostBatchByIds(allKeyIds), + sumUserTotalCostBatch( + allUserIds, + undefined, + userResetAtMap.size > 0 ? userResetAtMap : undefined + ), + sumKeyTotalCostBatchByIds( + allKeyIds, + undefined, + keyResetAtMap.size > 0 ? keyResetAtMap : undefined + ), ]); return users.map((user, idx) => { diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index e2bfc0a11..24ee3fa68 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -1,9 +1,9 @@ "use client"; -import { useInfiniteQuery, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, Loader2, Plus, Search, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyUsageData } from "@/actions/users"; import { getAllUserKeyGroups, @@ -23,13 +23,14 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { TagInput } from "@/components/ui/tag-input"; +import { clearUsageCache } from "@/lib/dashboard/user-limit-usage-cache"; +import { loadUserUsagePagesSequentially } from "@/lib/dashboard/user-usage-loader"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { CurrencyCode } from "@/lib/utils/currency"; import type { User, UserDisplay } from "@/types/user"; import { AddKeyDialog } from "../_components/user/add-key-dialog"; import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog"; import { CreateUserDialog } from "../_components/user/create-user-dialog"; -import { clearUsageCache } from "../_components/user/user-limit-badge"; import { UserManagementTable } from "../_components/user/user-management-table"; /** @@ -81,6 +82,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { | "createdAt" >("createdAt"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [usageByKeyId, setUsageByKeyId] = useState>({}); // Debounce search term to avoid frequent API requests const debouncedSearchTerm = useDebounce(searchTerm, 300); @@ -195,39 +197,53 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { staleTime: 30_000, }); - // Per-page usage queries: fire independently for each loaded page const pageUserIds = useMemo( () => (data?.pages ?? []).map((page) => page.users.map((u) => u.id)), [data] ); + const usageDatasetKey = useMemo(() => JSON.stringify(queryKey), [queryKey]); + const latestUsageDatasetKeyRef = useRef(usageDatasetKey); - const usageQueries = useQueries({ - queries: isAdmin - ? pageUserIds.map((ids) => ({ - queryKey: ["users-usage", ids], - queryFn: () => getUsersUsageBatch(ids), - enabled: ids.length > 0, - staleTime: 60_000, - refetchOnWindowFocus: false, - })) - : [], - }); + useEffect(() => { + latestUsageDatasetKeyRef.current = usageDatasetKey; + setUsageByKeyId({}); + }, [usageDatasetKey]); - // Stable fingerprint: only changes when a query actually receives new data, - // not on every render tick (useQueries returns a new array reference each time). - const usageDataVersion = usageQueries.map((q) => q.dataUpdatedAt).join(","); - - // Build merged usageByKeyId lookup from all resolved usage queries. - // biome-ignore lint/correctness/useExhaustiveDependencies: usageDataVersion tracks actual data changes; usageQueries ref is unstable - const usageByKeyId = useMemo(() => { - const merged: Record = {}; - for (const query of usageQueries) { - if (query.data?.ok) { - Object.assign(merged, query.data.data.usageByKeyId); - } + useEffect(() => { + if (!isAdmin || isLoading || isFetching || pageUserIds.length === 0) { + return; } - return merged; - }, [usageDataVersion]); + + const abortController = new AbortController(); + const requestedDatasetKey = usageDatasetKey; + + void loadUserUsagePagesSequentially({ + pageUserIds, + signal: abortController.signal, + fetchUsagePage: async (userIds) => { + const result = await queryClient.fetchQuery({ + queryKey: ["users-usage", userIds], + queryFn: () => getUsersUsageBatch(userIds), + staleTime: 60_000, + }); + return result.ok ? result.data.usageByKeyId : {}; + }, + onPageLoaded: (pageUsageByKeyId) => { + if (latestUsageDatasetKeyRef.current !== requestedDatasetKey) { + return; + } + + setUsageByKeyId((previous) => ({ + ...previous, + ...pageUsageByKeyId, + })); + }, + }); + + return () => { + abortController.abort(); + }; + }, [isAdmin, isFetching, isLoading, pageUserIds, queryClient, usageDatasetKey]); const coreUsers = useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]); @@ -771,6 +787,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { onOpenBatchEdit={handleOpenBatchEdit} translations={tableTranslations} onRefresh={() => { + setUsageByKeyId({}); clearUsageCache(); queryClient.invalidateQueries({ queryKey: ["users-usage"] }); refetch(); diff --git a/src/app/[locale]/internal/dashboard/big-screen/page.tsx b/src/app/[locale]/internal/dashboard/big-screen/page.tsx index 1036f01c0..a5e7145b9 100644 --- a/src/app/[locale]/internal/dashboard/big-screen/page.tsx +++ b/src/app/[locale]/internal/dashboard/big-screen/page.tsx @@ -311,7 +311,7 @@ const ActivityStream = ({ >
{item.user}
{item.model}
-
{item.provider}
+
{item.provider || "..."}
1000 ? "text-red-400" : "text-green-400"}`} > @@ -320,12 +320,14 @@ const ActivityStream = ({
- {item.status} + {item.status === 0 ? "..." : item.status}
diff --git a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx index a73947cea..16d3f1a64 100644 --- a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx +++ b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx @@ -1,34 +1,17 @@ "use client"; import { format } from "date-fns"; -import { - Activity, - ArrowDownRight, - ArrowUpRight, - BarChart3, - ChevronLeft, - ChevronRight, - Coins, - Database, - Hash, - Percent, - RefreshCw, - Target, -} from "lucide-react"; +import { BarChart3, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - getMyStatsSummary, - type ModelBreakdownItem, - type MyStatsSummary, -} from "@/actions/my-usage"; +import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage"; +import { ModelBreakdownColumn } from "@/components/analytics/model-breakdown-column"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { formatTokenAmount } from "@/lib/utils"; -import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; +import { formatCurrency } from "@/lib/utils/currency"; import { LogsDateRangePicker } from "../../dashboard/logs/_components/logs-date-range-picker"; interface StatisticsSummaryCardProps { @@ -320,250 +303,3 @@ export function StatisticsSummaryCard({ } const MODEL_BREAKDOWN_PAGE_SIZE = 5; - -interface ModelBreakdownColumnProps { - pageItems: ModelBreakdownItem[]; - currencyCode: CurrencyCode; - totalCost: number; - keyPrefix: string; - pageOffset: number; -} - -function ModelBreakdownColumn({ - pageItems, - currencyCode, - totalCost, - keyPrefix, - pageOffset, -}: ModelBreakdownColumnProps) { - return ( -
- {pageItems.map((item, index) => ( - - ))} -
- ); -} - -interface ModelBreakdownRowProps { - model: string | null; - requests: number; - cost: number; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - currencyCode: CurrencyCode; - totalCost: number; -} - -function ModelBreakdownRow({ - model, - requests, - cost, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - currencyCode, - totalCost, -}: ModelBreakdownRowProps) { - const [open, setOpen] = useState(false); - const t = useTranslations("myUsage.stats"); - - const totalAllTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; - const cacheHitRate = - totalInputTokens > 0 ? ((cacheReadTokens / totalInputTokens) * 100).toFixed(1) : "0.0"; - const costPercentage = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : "0.0"; - - const cacheHitRateNum = Number.parseFloat(cacheHitRate); - const cacheHitColor = - cacheHitRateNum >= 85 - ? "text-green-600 dark:text-green-400" - : cacheHitRateNum >= 60 - ? "text-yellow-600 dark:text-yellow-400" - : "text-orange-600 dark:text-orange-400"; - - return ( - <> -
setOpen(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setOpen(true); - } - }} - > -
- {model || t("unknownModel")} -
- - - {requests.toLocaleString()} - - - - {formatTokenAmount(totalAllTokens)} - - - - {cacheHitRate}% - -
-
-
-
{formatCurrency(cost, currencyCode)}
-
({costPercentage}%)
-
-
- - - - - - - {model || t("unknownModel")} - - -
-
-
-
- - {t("modal.requests")} -
-
{requests.toLocaleString()}
-
- -
-
- - {t("modal.totalTokens")} -
-
- {formatTokenAmount(totalAllTokens)} -
-
- -
-
- - {t("modal.cost")} -
-
- {formatCurrency(cost, currencyCode)} -
-
-
- - - -
-

- - {t("modal.totalTokens")} -

-
-
-
- - {t("modal.inputTokens")} -
-
- {formatTokenAmount(inputTokens)} -
-
- -
-
- - {t("modal.outputTokens")} -
-
- {formatTokenAmount(outputTokens)} -
-
-
-
- - - -
-

- - {t("modal.cacheTokens")} -

-
-
-
- - {t("modal.cacheWrite")} -
-
- {formatTokenAmount(cacheCreationTokens)} -
-
- -
-
- - {t("modal.cacheRead")} -
-
- {formatTokenAmount(cacheReadTokens)} -
-
-
- -
-
-
- - {t("modal.cacheHitRate")} -
-
- - {cacheHitRate}% - - = 85 - ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - : cacheHitRateNum >= 60 - ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" - : "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" - }`} - > - - {cacheHitRateNum >= 85 - ? t("modal.performanceHigh") - : cacheHitRateNum >= 60 - ? t("modal.performanceMedium") - : t("modal.performanceLow")} - -
-
-
-
-
-
-
- - ); -} diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index de146da9d..5fdcb4057 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -56,6 +56,7 @@ interface SystemSettingsFormProps { | "interceptAnthropicWarmupRequests" | "enableThinkingSignatureRectifier" | "enableBillingHeaderRectifier" + | "enableResponseInputRectifier" | "enableThinkingBudgetRectifier" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" @@ -105,6 +106,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) const [enableBillingHeaderRectifier, setEnableBillingHeaderRectifier] = useState( initialSettings.enableBillingHeaderRectifier ); + const [enableResponseInputRectifier, setEnableResponseInputRectifier] = useState( + initialSettings.enableResponseInputRectifier + ); const [enableThinkingBudgetRectifier, setEnableThinkingBudgetRectifier] = useState( initialSettings.enableThinkingBudgetRectifier ); @@ -172,6 +176,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier, enableBillingHeaderRectifier, + enableResponseInputRectifier, enableThinkingBudgetRectifier, enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection, @@ -201,6 +206,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) setInterceptAnthropicWarmupRequests(result.data.interceptAnthropicWarmupRequests); setEnableThinkingSignatureRectifier(result.data.enableThinkingSignatureRectifier); setEnableBillingHeaderRectifier(result.data.enableBillingHeaderRectifier); + setEnableResponseInputRectifier(result.data.enableResponseInputRectifier); setEnableThinkingBudgetRectifier(result.data.enableThinkingBudgetRectifier); setEnableCodexSessionIdCompletion(result.data.enableCodexSessionIdCompletion); setEnableClaudeMetadataUserIdInjection(result.data.enableClaudeMetadataUserIdInjection); @@ -476,6 +482,29 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) />
+ {/* Enable Response Input Rectifier */} +
+
+
+ +
+
+

+ {t("enableResponseInputRectifier")} +

+

+ {t("enableResponseInputRectifierDesc")} +

+
+
+ setEnableResponseInputRectifier(checked)} + disabled={isPending} + /> +
+ {/* Enable Codex Session ID Completion */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index 75bef5427..4a5373b37 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -51,6 +51,7 @@ async function SettingsConfigContent() { enableThinkingSignatureRectifier: settings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: settings.enableThinkingBudgetRectifier, enableBillingHeaderRectifier: settings.enableBillingHeaderRectifier, + enableResponseInputRectifier: settings.enableResponseInputRectifier, enableCodexSessionIdCompletion: settings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: settings.enableClaudeMetadataUserIdInjection, enableResponseFixer: settings.enableResponseFixer, diff --git a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx index 2e6d4d9db..7a7ff482e 100644 --- a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx +++ b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx @@ -93,20 +93,28 @@ export function LogCleanupPanel() { const result = await response.json(); if (!response.ok) { - throw new Error(result.error || t("failed")); + console.error("Cleanup API error:", result.error); + throw new Error(t("failed")); } if (result.success) { - toast.success( + const parts: string[] = [ t("successMessage", { count: result.totalDeleted.toLocaleString(), batches: result.batchCount, duration: (result.durationMs / 1000).toFixed(2), - }) - ); + }), + ]; + if (result.softDeletedPurged > 0) { + parts.push(t("softDeletePurged", { count: result.softDeletedPurged.toLocaleString() })); + } + if (result.vacuumPerformed) { + parts.push(t("vacuumComplete")); + } + toast.success(parts.join(" | ")); setIsOpen(false); } else { - toast.error(result.error || t("failed")); + toast.error(t("failed")); } } catch (error) { console.error("Cleanup error:", error); diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts b/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts new file mode 100644 index 000000000..0c52ad6fa --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts @@ -0,0 +1,222 @@ +import type { Context1mPreference } from "@/lib/special-attributes"; +import type { CacheTtlPreference } from "@/types/cache"; +import type { + AnthropicAdaptiveThinkingConfig, + AnthropicMaxTokensPreference, + AnthropicThinkingBudgetPreference, + CodexParallelToolCallsPreference, + CodexReasoningEffortPreference, + CodexReasoningSummaryPreference, + CodexServiceTierPreference, + CodexTextVerbosityPreference, + GeminiGoogleSearchPreference, + McpPassthroughType, + ProviderDisplay, +} from "@/types/provider"; +import { deepEquals } from "./deep-equals"; + +// 字段分析结果 +export type FieldAnalysisResult = + | { status: "uniform"; value: T } // 所有 provider 值相同 + | { status: "mixed"; values: T[] } // 值不同 + | { status: "empty" }; // 所有 provider 为 null/undefined + +// 批量设置分析结果(映射到 ProviderFormState 结构) +export interface BatchSettingsAnalysis { + routing: { + priority: FieldAnalysisResult; + weight: FieldAnalysisResult; + costMultiplier: FieldAnalysisResult; + groupTag: FieldAnalysisResult; + preserveClientIp: FieldAnalysisResult; + modelRedirects: FieldAnalysisResult>; + allowedModels: FieldAnalysisResult; + allowedClients: FieldAnalysisResult; + blockedClients: FieldAnalysisResult; + groupPriorities: FieldAnalysisResult>; + cacheTtlPreference: FieldAnalysisResult; + swapCacheTtlBilling: FieldAnalysisResult; + context1mPreference: FieldAnalysisResult; + codexReasoningEffortPreference: FieldAnalysisResult; + codexReasoningSummaryPreference: FieldAnalysisResult; + codexTextVerbosityPreference: FieldAnalysisResult; + codexParallelToolCallsPreference: FieldAnalysisResult; + codexServiceTierPreference: FieldAnalysisResult; + anthropicMaxTokensPreference: FieldAnalysisResult; + anthropicThinkingBudgetPreference: FieldAnalysisResult; + anthropicAdaptiveThinking: FieldAnalysisResult; + geminiGoogleSearchPreference: FieldAnalysisResult; + activeTimeStart: FieldAnalysisResult; + activeTimeEnd: FieldAnalysisResult; + }; + rateLimit: { + limit5hUsd: FieldAnalysisResult; + limitDailyUsd: FieldAnalysisResult; + dailyResetMode: FieldAnalysisResult<"fixed" | "rolling">; + dailyResetTime: FieldAnalysisResult; + limitWeeklyUsd: FieldAnalysisResult; + limitMonthlyUsd: FieldAnalysisResult; + limitTotalUsd: FieldAnalysisResult; + limitConcurrentSessions: FieldAnalysisResult; + }; + circuitBreaker: { + failureThreshold: FieldAnalysisResult; + openDurationMinutes: FieldAnalysisResult; + halfOpenSuccessThreshold: FieldAnalysisResult; + maxRetryAttempts: FieldAnalysisResult; + }; + network: { + proxyUrl: FieldAnalysisResult; + proxyFallbackToDirect: FieldAnalysisResult; + firstByteTimeoutStreamingSeconds: FieldAnalysisResult; + streamingIdleTimeoutSeconds: FieldAnalysisResult; + requestTimeoutNonStreamingSeconds: FieldAnalysisResult; + }; + mcp: { + mcpPassthroughType: FieldAnalysisResult; + mcpPassthroughUrl: FieldAnalysisResult; + }; +} + +/** + * 分析单个字段的值分布 + */ +function analyzeField( + providers: ProviderDisplay[], + extractor: (p: ProviderDisplay) => T +): FieldAnalysisResult { + if (providers.length === 0) return { status: "empty" }; + + const values = providers.map(extractor); + const firstValue = values[0]; + + // 检查是否所有值都为 null/undefined + if (values.every((v) => v == null)) return { status: "empty" }; + + // 检查是否所有值相同(使用深度比较) + if (values.every((v) => deepEquals(v, firstValue))) { + return { status: "uniform", value: firstValue }; + } + + // 值不同 - 去重 + const uniqueValues: T[] = []; + for (const v of values) { + if (!uniqueValues.some((existing) => deepEquals(existing, v))) { + uniqueValues.push(v); + } + } + + return { status: "mixed", values: uniqueValues }; +} + +/** + * 分析批量 provider 的所有字段设置 + */ +export function analyzeBatchProviderSettings(providers: ProviderDisplay[]): BatchSettingsAnalysis { + return { + routing: { + priority: analyzeField(providers, (p) => p.priority), + weight: analyzeField(providers, (p) => p.weight), + costMultiplier: analyzeField(providers, (p) => p.costMultiplier), + groupTag: analyzeField(providers, (p) => + p.groupTag + ? p.groupTag + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : [] + ), + preserveClientIp: analyzeField(providers, (p) => p.preserveClientIp), + modelRedirects: analyzeField(providers, (p) => p.modelRedirects ?? {}), + allowedModels: analyzeField(providers, (p) => p.allowedModels ?? []), + allowedClients: analyzeField(providers, (p) => p.allowedClients ?? []), + blockedClients: analyzeField(providers, (p) => p.blockedClients ?? []), + groupPriorities: analyzeField(providers, (p) => p.groupPriorities ?? {}), + cacheTtlPreference: analyzeField(providers, (p) => p.cacheTtlPreference ?? "inherit"), + swapCacheTtlBilling: analyzeField(providers, (p) => p.swapCacheTtlBilling ?? false), + context1mPreference: analyzeField( + providers, + (p) => (p.context1mPreference as Context1mPreference) ?? "inherit" + ), + codexReasoningEffortPreference: analyzeField( + providers, + (p) => p.codexReasoningEffortPreference ?? "inherit" + ), + codexReasoningSummaryPreference: analyzeField( + providers, + (p) => p.codexReasoningSummaryPreference ?? "inherit" + ), + codexTextVerbosityPreference: analyzeField( + providers, + (p) => p.codexTextVerbosityPreference ?? "inherit" + ), + codexParallelToolCallsPreference: analyzeField( + providers, + (p) => p.codexParallelToolCallsPreference ?? "inherit" + ), + codexServiceTierPreference: analyzeField( + providers, + (p) => p.codexServiceTierPreference ?? "inherit" + ), + anthropicMaxTokensPreference: analyzeField( + providers, + (p) => p.anthropicMaxTokensPreference ?? "inherit" + ), + anthropicThinkingBudgetPreference: analyzeField( + providers, + (p) => p.anthropicThinkingBudgetPreference ?? "inherit" + ), + anthropicAdaptiveThinking: analyzeField( + providers, + (p) => p.anthropicAdaptiveThinking ?? null + ), + geminiGoogleSearchPreference: analyzeField( + providers, + (p) => p.geminiGoogleSearchPreference ?? "inherit" + ), + activeTimeStart: analyzeField(providers, (p) => p.activeTimeStart ?? null), + activeTimeEnd: analyzeField(providers, (p) => p.activeTimeEnd ?? null), + }, + rateLimit: { + limit5hUsd: analyzeField(providers, (p) => p.limit5hUsd ?? null), + limitDailyUsd: analyzeField(providers, (p) => p.limitDailyUsd ?? null), + dailyResetMode: analyzeField(providers, (p) => p.dailyResetMode ?? "fixed"), + dailyResetTime: analyzeField(providers, (p) => p.dailyResetTime ?? "00:00"), + limitWeeklyUsd: analyzeField(providers, (p) => p.limitWeeklyUsd ?? null), + limitMonthlyUsd: analyzeField(providers, (p) => p.limitMonthlyUsd ?? null), + limitTotalUsd: analyzeField(providers, (p) => p.limitTotalUsd ?? null), + limitConcurrentSessions: analyzeField(providers, (p) => p.limitConcurrentSessions ?? null), + }, + circuitBreaker: { + failureThreshold: analyzeField(providers, (p) => p.circuitBreakerFailureThreshold), + openDurationMinutes: analyzeField(providers, (p) => + p.circuitBreakerOpenDuration != null ? p.circuitBreakerOpenDuration / 60000 : undefined + ), + halfOpenSuccessThreshold: analyzeField( + providers, + (p) => p.circuitBreakerHalfOpenSuccessThreshold + ), + maxRetryAttempts: analyzeField(providers, (p) => p.maxRetryAttempts ?? null), + }, + network: { + proxyUrl: analyzeField(providers, (p) => p.proxyUrl ?? ""), + proxyFallbackToDirect: analyzeField(providers, (p) => p.proxyFallbackToDirect ?? false), + firstByteTimeoutStreamingSeconds: analyzeField(providers, (p) => { + const ms = p.firstByteTimeoutStreamingMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + }), + streamingIdleTimeoutSeconds: analyzeField(providers, (p) => { + const ms = p.streamingIdleTimeoutMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + }), + requestTimeoutNonStreamingSeconds: analyzeField(providers, (p) => { + const ms = p.requestTimeoutNonStreamingMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + }), + }, + mcp: { + mcpPassthroughType: analyzeField(providers, (p) => p.mcpPassthroughType ?? "none"), + mcpPassthroughUrl: analyzeField(providers, (p) => p.mcpPassthroughUrl ?? ""), + }, + }; +} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/deep-equals.ts b/src/app/[locale]/settings/providers/_components/batch-edit/deep-equals.ts new file mode 100644 index 000000000..733110092 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/deep-equals.ts @@ -0,0 +1,37 @@ +/** + * 深度比较两个值是否相等(处理对象、数组、基本类型) + */ +export function deepEquals(a: unknown, b: unknown): boolean { + // 1. Object.is 处理基本类型和特殊值(NaN, +0/-0) + if (Object.is(a, b)) return true; + + // 2. null/undefined 处理 + if (a == null || b == null) return false; + + // 3. 类型不同 + if (typeof a !== typeof b) return false; + + // 4. 数组比较 + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => deepEquals(item, b[i])); + } + + // 5. 数组和对象的类型区分 + if (Array.isArray(a) !== Array.isArray(b)) return false; + + // 6. 对象比较(使用稳定序列化) + if (typeof a === "object" && typeof b === "object") { + const keysA = Object.keys(a).sort(); + const keysB = Object.keys(b).sort(); + + if (keysA.length !== keysB.length) return false; + if (!keysA.every((k, i) => k === keysB[i])) return false; + + return keysA.every((key) => + deepEquals((a as Record)[key], (b as Record)[key]) + ); + } + + return false; +} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx new file mode 100644 index 000000000..93c8187b2 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx @@ -0,0 +1,45 @@ +import { Info } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +interface MixedValueIndicatorProps { + values?: unknown[]; // 可选:显示所有不同的值 +} + +function formatValueForDisplay(value: unknown): string { + if (value == null) return "null"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function MixedValueIndicator({ values }: MixedValueIndicatorProps) { + const t = useTranslations("settings.providers.batchEdit"); + + return ( + + + + + + {t("mixedValues.label")} + + + {values && values.length > 0 && ( + +
+

{t("mixedValues.tooltip")}

+
    + {values.slice(0, 5).map((v, i) => ( +
  • {formatValueForDisplay(v)}
  • + ))} + {values.length > 5 && ( +
  • {t("mixedValues.andMore", { count: values.length - 5 })}
  • + )} +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx index 6920c1888..ef4946acf 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx @@ -43,6 +43,7 @@ import { import { BasicInfoSection } from "../forms/provider-form/sections/basic-info-section"; import { LimitsSection } from "../forms/provider-form/sections/limits-section"; import { NetworkSection } from "../forms/provider-form/sections/network-section"; +import { OptionsSection } from "../forms/provider-form/sections/options-section"; import { RoutingSection } from "../forms/provider-form/sections/routing-section"; import { TestingSection } from "../forms/provider-form/sections/testing-section"; import { buildPatchDraftFromFormState } from "./build-patch-draft"; @@ -293,6 +294,7 @@ function BatchEditDialogContent({
{state.ui.activeTab === "basic" && } {state.ui.activeTab === "routing" && } + {state.ui.activeTab === "options" && } {state.ui.activeTab === "limits" && } {state.ui.activeTab === "network" && } {state.ui.activeTab === "testing" && } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx index 6f4a51f23..1d3206328 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx @@ -1,22 +1,83 @@ "use client"; import { motion } from "framer-motion"; -import { FileText, FlaskConical, Gauge, Network, Route } from "lucide-react"; +import { + Clock, + FileText, + FlaskConical, + Gauge, + Network, + Route, + Scale, + Settings, + Shield, + Timer, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; -import type { TabId } from "../provider-form-types"; +import type { NavTargetId, SubTabId, TabId } from "../provider-form-types"; -const TAB_CONFIG: { id: TabId; icon: typeof FileText; labelKey: string }[] = [ +type NavItemConfig = { + id: TabId; + icon: typeof FileText; + labelKey: string; + children?: { + id: SubTabId; + icon: typeof FileText; + labelKey: string; + }[]; +}; + +const NAV_CONFIG: NavItemConfig[] = [ { id: "basic", icon: FileText, labelKey: "tabs.basic" }, - { id: "routing", icon: Route, labelKey: "tabs.routing" }, - { id: "limits", icon: Gauge, labelKey: "tabs.limits" }, - { id: "network", icon: Network, labelKey: "tabs.network" }, + { + id: "routing", + icon: Route, + labelKey: "tabs.routing", + children: [{ id: "scheduling", icon: Scale, labelKey: "tabs.scheduling" }], + }, + { + id: "options", + icon: Settings, + labelKey: "tabs.options", + children: [{ id: "activeTime", icon: Clock, labelKey: "tabs.activeTime" }], + }, + { + id: "limits", + icon: Gauge, + labelKey: "tabs.limits", + children: [{ id: "circuitBreaker", icon: Shield, labelKey: "tabs.circuitBreaker" }], + }, + { + id: "network", + icon: Network, + labelKey: "tabs.network", + children: [{ id: "timeout", icon: Timer, labelKey: "tabs.timeout" }], + }, { id: "testing", icon: FlaskConical, labelKey: "tabs.testing" }, ]; +const TAB_CONFIG: { id: TabId; icon: typeof FileText; labelKey: string }[] = NAV_CONFIG.map( + ({ id, icon, labelKey }) => ({ id, icon, labelKey }) +); + +export const TAB_ORDER: TabId[] = NAV_CONFIG.map((item) => item.id); + +export const NAV_ORDER: NavTargetId[] = NAV_CONFIG.flatMap((item) => [ + item.id, + ...(item.children?.map((c) => c.id) ?? []), +]); + +export const PARENT_MAP = Object.fromEntries( + NAV_CONFIG.flatMap((item) => (item.children ?? []).map((child) => [child.id, item.id])) +) as Record; + interface FormTabNavProps { activeTab: TabId; + activeSubTab?: SubTabId | null; + excludeTabs?: TabId[]; onTabChange: (tab: TabId) => void; + onSubTabChange?: (subTab: SubTabId) => void; disabled?: boolean; tabStatus?: Partial>; layout?: "vertical" | "horizontal"; @@ -24,13 +85,23 @@ interface FormTabNavProps { export function FormTabNav({ activeTab, + activeSubTab = null, onTabChange, + onSubTabChange, disabled, tabStatus = {}, layout = "vertical", + excludeTabs, }: FormTabNavProps) { const t = useTranslations("settings.providers.form"); + const filteredTabs = excludeTabs?.length + ? TAB_CONFIG.filter((tab) => !excludeTabs.includes(tab.id)) + : TAB_CONFIG; + const filteredNav = excludeTabs?.length + ? NAV_CONFIG.filter((item) => !excludeTabs.includes(item.id)) + : NAV_CONFIG; + const getStatusColor = (status?: "default" | "warning" | "configured") => { switch (status) { case "warning": @@ -42,15 +113,15 @@ export function FormTabNav({ } }; - const activeTabIndex = TAB_CONFIG.findIndex((tab) => tab.id === activeTab); + const activeTabIndex = filteredTabs.findIndex((tab) => tab.id === activeTab); const stepNumber = activeTabIndex >= 0 ? activeTabIndex + 1 : 0; - const stepProgressWidth = `${(stepNumber / TAB_CONFIG.length) * 100}%`; + const stepProgressWidth = `${(stepNumber / filteredTabs.length) * 100}%`; if (layout === "horizontal") { return ( {/* Tablet: Horizontal Tabs */} -
{/* Network Section */} @@ -620,7 +708,13 @@ function ProviderFormContent({ sectionRefs.current.network = el; }} > - + { + sectionRefs.current.timeout = el; + }, + }} + />
{/* Testing Section */} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index 9ea0146b7..5fe69ff6f 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -11,6 +11,7 @@ import { useRef, } from "react"; import type { ProviderDisplay, ProviderType } from "@/types/provider"; +import { analyzeBatchProviderSettings } from "../../batch-edit/analyze-batch-settings"; import type { FormMode, ProviderFormAction, @@ -79,14 +80,205 @@ export function createInitialState( url?: string; websiteUrl?: string; providerType?: ProviderType; - } + }, + batchProviders?: ProviderDisplay[] ): ProviderFormState { const isEdit = mode === "edit"; const isBatch = mode === "batch"; const raw = isEdit ? provider : cloneProvider; const sourceProvider = raw ? structuredClone(raw) : undefined; - // Batch mode: all fields start at neutral defaults (no provider source) + // Batch mode: 使用分析结果预填充 + if (isBatch && batchProviders && batchProviders.length > 0) { + const analysis = analyzeBatchProviderSettings(batchProviders); + + return { + basic: { name: "", url: "", key: "", websiteUrl: "" }, + routing: { + providerType: "claude", // 批量编辑不支持修改 providerType + groupTag: + analysis.routing.groupTag.status === "uniform" ? analysis.routing.groupTag.value : [], + preserveClientIp: + analysis.routing.preserveClientIp.status === "uniform" + ? analysis.routing.preserveClientIp.value + : false, + modelRedirects: + analysis.routing.modelRedirects.status === "uniform" + ? analysis.routing.modelRedirects.value + : {}, + allowedModels: + analysis.routing.allowedModels.status === "uniform" + ? analysis.routing.allowedModels.value + : [], + allowedClients: + analysis.routing.allowedClients.status === "uniform" + ? analysis.routing.allowedClients.value + : [], + blockedClients: + analysis.routing.blockedClients.status === "uniform" + ? analysis.routing.blockedClients.value + : [], + priority: + analysis.routing.priority.status === "uniform" ? analysis.routing.priority.value : 0, + groupPriorities: + analysis.routing.groupPriorities.status === "uniform" + ? analysis.routing.groupPriorities.value + : {}, + weight: analysis.routing.weight.status === "uniform" ? analysis.routing.weight.value : 1, + costMultiplier: + analysis.routing.costMultiplier.status === "uniform" + ? analysis.routing.costMultiplier.value + : 1.0, + cacheTtlPreference: + analysis.routing.cacheTtlPreference.status === "uniform" + ? analysis.routing.cacheTtlPreference.value + : "inherit", + swapCacheTtlBilling: + analysis.routing.swapCacheTtlBilling.status === "uniform" + ? analysis.routing.swapCacheTtlBilling.value + : false, + context1mPreference: + analysis.routing.context1mPreference.status === "uniform" + ? analysis.routing.context1mPreference.value + : "inherit", + codexReasoningEffortPreference: + analysis.routing.codexReasoningEffortPreference.status === "uniform" + ? analysis.routing.codexReasoningEffortPreference.value + : "inherit", + codexReasoningSummaryPreference: + analysis.routing.codexReasoningSummaryPreference.status === "uniform" + ? analysis.routing.codexReasoningSummaryPreference.value + : "inherit", + codexTextVerbosityPreference: + analysis.routing.codexTextVerbosityPreference.status === "uniform" + ? analysis.routing.codexTextVerbosityPreference.value + : "inherit", + codexParallelToolCallsPreference: + analysis.routing.codexParallelToolCallsPreference.status === "uniform" + ? analysis.routing.codexParallelToolCallsPreference.value + : "inherit", + codexServiceTierPreference: + analysis.routing.codexServiceTierPreference.status === "uniform" + ? analysis.routing.codexServiceTierPreference.value + : "inherit", + anthropicMaxTokensPreference: + analysis.routing.anthropicMaxTokensPreference.status === "uniform" + ? analysis.routing.anthropicMaxTokensPreference.value + : "inherit", + anthropicThinkingBudgetPreference: + analysis.routing.anthropicThinkingBudgetPreference.status === "uniform" + ? analysis.routing.anthropicThinkingBudgetPreference.value + : "inherit", + anthropicAdaptiveThinking: + analysis.routing.anthropicAdaptiveThinking.status === "uniform" + ? analysis.routing.anthropicAdaptiveThinking.value + : null, + geminiGoogleSearchPreference: + analysis.routing.geminiGoogleSearchPreference.status === "uniform" + ? analysis.routing.geminiGoogleSearchPreference.value + : "inherit", + activeTimeStart: + analysis.routing.activeTimeStart.status === "uniform" + ? analysis.routing.activeTimeStart.value + : null, + activeTimeEnd: + analysis.routing.activeTimeEnd.status === "uniform" + ? analysis.routing.activeTimeEnd.value + : null, + }, + rateLimit: { + limit5hUsd: + analysis.rateLimit.limit5hUsd.status === "uniform" + ? analysis.rateLimit.limit5hUsd.value + : null, + limitDailyUsd: + analysis.rateLimit.limitDailyUsd.status === "uniform" + ? analysis.rateLimit.limitDailyUsd.value + : null, + dailyResetMode: + analysis.rateLimit.dailyResetMode.status === "uniform" + ? analysis.rateLimit.dailyResetMode.value + : "fixed", + dailyResetTime: + analysis.rateLimit.dailyResetTime.status === "uniform" + ? analysis.rateLimit.dailyResetTime.value + : "00:00", + limitWeeklyUsd: + analysis.rateLimit.limitWeeklyUsd.status === "uniform" + ? analysis.rateLimit.limitWeeklyUsd.value + : null, + limitMonthlyUsd: + analysis.rateLimit.limitMonthlyUsd.status === "uniform" + ? analysis.rateLimit.limitMonthlyUsd.value + : null, + limitTotalUsd: + analysis.rateLimit.limitTotalUsd.status === "uniform" + ? analysis.rateLimit.limitTotalUsd.value + : null, + limitConcurrentSessions: + analysis.rateLimit.limitConcurrentSessions.status === "uniform" + ? analysis.rateLimit.limitConcurrentSessions.value + : null, + }, + circuitBreaker: { + failureThreshold: + analysis.circuitBreaker.failureThreshold.status === "uniform" + ? analysis.circuitBreaker.failureThreshold.value + : undefined, + openDurationMinutes: + analysis.circuitBreaker.openDurationMinutes.status === "uniform" + ? analysis.circuitBreaker.openDurationMinutes.value + : undefined, + halfOpenSuccessThreshold: + analysis.circuitBreaker.halfOpenSuccessThreshold.status === "uniform" + ? analysis.circuitBreaker.halfOpenSuccessThreshold.value + : undefined, + maxRetryAttempts: + analysis.circuitBreaker.maxRetryAttempts.status === "uniform" + ? analysis.circuitBreaker.maxRetryAttempts.value + : null, + }, + network: { + proxyUrl: + analysis.network.proxyUrl.status === "uniform" ? analysis.network.proxyUrl.value : "", + proxyFallbackToDirect: + analysis.network.proxyFallbackToDirect.status === "uniform" + ? analysis.network.proxyFallbackToDirect.value + : false, + firstByteTimeoutStreamingSeconds: + analysis.network.firstByteTimeoutStreamingSeconds.status === "uniform" + ? analysis.network.firstByteTimeoutStreamingSeconds.value + : undefined, + streamingIdleTimeoutSeconds: + analysis.network.streamingIdleTimeoutSeconds.status === "uniform" + ? analysis.network.streamingIdleTimeoutSeconds.value + : undefined, + requestTimeoutNonStreamingSeconds: + analysis.network.requestTimeoutNonStreamingSeconds.status === "uniform" + ? analysis.network.requestTimeoutNonStreamingSeconds.value + : undefined, + }, + mcp: { + mcpPassthroughType: + analysis.mcp.mcpPassthroughType.status === "uniform" + ? analysis.mcp.mcpPassthroughType.value + : "none", + mcpPassthroughUrl: + analysis.mcp.mcpPassthroughUrl.status === "uniform" + ? analysis.mcp.mcpPassthroughUrl.value + : "", + }, + batch: { isEnabled: "no_change" }, + ui: { + activeTab: "basic", + activeSubTab: null, + isPending: false, + showFailureThresholdConfirm: false, + }, + }; + } + + // Batch mode fallback: all fields start at neutral defaults (no provider source) if (isBatch) { return { basic: { name: "", url: "", key: "", websiteUrl: "" }, @@ -147,6 +339,7 @@ export function createInitialState( batch: { isEnabled: "no_change" }, ui: { activeTab: "basic", + activeSubTab: null, isPending: false, showFailureThresholdConfirm: false, }, @@ -241,6 +434,7 @@ export function createInitialState( batch: { isEnabled: "no_change" }, ui: { activeTab: "basic", + activeSubTab: null, isPending: false, showFailureThresholdConfirm: false, }, @@ -476,7 +670,12 @@ export function providerFormReducer( // UI case "SET_ACTIVE_TAB": - return { ...state, ui: { ...state.ui, activeTab: action.payload } }; + return { ...state, ui: { ...state.ui, activeTab: action.payload, activeSubTab: null } }; + case "SET_ACTIVE_NAV": + return { + ...state, + ui: { ...state.ui, activeTab: action.payload.tab, activeSubTab: action.payload.subTab }, + }; case "SET_IS_PENDING": return { ...state, ui: { ...state.ui, isPending: action.payload } }; case "SET_SHOW_FAILURE_THRESHOLD_CONFIRM": @@ -534,12 +733,20 @@ export function ProviderFormProvider({ }) { const [state, rawDispatch] = useReducer( providerFormReducer, - createInitialState(mode, provider, cloneProvider, preset) + createInitialState(mode, provider, cloneProvider, preset, batchProviders) ); const dirtyFieldsRef = useRef(new Set()); const isBatch = mode === "batch"; + // Compute batch analysis once if in batch mode + const batchAnalysis = useMemo(() => { + if (isBatch && batchProviders && batchProviders.length > 0) { + return analyzeBatchProviderSettings(batchProviders); + } + return undefined; + }, [isBatch, batchProviders]); + // Wrap dispatch for batch mode to auto-track dirty fields const dispatch: Dispatch = useCallback( (action: ProviderFormAction) => { @@ -566,6 +773,7 @@ export function ProviderFormProvider({ groupSuggestions, batchProviders, dirtyFields: dirtyFieldsRef.current, + batchAnalysis, }), [ state, @@ -577,6 +785,7 @@ export function ProviderFormProvider({ hideWebsiteUrl, groupSuggestions, batchProviders, + batchAnalysis, ] ); diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 3c89b6bec..8d6ee3411 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -15,12 +15,19 @@ import type { ProviderDisplay, ProviderType, } from "@/types/provider"; +import type { BatchSettingsAnalysis } from "../../batch-edit/analyze-batch-settings"; // Form mode export type FormMode = "create" | "edit" | "batch"; // Tab identifiers -export type TabId = "basic" | "routing" | "limits" | "network" | "testing"; +export type TabId = "basic" | "routing" | "options" | "limits" | "network" | "testing"; + +// Sub-tab identifiers for sub-navigation within parent sections +export type SubTabId = "scheduling" | "activeTime" | "circuitBreaker" | "timeout"; + +// Combined navigation target (parent tab or sub-tab) +export type NavTargetId = TabId | SubTabId; // Tab configuration export interface TabConfig { @@ -106,6 +113,7 @@ export interface BatchState { export interface UIState { activeTab: TabId; + activeSubTab: SubTabId | null; isPending: boolean; showFailureThresholdConfirm: boolean; } @@ -186,6 +194,7 @@ export type ProviderFormAction = | { type: "SET_MCP_PASSTHROUGH_URL"; payload: string } // UI actions | { type: "SET_ACTIVE_TAB"; payload: TabId } + | { type: "SET_ACTIVE_NAV"; payload: { tab: TabId; subTab: SubTabId | null } } | { type: "SET_IS_PENDING"; payload: boolean } | { type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM"; payload: boolean } // Bulk actions @@ -225,4 +234,5 @@ export interface ProviderFormContextValue { groupSuggestions: string[]; batchProviders?: ProviderDisplay[]; dirtyFields: Set; + batchAnalysis?: BatchSettingsAnalysis; } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx index 3b9f8a470..ff19392d8 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/select"; import { PROVIDER_DEFAULTS } from "@/lib/constants/provider.constants"; import { cn } from "@/lib/utils"; +import { MixedValueIndicator } from "../../../batch-edit/mixed-value-indicator"; import { FieldGroup, SectionCard, SmartInputWrapper } from "../components/section-card"; import { useProviderForm } from "../provider-form-context"; @@ -54,6 +55,7 @@ interface LimitCardProps { step?: string; min?: string; isDecimal?: boolean; + mixedValues?: (number | null)[]; } function LimitCard({ @@ -69,6 +71,7 @@ function LimitCard({ step = "0.01", min = "0", isDecimal = true, + mixedValues, }: LimitCardProps) { return (
+ {mixedValues && mixedValues.length > 0 && }
{value !== null && ( @@ -123,10 +127,17 @@ function LimitCard({ ); } -export function LimitsSection() { +interface LimitsSectionProps { + subSectionRefs?: { + circuitBreaker?: (el: HTMLDivElement | null) => void; + }; +} + +export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { const t = useTranslations("settings.providers.form"); - const { state, dispatch, mode } = useProviderForm(); + const { state, dispatch, mode, batchAnalysis } = useProviderForm(); const isEdit = mode === "edit"; + const isBatch = mode === "batch"; return ( dispatch({ type: "SET_LIMIT_5H_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limit5hUsd.status === "mixed" + ? batchAnalysis.rateLimit.limit5hUsd.values + : undefined + } /> dispatch({ type: "SET_LIMIT_DAILY_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitDailyUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitDailyUsd.values + : undefined + } /> dispatch({ type: "SET_LIMIT_WEEKLY_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitWeeklyUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitWeeklyUsd.values + : undefined + } /> dispatch({ type: "SET_LIMIT_MONTHLY_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitMonthlyUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitMonthlyUsd.values + : undefined + } />
@@ -257,6 +288,11 @@ export function LimitsSection() { placeholder={t("sections.rateLimit.limitTotal.placeholder")} onChange={(value) => dispatch({ type: "SET_LIMIT_TOTAL_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitTotalUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitTotalUsd.values + : undefined + } />
@@ -279,146 +320,180 @@ export function LimitsSection() { {/* Circuit Breaker Settings */} - -
- {/* Circuit Breaker Parameters */} -
- -
- { - const val = e.target.value; - dispatch({ - type: "SET_FAILURE_THRESHOLD", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")} - disabled={state.ui.isPending} - min="0" - step="1" - className={cn(state.circuitBreaker.failureThreshold === 0 && "border-yellow-500")} - /> - + +
+ {/* Circuit Breaker Parameters */} +
+ +
+
+ { + const val = e.target.value; + dispatch({ + type: "SET_FAILURE_THRESHOLD", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + className={cn( + state.circuitBreaker.failureThreshold === 0 && "border-yellow-500" + )} + /> + +
+ {state.circuitBreaker.failureThreshold === 0 && ( +

+ {t("sections.circuitBreaker.failureThreshold.warning")} +

)} - /> -
- {state.circuitBreaker.failureThreshold === 0 && ( -

- {t("sections.circuitBreaker.failureThreshold.warning")} -

- )} -
+ {isBatch && batchAnalysis?.circuitBreaker.failureThreshold.status === "mixed" && ( + + )} +
+ - -
- { - const val = e.target.value; - dispatch({ - type: "SET_OPEN_DURATION_MINUTES", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.openDuration.placeholder")} - disabled={state.ui.isPending} - min="1" - max="1440" - step="1" - className="pr-12" - /> - - min - -
-
+ +
+
+ { + const val = e.target.value; + dispatch({ + type: "SET_OPEN_DURATION_MINUTES", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.openDuration.placeholder")} + disabled={state.ui.isPending} + min="1" + max="1440" + step="1" + className="pr-12" + /> + + min + +
+ {isBatch && + batchAnalysis?.circuitBreaker.openDurationMinutes.status === "mixed" && ( + + )} +
+
- - { - const val = e.target.value; - dispatch({ - type: "SET_HALF_OPEN_SUCCESS_THRESHOLD", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.successThreshold.placeholder")} - disabled={state.ui.isPending} - min="1" - max="10" - step="1" - /> - + +
+ { + const val = e.target.value; + dispatch({ + type: "SET_HALF_OPEN_SUCCESS_THRESHOLD", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.successThreshold.placeholder")} + disabled={state.ui.isPending} + min="1" + max="10" + step="1" + /> + {isBatch && + batchAnalysis?.circuitBreaker.halfOpenSuccessThreshold.status === "mixed" && ( + + )} +
+
- -
- { - const val = e.target.value; - dispatch({ - type: "SET_MAX_RETRY_ATTEMPTS", - payload: val === "" ? null : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.maxRetryAttempts.placeholder")} - disabled={state.ui.isPending} - min="1" - max="10" - step="1" - /> - -
-
-
+ +
+
+ { + const val = e.target.value; + dispatch({ + type: "SET_MAX_RETRY_ATTEMPTS", + payload: val === "" ? null : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.maxRetryAttempts.placeholder")} + disabled={state.ui.isPending} + min="1" + max="10" + step="1" + /> + +
+ {isBatch && batchAnalysis?.circuitBreaker.maxRetryAttempts.status === "mixed" && ( + + )} +
+
+
- {/* Circuit Breaker Status Indicator */} -
- -
- {t("sections.circuitBreaker.summary", { - failureThreshold: state.circuitBreaker.failureThreshold ?? 5, - openDuration: state.circuitBreaker.openDurationMinutes ?? 30, - successThreshold: state.circuitBreaker.halfOpenSuccessThreshold ?? 2, - maxRetryAttempts: - state.circuitBreaker.maxRetryAttempts ?? PROVIDER_DEFAULTS.MAX_RETRY_ATTEMPTS, - })} + {/* Circuit Breaker Status Indicator */} +
+ +
+ {t("sections.circuitBreaker.summary", { + failureThreshold: state.circuitBreaker.failureThreshold ?? 5, + openDuration: state.circuitBreaker.openDurationMinutes ?? 30, + successThreshold: state.circuitBreaker.halfOpenSuccessThreshold ?? 2, + maxRetryAttempts: + state.circuitBreaker.maxRetryAttempts ?? PROVIDER_DEFAULTS.MAX_RETRY_ATTEMPTS, + })} +
-
- + +
); } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx index 021733be9..7347141f4 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx @@ -42,7 +42,6 @@ function TimeoutInput({ isCore, }: TimeoutInputProps) { const t = useTranslations("settings.providers.form"); - const _displayValue = value ?? defaultValue; const isCustom = value !== undefined; return ( @@ -77,7 +76,7 @@ function TimeoutInput({ { const val = e.target.value; onChange(val === "" ? undefined : parseInt(val, 10)); @@ -110,7 +109,13 @@ function TimeoutInput({ ); } -export function NetworkSection() { +interface NetworkSectionProps { + subSectionRefs?: { + timeout?: (el: HTMLDivElement | null) => void; + }; +} + +export function NetworkSection({ subSectionRefs }: NetworkSectionProps) { const t = useTranslations("settings.providers.form"); const { state, dispatch, mode } = useProviderForm(); const isEdit = mode === "edit"; @@ -198,88 +203,90 @@ export function NetworkSection() { {/* Timeout Configuration */} - -
- -
- - dispatch({ type: "SET_FIRST_BYTE_TIMEOUT_STREAMING", payload: value }) - } - disabled={state.ui.isPending} - min="0" - max="180" - icon={Clock} - isCore={true} - /> +
+ +
+ +
+ + dispatch({ type: "SET_FIRST_BYTE_TIMEOUT_STREAMING", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="180" + icon={Clock} + isCore={true} + /> - - dispatch({ type: "SET_STREAMING_IDLE_TIMEOUT", payload: value }) - } - disabled={state.ui.isPending} - min="0" - max="600" - icon={Timer} - isCore={true} - /> + + dispatch({ type: "SET_STREAMING_IDLE_TIMEOUT", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="600" + icon={Timer} + isCore={true} + /> - - dispatch({ type: "SET_REQUEST_TIMEOUT_NON_STREAMING", payload: value }) - } - disabled={state.ui.isPending} - min="0" - max="1200" - icon={Clock} - isCore={true} - /> -
-
+ + dispatch({ type: "SET_REQUEST_TIMEOUT_NON_STREAMING", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="1800" + icon={Clock} + isCore={true} + /> +
+ - {/* Timeout Summary */} -
- -
- {t("sections.timeout.summary", { - streaming: - state.network.firstByteTimeoutStreamingSeconds ?? - PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS / 1000, - idle: - state.network.streamingIdleTimeoutSeconds ?? - PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS / 1000, - nonStreaming: - state.network.requestTimeoutNonStreamingSeconds ?? - PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS / 1000, - })} + {/* Timeout Summary */} +
+ +
+ {t("sections.timeout.summary", { + streaming: + state.network.firstByteTimeoutStreamingSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS / 1000, + idle: + state.network.streamingIdleTimeoutSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS / 1000, + nonStreaming: + state.network.requestTimeoutNonStreamingSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS / 1000, + })} +
-
-

{t("sections.timeout.disableHint")}

-
-
+

{t("sections.timeout.disableHint")}

+
+ +
); } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section.tsx new file mode 100644 index 000000000..7f1678072 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Clock, Info, Settings, Timer } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { + CodexParallelToolCallsPreference, + CodexReasoningEffortPreference, + CodexReasoningSummaryPreference, + CodexServiceTierPreference, + CodexTextVerbosityPreference, + GeminiGoogleSearchPreference, +} from "@/types/provider"; +import { AdaptiveThinkingEditor } from "../../../adaptive-thinking-editor"; +import { ThinkingBudgetEditor } from "../../../thinking-budget-editor"; +import { SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; +import { useProviderForm } from "../provider-form-context"; + +interface OptionsSectionProps { + subSectionRefs?: { + activeTime?: (el: HTMLDivElement | null) => void; + }; +} + +export function OptionsSection({ subSectionRefs }: OptionsSectionProps) { + const t = useTranslations("settings.providers.form"); + const tBatch = useTranslations("settings.providers.batchEdit"); + const { state, dispatch, mode } = useProviderForm(); + const isEdit = mode === "edit"; + const isBatch = mode === "batch"; + const providerType = state.routing.providerType; + + return ( + + +
+ {/* Advanced Settings */} + +
+ + + dispatch({ type: "SET_PRESERVE_CLIENT_IP", payload: checked }) + } + disabled={state.ui.isPending} + /> + + + {/* Swap Cache TTL Billing */} + + + dispatch({ type: "SET_SWAP_CACHE_TTL_BILLING", payload: checked }) + } + disabled={state.ui.isPending} + /> + + + {/* Cache TTL */} + + + + + {/* 1M Context Window - Claude type only (or batch mode) */} + {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( + + + + )} +
+
+ + {/* Codex Overrides - Codex type only (or batch mode) */} + {(providerType === "codex" || isBatch) && ( + {tBatch("batchNotes.codexOnly")} + ) : undefined + } + > +
+ + + +
+ +
+
+ +

+ {t("sections.routing.codexOverrides.reasoningEffort.help")} +

+
+
+
+ + + + + + + + + + + + + + + + +
+ +
+
+ +

+ {t("sections.routing.codexOverrides.serviceTier.help")} +

+
+
+
+
+
+ )} + + {/* Anthropic Overrides - Claude type only (or batch mode) */} + {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( + {tBatch("batchNotes.claudeOnly")} + ) : undefined + } + > +
+ +
+ + {state.routing.anthropicMaxTokensPreference !== "inherit" && ( + { + const val = e.target.value; + if (val === "") { + dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: "inherit" }); + } else { + dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: val }); + } + }} + placeholder={t("sections.routing.anthropicOverrides.maxTokens.placeholder")} + disabled={state.ui.isPending} + min="1" + max="64000" + className="flex-1" + /> + )} +
+
+ + + + dispatch({ + type: "SET_ANTHROPIC_THINKING_BUDGET", + payload: val, + }) + } + disabled={state.ui.isPending} + /> + + + + dispatch({ type: "SET_ADAPTIVE_THINKING_ENABLED", payload: enabled }) + } + onConfigChange={(newConfig) => { + dispatch({ + type: "SET_ADAPTIVE_THINKING_EFFORT", + payload: newConfig.effort, + }); + dispatch({ + type: "SET_ADAPTIVE_THINKING_MODEL_MATCH_MODE", + payload: newConfig.modelMatchMode, + }); + dispatch({ + type: "SET_ADAPTIVE_THINKING_MODELS", + payload: newConfig.models, + }); + }} + disabled={state.ui.isPending} + /> +
+
+ )} + + {/* Gemini Overrides - Gemini type only (or batch mode) */} + {(providerType === "gemini" || providerType === "gemini-cli" || isBatch) && ( + {tBatch("batchNotes.geminiOnly")} + ) : undefined + } + > + + + + + )} + + {/* Scheduled Active Time */} +
+ +
+ + { + if (checked) { + dispatch({ type: "SET_ACTIVE_TIME_START", payload: "09:00" }); + dispatch({ type: "SET_ACTIVE_TIME_END", payload: "22:00" }); + } else { + dispatch({ type: "SET_ACTIVE_TIME_START", payload: null }); + dispatch({ type: "SET_ACTIVE_TIME_END", payload: null }); + } + }} + disabled={state.ui.isPending} + /> + + + {state.routing.activeTimeStart !== null && state.routing.activeTimeEnd !== null && ( +
+
+ + + dispatch({ type: "SET_ACTIVE_TIME_START", payload: e.target.value }) + } + disabled={state.ui.isPending} + /> + + + + dispatch({ type: "SET_ACTIVE_TIME_END", payload: e.target.value }) + } + disabled={state.ui.isPending} + /> + +
+

+ {t("sections.routing.activeTime.timezoneNote")} +

+ {state.routing.activeTimeStart > state.routing.activeTimeEnd && ( +

+ {t("sections.routing.activeTime.crossDayHint", { + start: state.routing.activeTimeStart, + end: state.routing.activeTimeEnd, + })} +

+ )} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index e222e2cd0..701daa77b 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -1,7 +1,7 @@ "use client"; import { motion } from "framer-motion"; -import { Clock, Info, Layers, Route, Scale, Settings, Timer } from "lucide-react"; +import { Info, Layers, Route, Scale } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -17,35 +17,36 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { TagInput } from "@/components/ui/tag-input"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getProviderTypeConfig } from "@/lib/provider-type-utils"; -import type { - CodexParallelToolCallsPreference, - CodexReasoningEffortPreference, - CodexReasoningSummaryPreference, - CodexServiceTierPreference, - CodexTextVerbosityPreference, - GeminiGoogleSearchPreference, - ProviderType, -} from "@/types/provider"; -import { AdaptiveThinkingEditor } from "../../../adaptive-thinking-editor"; +import type { ProviderType } from "@/types/provider"; +import { MixedValueIndicator } from "../../../batch-edit/mixed-value-indicator"; import { ModelMultiSelect } from "../../../model-multi-select"; import { ModelRedirectEditor } from "../../../model-redirect-editor"; -import { ThinkingBudgetEditor } from "../../../thinking-budget-editor"; import { FieldGroup, SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; import { useProviderForm } from "../provider-form-context"; const GROUP_TAG_MAX_TOTAL_LENGTH = 50; -export function RoutingSection() { +interface RoutingSectionProps { + subSectionRefs?: { + scheduling?: (el: HTMLDivElement | null) => void; + }; +} + +export function RoutingSection({ subSectionRefs }: RoutingSectionProps) { const t = useTranslations("settings.providers.form"); - const tBatch = useTranslations("settings.providers.batchEdit"); const tUI = useTranslations("ui.tagInput"); - const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions } = - useProviderForm(); + const { + state, + dispatch, + mode, + provider, + enableMultiProviderTypes, + groupSuggestions, + batchAnalysis, + } = useProviderForm(); const isEdit = mode === "edit"; const isBatch = mode === "batch"; - const { providerType } = state.routing; const renderProviderTypeLabel = (type: ProviderType) => { switch (type) { @@ -93,99 +94,98 @@ export function RoutingSection() { }; return ( - - - {/* Provider Type & Group - hidden in batch mode */} - {!isBatch && ( - -
- - - {!enableMultiProviderTypes && - state.routing.providerType === "openai-compatible" && ( -

- {t("sections.routing.providerTypeDisabledNote")} -

- )} -
- - - { - const messages: Record = { - empty: tUI("emptyTag"), - duplicate: tUI("duplicateTag"), - too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }), - invalid_format: tUI("invalidFormat"), - max_tags: tUI("maxTags"), - }; - toast.error(messages[reason] || reason); - }} - /> - -
-
- )} - - {/* Model Configuration */} + + {/* Provider Type & Group - hidden in batch mode */} + {!isBatch && (
- {/* Model Redirects */} - + + + {!enableMultiProviderTypes && state.routing.providerType === "openai-compatible" && ( +

+ {t("sections.routing.providerTypeDisabledNote")} +

+ )} +
+ + + { + const messages: Record = { + empty: tUI("emptyTag"), + duplicate: tUI("duplicateTag"), + too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }), + invalid_format: tUI("invalidFormat"), + max_tags: tUI("maxTags"), + }; + toast.error(messages[reason] || tUI("unknownError")); + }} + /> + +
+
+ )} + + {/* Model Configuration */} + +
+ {/* Model Redirects */} + +
) => @@ -193,131 +193,142 @@ export function RoutingSection() { } disabled={state.ui.isPending} /> - - - {/* Allowed Models */} - -
- - dispatch({ type: "SET_ALLOWED_MODELS", payload: value }) - } - disabled={state.ui.isPending} - providerUrl={state.basic.url} - apiKey={state.basic.key} - proxyUrl={state.network.proxyUrl} - proxyFallbackToDirect={state.network.proxyFallbackToDirect} - providerId={isEdit ? provider?.id : undefined} - /> - {state.routing.allowedModels.length > 0 && ( -
- {state.routing.allowedModels.slice(0, 5).map((model) => ( - - {model} - - ))} - {state.routing.allowedModels.length > 5 && ( - - {t("sections.routing.modelWhitelist.moreModels", { - count: state.routing.allowedModels.length - 5, - })} - - )} -
- )} -

- {state.routing.allowedModels.length === 0 ? ( - - {t("sections.routing.modelWhitelist.allowAll")} - - ) : ( - - {t("sections.routing.modelWhitelist.selectedOnly", { - count: state.routing.allowedModels.length, + {isBatch && batchAnalysis?.routing.modelRedirects.status === "mixed" && ( + + )} +

+
+ + {/* Allowed Models */} + +
+ + dispatch({ type: "SET_ALLOWED_MODELS", payload: value }) + } + disabled={state.ui.isPending} + providerUrl={state.basic.url} + apiKey={state.basic.key} + proxyUrl={state.network.proxyUrl} + proxyFallbackToDirect={state.network.proxyFallbackToDirect} + providerId={isEdit ? provider?.id : undefined} + /> + {state.routing.allowedModels.length > 0 && ( +
+ {state.routing.allowedModels.slice(0, 5).map((model) => ( + + {model} + + ))} + {state.routing.allowedModels.length > 5 && ( + + {t("sections.routing.modelWhitelist.moreModels", { + count: state.routing.allowedModels.length - 5, })} - + )} +
+ )} +

+ {state.routing.allowedModels.length === 0 ? ( + + {t("sections.routing.modelWhitelist.allowAll")} + + ) : ( + + {t("sections.routing.modelWhitelist.selectedOnly", { + count: state.routing.allowedModels.length, + })} + + )} +

+
+
+ + + + + + {clientRestrictionsEnabled && ( +
+
+

+ {t("sections.routing.clientRestrictions.priorityNote")} +

+

+ {t("sections.routing.clientRestrictions.customHelp")}

- - - dispatch({ type: "SET_ALLOWED_CLIENTS", payload: next })} + onBlockedChange={(next) => dispatch({ type: "SET_BLOCKED_CLIENTS", payload: next })} disabled={state.ui.isPending} - /> - - - {clientRestrictionsEnabled && ( -
-
-

- {t("sections.routing.clientRestrictions.priorityNote")} -

-

- {t("sections.routing.clientRestrictions.customHelp")} -

-
- - - dispatch({ type: "SET_ALLOWED_CLIENTS", payload: next }) - } - onBlockedChange={(next) => - dispatch({ type: "SET_BLOCKED_CLIENTS", payload: next }) - } - disabled={state.ui.isPending} - translations={{ - allowAction: t("sections.routing.clientRestrictions.allowAction"), - blockAction: t("sections.routing.clientRestrictions.blockAction"), - customAllowedLabel: t("sections.routing.clientRestrictions.customAllowedLabel"), - customAllowedPlaceholder: t( - "sections.routing.clientRestrictions.customAllowedPlaceholder" + translations={{ + allowAction: t("sections.routing.clientRestrictions.allowAction"), + blockAction: t("sections.routing.clientRestrictions.blockAction"), + customAllowedLabel: t("sections.routing.clientRestrictions.customAllowedLabel"), + customAllowedPlaceholder: t( + "sections.routing.clientRestrictions.customAllowedPlaceholder" + ), + customBlockedLabel: t("sections.routing.clientRestrictions.customBlockedLabel"), + customBlockedPlaceholder: t( + "sections.routing.clientRestrictions.customBlockedPlaceholder" + ), + customHelp: t("sections.routing.clientRestrictions.customHelp"), + presetClients: { + "claude-code": t( + "sections.routing.clientRestrictions.presetClients.claude-code" ), - customBlockedLabel: t("sections.routing.clientRestrictions.customBlockedLabel"), - customBlockedPlaceholder: t( - "sections.routing.clientRestrictions.customBlockedPlaceholder" + "gemini-cli": t("sections.routing.clientRestrictions.presetClients.gemini-cli"), + "factory-cli": t( + "sections.routing.clientRestrictions.presetClients.factory-cli" ), - customHelp: t("sections.routing.clientRestrictions.customHelp"), - presetClients: { - "claude-code": t( - "sections.routing.clientRestrictions.presetClients.claude-code" - ), - "gemini-cli": t( - "sections.routing.clientRestrictions.presetClients.gemini-cli" - ), - "factory-cli": t( - "sections.routing.clientRestrictions.presetClients.factory-cli" - ), - "codex-cli": t("sections.routing.clientRestrictions.presetClients.codex-cli"), - }, - }} - onInvalidTag={(_tag, reason) => { - const messages: Record = { - empty: tUI("emptyTag"), - duplicate: tUI("duplicateTag"), - too_long: tUI("tooLong", { max: 64 }), - invalid_format: tUI("invalidFormat"), - max_tags: tUI("maxTags"), - }; - toast.error(messages[reason] || reason); - }} - /> -
- )} -
- + "codex-cli": t("sections.routing.clientRestrictions.presetClients.codex-cli"), + }, + subClients: { + all: t("sections.routing.clientRestrictions.subClients.all"), + cli: t("sections.routing.clientRestrictions.subClients.cli"), + vscode: t("sections.routing.clientRestrictions.subClients.vscode"), + "sdk-ts": t("sections.routing.clientRestrictions.subClients.sdk-ts"), + "sdk-py": t("sections.routing.clientRestrictions.subClients.sdk-py"), + "cli-sdk": t("sections.routing.clientRestrictions.subClients.cli-sdk"), + "gh-action": t("sections.routing.clientRestrictions.subClients.gh-action"), + }, + nSelected: t("sections.routing.clientRestrictions.nSelected", { + count: "{count}", + }), + }} + onInvalidTag={(_tag, reason) => { + const messages: Record = { + empty: tUI("emptyTag"), + duplicate: tUI("duplicateTag"), + too_long: tUI("tooLong", { max: 64 }), + invalid_format: tUI("invalidFormat"), + max_tags: tUI("maxTags"), + }; + toast.error(messages[reason] || tUI("unknownError")); + }} + /> +
+ )} +
+
- {/* Scheduling Parameters */} + {/* Scheduling Parameters */} +
- - dispatch({ type: "SET_PRIORITY", payload: parseInt(e.target.value, 10) || 0 }) - } - placeholder={t("sections.routing.scheduleParams.priority.placeholder")} - disabled={state.ui.isPending} - min="0" - step="1" - /> +
+ + dispatch({ type: "SET_PRIORITY", payload: parseInt(e.target.value, 10) || 0 }) + } + placeholder={t("sections.routing.scheduleParams.priority.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + /> + {isBatch && batchAnalysis?.routing.priority.status === "mixed" && ( + + )} +
- - dispatch({ type: "SET_WEIGHT", payload: parseInt(e.target.value, 10) || 1 }) - } - placeholder={t("sections.routing.scheduleParams.weight.placeholder")} - disabled={state.ui.isPending} - min="1" - step="1" - /> +
+ + dispatch({ type: "SET_WEIGHT", payload: parseInt(e.target.value, 10) || 1 }) + } + placeholder={t("sections.routing.scheduleParams.weight.placeholder")} + disabled={state.ui.isPending} + min="1" + step="1" + /> + {isBatch && batchAnalysis?.routing.weight.status === "mixed" && ( + + )} +
- { - const value = e.target.value; - if (value === "") { - dispatch({ type: "SET_COST_MULTIPLIER", payload: 1.0 }); - return; - } - const num = parseFloat(value); - dispatch({ type: "SET_COST_MULTIPLIER", payload: Number.isNaN(num) ? 1.0 : num }); - }} - onFocus={(e) => e.target.select()} - placeholder={t("sections.routing.scheduleParams.costMultiplier.placeholder")} - disabled={state.ui.isPending} - min="0" - step="0.0001" - /> +
+ { + const value = e.target.value; + if (value === "") { + dispatch({ type: "SET_COST_MULTIPLIER", payload: 1.0 }); + return; + } + const num = parseFloat(value); + dispatch({ + type: "SET_COST_MULTIPLIER", + payload: Number.isNaN(num) ? 1.0 : num, + }); + }} + onFocus={(e) => e.target.select()} + placeholder={t("sections.routing.scheduleParams.costMultiplier.placeholder")} + disabled={state.ui.isPending} + min="0" + step="0.0001" + /> + {isBatch && batchAnalysis?.routing.costMultiplier.status === "mixed" && ( + + )} +
@@ -426,510 +455,7 @@ export function RoutingSection() {
)}
- - {/* Advanced Settings */} - -
- - - dispatch({ type: "SET_PRESERVE_CLIENT_IP", payload: checked }) - } - disabled={state.ui.isPending} - /> - - - {/* Swap Cache TTL Billing */} - - - dispatch({ type: "SET_SWAP_CACHE_TTL_BILLING", payload: checked }) - } - disabled={state.ui.isPending} - /> - - - {/* Cache TTL */} - - - - - {/* 1M Context Window - Claude type only (or batch mode) */} - {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( - - - - )} -
-
- - {/* Codex Overrides - Codex type only (or batch mode) */} - {(providerType === "codex" || isBatch) && ( - {tBatch("batchNotes.codexOnly")} - ) : undefined - } - > -
- - - -
- -
-
- -

- {t("sections.routing.codexOverrides.reasoningEffort.help")} -

-
-
-
- - - - - - - - - - - - - - - - -
- -
-
- -

- {t("sections.routing.codexOverrides.serviceTier.help")} -

-
-
-
-
-
- )} - - {/* Anthropic Overrides - Claude type only (or batch mode) */} - {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( - {tBatch("batchNotes.claudeOnly")} - ) : undefined - } - > -
- -
- - {state.routing.anthropicMaxTokensPreference !== "inherit" && ( - { - const val = e.target.value; - if (val === "") { - dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: "inherit" }); - } else { - dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: val }); - } - }} - placeholder={t("sections.routing.anthropicOverrides.maxTokens.placeholder")} - disabled={state.ui.isPending} - min="1" - max="64000" - className="flex-1" - /> - )} -
-
- - - - dispatch({ - type: "SET_ANTHROPIC_THINKING_BUDGET", - payload: val, - }) - } - disabled={state.ui.isPending} - /> - - - - dispatch({ type: "SET_ADAPTIVE_THINKING_ENABLED", payload: enabled }) - } - onConfigChange={(newConfig) => { - dispatch({ - type: "SET_ADAPTIVE_THINKING_EFFORT", - payload: newConfig.effort, - }); - dispatch({ - type: "SET_ADAPTIVE_THINKING_MODEL_MATCH_MODE", - payload: newConfig.modelMatchMode, - }); - dispatch({ - type: "SET_ADAPTIVE_THINKING_MODELS", - payload: newConfig.models, - }); - }} - disabled={state.ui.isPending} - /> -
-
- )} - - {/* Gemini Overrides - Gemini type only (or batch mode) */} - {(providerType === "gemini" || providerType === "gemini-cli" || isBatch) && ( - {tBatch("batchNotes.geminiOnly")} - ) : undefined - } - > - - - - - )} - - {/* Scheduled Active Time */} - -
- - { - if (checked) { - dispatch({ type: "SET_ACTIVE_TIME_START", payload: "09:00" }); - dispatch({ type: "SET_ACTIVE_TIME_END", payload: "22:00" }); - } else { - dispatch({ type: "SET_ACTIVE_TIME_START", payload: null }); - dispatch({ type: "SET_ACTIVE_TIME_END", payload: null }); - } - }} - disabled={state.ui.isPending} - /> - - - {state.routing.activeTimeStart !== null && state.routing.activeTimeEnd !== null && ( -
-
- - - dispatch({ type: "SET_ACTIVE_TIME_START", payload: e.target.value }) - } - disabled={state.ui.isPending} - /> - - - - dispatch({ type: "SET_ACTIVE_TIME_END", payload: e.target.value }) - } - disabled={state.ui.isPending} - /> - -
-

- {t("sections.routing.activeTime.timezoneNote")} -

- {state.routing.activeTimeStart > state.routing.activeTimeEnd && ( -

- {t("sections.routing.activeTime.crossDayHint", { - start: state.routing.activeTimeStart, - end: state.routing.activeTimeEnd, - })} -

- )} -
- )} -
-
- - +
+ ); } diff --git a/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx b/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx index 8e3a81935..aeab6c213 100644 --- a/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx @@ -31,7 +31,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; +import { PROVIDER_LIMITS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import type { CurrencyCode } from "@/lib/utils/currency"; @@ -375,18 +375,18 @@ export function ProviderListItem({ 超时配置: {tTimeout("summary", { - streaming: - item.firstByteTimeoutStreamingMs === 0 - ? "∞" - : ((item.firstByteTimeoutStreamingMs || 30000) / 1000).toString(), - idle: - item.streamingIdleTimeoutMs === 0 - ? "∞" - : ((item.streamingIdleTimeoutMs || 10000) / 1000).toString(), - nonStreaming: - item.requestTimeoutNonStreamingMs === 0 - ? "∞" - : ((item.requestTimeoutNonStreamingMs || 600000) / 1000).toString(), + streaming: ( + (item.firstByteTimeoutStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS) / 1000 + ).toString(), + idle: ( + (item.streamingIdleTimeoutMs ?? + PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS) / 1000 + ).toString(), + nonStreaming: ( + (item.requestTimeoutNonStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS) / 1000 + ).toString(), })} diff --git a/src/app/[locale]/settings/providers/_components/provider-list.tsx b/src/app/[locale]/settings/providers/_components/provider-list.tsx index 2b209b39a..d3ebb5c44 100644 --- a/src/app/[locale]/settings/providers/_components/provider-list.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-list.tsx @@ -4,6 +4,7 @@ import { Globe } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo } from "react"; import { getProviderVendors } from "@/actions/provider-endpoints"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { CurrencyCode } from "@/lib/utils/currency"; import type { ProviderDisplay, ProviderStatisticsMap } from "@/types/provider"; import type { User } from "@/types/user"; @@ -81,30 +82,34 @@ export function ProviderList({ } return ( -
- {providers.map((provider) => ( - onSelectProvider(provider.id, checked) : undefined - } - allGroups={allGroups} - userGroups={userGroups} - isAdmin={isAdmin} - /> - ))} -
+ +
+ {providers.map((provider) => ( + onSelectProvider(provider.id, checked) : undefined + } + allGroups={allGroups} + userGroups={userGroups} + isAdmin={isAdmin} + /> + ))} +
+
); } diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 5681eb8de..8667c5e77 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -11,6 +11,7 @@ import { Key, MoreHorizontal, RotateCcw, + ShieldCheck, Trash, XCircle, } from "lucide-react"; @@ -55,7 +56,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; -import { PROVIDER_GROUP, PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + PROVIDER_GROUP, + PROVIDER_LIMITS, + PROVIDER_TIMEOUT_DEFAULTS, +} from "@/lib/constants/provider.constants"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; @@ -766,6 +772,18 @@ export function ProviderRichListItem({ ) : ( {provider.url} )} + {provider.proxyUrl && ( + + + + + + + + {tList("proxyEnabled")} + + + )} {/* 官网链接 */} {provider.websiteUrl && ( @@ -794,18 +812,18 @@ export function ProviderRichListItem({ )} {tTimeout("summary", { - streaming: - provider.firstByteTimeoutStreamingMs === 0 - ? "∞" - : ((provider.firstByteTimeoutStreamingMs || 30000) / 1000).toString(), - idle: - provider.streamingIdleTimeoutMs === 0 - ? "∞" - : ((provider.streamingIdleTimeoutMs || 10000) / 1000).toString(), - nonStreaming: - provider.requestTimeoutNonStreamingMs === 0 - ? "∞" - : ((provider.requestTimeoutNonStreamingMs || 600000) / 1000).toString(), + streaming: ( + (provider.firstByteTimeoutStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS) / 1000 + ).toString(), + idle: ( + (provider.streamingIdleTimeoutMs ?? + PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS) / 1000 + ).toString(), + nonStreaming: ( + (provider.requestTimeoutNonStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS) / 1000 + ).toString(), })} diff --git a/src/app/api/admin/log-cleanup/manual/route.ts b/src/app/api/admin/log-cleanup/manual/route.ts index cae7a752e..0a5d9f9fe 100644 --- a/src/app/api/admin/log-cleanup/manual/route.ts +++ b/src/app/api/admin/log-cleanup/manual/route.ts @@ -93,6 +93,8 @@ export async function POST(request: NextRequest) { totalDeleted: result.totalDeleted, batchCount: result.batchCount, durationMs: result.durationMs, + softDeletedPurged: result.softDeletedPurged, + vacuumPerformed: result.vacuumPerformed, error: result.error, }); } catch (error) { diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 8fdef012a..083601ab6 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -79,6 +79,7 @@ export async function GET(request: NextRequest) { const includeModelStatsParam = searchParams.get("includeModelStats"); const userTagsParam = searchParams.get("userTags"); const userGroupsParam = searchParams.get("userGroups"); + const includeUserModelStatsParam = searchParams.get("includeUserModelStats"); if (!VALID_PERIODS.includes(period)) { return NextResponse.json( @@ -135,6 +136,19 @@ export async function GET(request: NextRequest) { includeModelStatsParam === "true" || includeModelStatsParam === "yes"); + const includeUserModelStats = + scope === "user" && + (includeUserModelStatsParam === "1" || + includeUserModelStatsParam === "true" || + includeUserModelStatsParam === "yes"); + + if (includeUserModelStats && !isAdmin) { + return NextResponse.json( + { error: "INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED" }, + { status: 403 } + ); + } + const parseListParam = (param: string | null): string[] | undefined => { if (!param) return undefined; const items = param @@ -158,7 +172,12 @@ export async function GET(request: NextRequest) { systemSettings.currencyDisplay, scope, dateRange, - { providerType, userTags, userGroups, includeModelStats } + { + providerType, + userTags, + userGroups, + includeModelStats: includeModelStats || includeUserModelStats, + } ); // 格式化金额字段 @@ -227,11 +246,26 @@ export async function GET(request: NextRequest) { }) : undefined; + const userModelStatsFormatted = + scope === "user" && Array.isArray(typedEntry.modelStats) + ? typedEntry.modelStats.map((ms) => { + const stat = ms as { + totalCost: number; + model: string | null; + } & Record; + return { + ...stat, + totalCostFormatted: formatCurrency(stat.totalCost, systemSettings.currencyDisplay), + }; + }) + : undefined; + return { ...base, ...providerFields, ...cacheFields, ...(modelStatsFormatted !== undefined ? { modelStats: modelStatsFormatted } : {}), + ...(userModelStatsFormatted !== undefined ? { modelStats: userModelStatsFormatted } : {}), }; }); @@ -251,7 +285,10 @@ export async function GET(request: NextRequest) { return NextResponse.json(data, { headers: { - "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", + "Cache-Control": + includeUserModelStats || !systemSettings.allowGlobalUsageView + ? "private, no-store" + : "public, s-maxage=60, stale-while-revalidate=120", }, }); } catch (error) { diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index 744791aa9..e9e07324e 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -9,6 +9,7 @@ import { detectClientFormat, detectFormatByEndpoint } from "./proxy/format-mappe import { ProxyForwarder } from "./proxy/forwarder"; import { GuardPipelineBuilder } from "./proxy/guard-pipeline"; import { ProxyResponseHandler } from "./proxy/response-handler"; +import { normalizeResponseInput } from "./proxy/response-input-rectifier"; import { ProxyResponses } from "./proxy/responses"; import { ProxySession } from "./proxy/session"; @@ -49,6 +50,11 @@ export async function handleProxyRequest(c: Context): Promise { } } + // Response API input rectifier: normalize non-array input before guard pipeline + if (session.originalFormat === "response") { + await normalizeResponseInput(session); + } + // Build guard pipeline from session endpoint policy const pipeline = GuardPipelineBuilder.fromSession(session); diff --git a/src/app/v1/_lib/proxy/client-detector.ts b/src/app/v1/_lib/proxy/client-detector.ts index d1b5705c8..391dde467 100644 --- a/src/app/v1/_lib/proxy/client-detector.ts +++ b/src/app/v1/_lib/proxy/client-detector.ts @@ -33,6 +33,33 @@ export interface ClientRestrictionResult { const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, ""); +function globMatch(pattern: string, text: string): boolean { + const lp = pattern.toLowerCase(); + const lt = text.toLowerCase(); + let pi = 0; + let ti = 0; + let starPi = -1; + let starTi = -1; + while (ti < lt.length) { + if (pi < lp.length && lp[pi] === lt[ti]) { + pi++; + ti++; + } else if (pi < lp.length && lp[pi] === "*") { + starPi = pi; + starTi = ti; + pi++; + } else if (starPi >= 0) { + pi = starPi + 1; + starTi++; + ti = starTi; + } else { + return false; + } + } + while (pi < lp.length && lp[pi] === "*") pi++; + return pi === lp.length; +} + const ENTRYPOINT_MAP: Record = { cli: "claude-code-cli", "sdk-cli": "claude-code-cli-sdk", @@ -104,6 +131,10 @@ export function matchClientPattern(session: ProxySession, pattern: string): bool return false; } + if (pattern.includes("*")) { + return globMatch(pattern, ua); + } + const normalizedPattern = normalize(pattern); if (normalizedPattern === "") { return false; @@ -138,9 +169,13 @@ export function detectClientFull(session: ProxySession, pattern: string): Client } else { const ua = session.userAgent?.trim(); if (ua) { - const normalizedPattern = normalize(pattern); - if (normalizedPattern !== "") { - matched = normalize(ua).includes(normalizedPattern); + if (pattern.includes("*")) { + matched = globMatch(pattern, ua); + } else { + const normalizedPattern = normalize(pattern); + if (normalizedPattern !== "") { + matched = normalize(ua).includes(normalizedPattern); + } } } } @@ -192,6 +227,9 @@ export function isClientAllowedDetailed( const matches = (pattern: string): boolean => { if (!isBuiltinKeyword(pattern)) { if (!ua) return false; + if (pattern.includes("*")) { + return globMatch(pattern, ua); + } const normalizedPattern = normalize(pattern); return normalizedPattern !== "" && normalizedUa.includes(normalizedPattern); } diff --git a/src/app/v1/_lib/proxy/format-mapper.ts b/src/app/v1/_lib/proxy/format-mapper.ts index faf17cfeb..9d4839f5f 100644 --- a/src/app/v1/_lib/proxy/format-mapper.ts +++ b/src/app/v1/_lib/proxy/format-mapper.ts @@ -121,6 +121,7 @@ export function detectClientFormat(requestBody: Record): Client } // 3. 检测 Response API (Codex) 格式 + // 仅通过 input 数组识别;字符串/单对象简写由 response-input-rectifier 在端点确认后规范化 if (Array.isArray(requestBody.input)) { return "response"; } diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 56eb16489..f93cc0558 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -36,6 +36,7 @@ import { import { updateMessageRequestDetails } from "@/repository/message"; import type { CacheTtlPreference, CacheTtlResolved } from "@/types/cache"; import type { ProviderChainItem } from "@/types/message"; +import type { Provider } from "@/types/provider"; import type { ClaudeMetadataUserIdInjectionSpecialSetting } from "@/types/special-settings"; import { GeminiAuth } from "../gemini/auth"; @@ -84,11 +85,33 @@ const STANDARD_ENDPOINTS = [ const STRICT_STANDARD_ENDPOINTS = ["/v1/messages", "/v1/responses", "/v1/chat/completions"]; +const OUTBOUND_TRANSPORT_HEADER_BLACKLIST = ["content-length", "connection", "transfer-encoding"]; + const RETRY_LIMITS = PROVIDER_LIMITS.MAX_RETRY_ATTEMPTS; const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(防止无限循环) type CacheTtlOption = CacheTtlPreference | null | undefined; +type ProxySessionWithAttemptRuntime = ProxySession & { + clearResponseTimeout?: () => void; + responseController?: AbortController; +}; + +type StreamingHedgeAttempt = { + provider: Provider; + session: ProxySession; + endpointAudit: { endpointId: number | null; endpointUrl: string }; + responseController: AbortController | null; + clearResponseTimeout: (() => void) | null; + firstByteTimeoutMs: number; + sequence: number; + settled: boolean; + thresholdTriggered: boolean; + thresholdTimer: NodeJS.Timeout | null; + reader: ReadableStreamDefaultReader | null; + response: Response | null; +}; + // 非流式响应体检查的上限(字节):避免上游在 2xx 场景返回超大内容导致内存占用失控。 // 说明: // - 该检查仅用于“空响应/假 200”启发式判定,不用于业务逻辑解析; @@ -504,6 +527,12 @@ export class ProxyForwarder { throw new Error("代理上下文缺少供应商或鉴权信息"); } + if (ProxyForwarder.shouldUseStreamingHedge(session)) { + const hedgePromise = ProxyForwarder.sendStreamingWithHedge(session); + void hedgePromise.catch(() => undefined); + return await hedgePromise; + } + const env = getEnvConfig(); const envDefaultMaxAttempts = clampRetryAttempts(env.MAX_RETRY_ATTEMPTS_DEFAULT); @@ -1020,10 +1049,12 @@ export class ProxyForwarder { totalProvidersAttempted, }); + await ProxyForwarder.clearSessionProviderBinding(session); + // 记录到决策链(标记为客户端中断) session.addProviderToChain(currentProvider, { ...endpointAudit, - reason: "system_error", // 使用 system_error 作为客户端中断的原因 + reason: "client_abort", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, errorMessage: "Client aborted request", @@ -1741,6 +1772,7 @@ export class ProxyForwarder { } // ⭐ 不暴露供应商详情,仅返回简单错误 + await ProxyForwarder.clearSessionProviderBinding(session); throw new ProxyError("所有供应商暂时不可用,请稍后重试", 503); // Service Unavailable } @@ -2131,51 +2163,63 @@ export class ProxyForwarder { const hasBody = session.method !== "GET" && session.method !== "HEAD"; if (hasBody) { - const filteredMessage = filterPrivateParameters(session.request.message) as Record< - string, - unknown - >; + if (session.getEndpointPolicy().bypassForwarderPreprocessing && session.request.buffer) { + // Raw passthrough: preserve original request body bytes as-is + requestBody = session.request.buffer; + session.forwardedRequestBody = session.request.log; - // 将 metadata.user_id 注入放在私有参数过滤之后,避免受过滤逻辑影响。 - let messageToSend: Record = filteredMessage; - if (provider.providerType === "claude" || provider.providerType === "claude-auth") { - const settings = await getCachedSystemSettings(); - const enabled = settings.enableClaudeMetadataUserIdInjection ?? true; - const injection = applyClaudeMetadataUserIdInjectionWithAudit( - filteredMessage, - session, - enabled - ); + try { + isStreaming = (session.request.message as Record).stream === true; + } catch { + isStreaming = false; + } + } else { + const filteredMessage = filterPrivateParameters(session.request.message) as Record< + string, + unknown + >; + + // 将 metadata.user_id 注入放在私有参数过滤之后,避免受过滤逻辑影响。 + let messageToSend: Record = filteredMessage; + if (provider.providerType === "claude" || provider.providerType === "claude-auth") { + const settings = await getCachedSystemSettings(); + const enabled = settings.enableClaudeMetadataUserIdInjection ?? true; + const injection = applyClaudeMetadataUserIdInjectionWithAudit( + filteredMessage, + session, + enabled + ); - if (injection) { - messageToSend = injection.message; - session.addSpecialSetting(injection.audit); - await persistSpecialSettings(session); + if (injection) { + messageToSend = injection.message; + session.addSpecialSetting(injection.audit); + await persistSpecialSettings(session); + } } - } - const bodyString = JSON.stringify(messageToSend); - requestBody = bodyString; - session.forwardedRequestBody = bodyString; + const bodyString = JSON.stringify(messageToSend); + requestBody = bodyString; + session.forwardedRequestBody = bodyString; - try { - const parsed = JSON.parse(bodyString); - isStreaming = parsed.stream === true; - } catch { - isStreaming = false; - } + try { + const parsed = JSON.parse(bodyString); + isStreaming = parsed.stream === true; + } catch { + isStreaming = false; + } - if (process.env.NODE_ENV === "development") { - logger.trace("ProxyForwarder: Forwarding request", { - provider: provider.name, - providerId: provider.id, - proxyUrl: proxyUrl, - format: session.originalFormat, - method: session.method, - bodyLength: bodyString.length, - bodyPreview: bodyString.slice(0, 1000), - isStreaming, - }); + if (process.env.NODE_ENV === "development") { + logger.trace("ProxyForwarder: Forwarding request", { + provider: provider.name, + providerId: provider.id, + proxyUrl: proxyUrl, + format: session.originalFormat, + method: session.method, + bodyLength: bodyString.length, + bodyPreview: bodyString.slice(0, 1000), + isStreaming, + }); + } } } } @@ -2811,6 +2855,703 @@ export class ProxyForwarder { return alternativeProvider; } + private static shouldUseStreamingHedge(session: ProxySession): boolean { + const endpointPolicy = session.getEndpointPolicy?.(); + return ( + (endpointPolicy?.allowRetry ?? true) && + (endpointPolicy?.allowProviderSwitch ?? true) && + (session.request.message as Record).stream === true && + (session.provider?.firstByteTimeoutStreamingMs ?? 0) > 0 + ); + } + + private static async sendStreamingWithHedge(session: ProxySession): Promise { + const initialProvider = session.provider; + if (!initialProvider) { + throw new Error("代理上下文缺少供应商"); + } + + const launchedProviderIds = new Set(); + let launchedProviderCount = 0; + let settled = false; + let winnerCommitted = false; + let noMoreProviders = false; + let launchingAlternative: Promise | null = null; + let lastError: Error | null = null; + const attempts = new Set(); + + let resolveResult: ((result: { response?: Response; error?: Error }) => void) | null = null; + const resultPromise = new Promise<{ response?: Response; error?: Error }>((resolve) => { + resolveResult = resolve; + }); + + const settleSuccess = (response: Response) => { + if (settled) return; + settled = true; + resolveResult?.({ response }); + }; + + const settleFailure = async (error: Error) => { + if (settled) return; + settled = true; + await ProxyForwarder.clearSessionProviderBinding(session); + resolveResult?.({ error }); + }; + + const abortAttempt = (attempt: StreamingHedgeAttempt, reason: string) => { + if (attempt.settled) return; + attempt.settled = true; + if (attempt.thresholdTimer) { + clearTimeout(attempt.thresholdTimer); + attempt.thresholdTimer = null; + } + attempts.delete(attempt); + if (reason === "hedge_loser") { + attempt.session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "hedge_loser_cancelled", + attemptNumber: attempt.sequence, + }); + } + try { + attempt.responseController?.abort(new Error(reason)); + } catch { + // ignore + } + const readerCancel = attempt.reader?.cancel(); + readerCancel?.catch(() => { + // ignore + }); + }; + + const abortAllAttempts = (winner?: StreamingHedgeAttempt, reason: string = "hedge_loser") => { + for (const attempt of Array.from(attempts)) { + if (winner && attempt === winner) continue; + abortAttempt(attempt, reason); + } + }; + + const finishIfExhausted = async () => { + if (!settled && noMoreProviders && attempts.size === 0) { + await settleFailure( + lastError ?? + new ProxyError("No available providers", 503, { + body: "", + providerId: initialProvider.id, + }) + ); + } + }; + + const launchAlternative = async () => { + if (settled || winnerCommitted || noMoreProviders) return; + if (launchingAlternative) { + await launchingAlternative; + return; + } + + launchingAlternative = (async () => { + const alternativeProvider = await ProxyForwarder.selectAlternative( + session, + Array.from(launchedProviderIds) + ); + if (!alternativeProvider) { + noMoreProviders = true; + // No alternative providers available — let in-flight attempt(s) continue. + // If all attempts already completed, settle with last error. + if (attempts.size === 0) { + await finishIfExhausted(); + } + return; + } + + await startAttempt(alternativeProvider, false); + })() + .catch(async (error) => { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + + logger.error("ProxyForwarder: Hedge failed to launch alternative provider", { + error: normalizedError, + sessionId: session.sessionId ?? null, + providerId: initialProvider.id, + providerName: initialProvider.name, + }); + + lastError = new ProxyError("No available providers", 503, { + body: "", + providerId: initialProvider.id, + providerName: initialProvider.name, + }); + noMoreProviders = true; + abortAllAttempts(undefined, "hedge_launch_failed"); + await finishIfExhausted(); + }) + .finally(() => { + launchingAlternative = null; + }); + + await launchingAlternative; + }; + + const handleAttemptFailure = async (attempt: StreamingHedgeAttempt, error: Error) => { + if (settled || winnerCommitted || attempt.settled) return; + + attempt.settled = true; + if (attempt.thresholdTimer) { + clearTimeout(attempt.thresholdTimer); + attempt.thresholdTimer = null; + } + attempts.delete(attempt); + lastError = error; + + const errorCategory = await categorizeErrorAsync(error); + const statusCode = error instanceof ProxyError ? error.statusCode : undefined; + + if (attempt.endpointAudit.endpointId != null) { + const isTimeoutError = error instanceof ProxyError && error.statusCode === 524; + if (isTimeoutError || errorCategory === ErrorCategory.SYSTEM_ERROR) { + await recordEndpointFailure(attempt.endpointAudit.endpointId, error); + } + } + + if (errorCategory === ErrorCategory.CLIENT_ABORT) { + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "client_abort", + attemptNumber: attempt.sequence, + errorMessage: "Client aborted request", + circuitState: getCircuitState(attempt.provider.id), + }); + abortAllAttempts(undefined, "client_abort"); + await settleFailure( + error instanceof ProxyError ? error : new ProxyError("Request aborted by client", 499) + ); + return; + } + + if (errorCategory === ErrorCategory.PROVIDER_ERROR && statusCode !== 404) { + await recordFailure(attempt.provider.id, error); + } + + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: + errorCategory === ErrorCategory.RESOURCE_NOT_FOUND + ? "resource_not_found" + : "retry_failed", + attemptNumber: attempt.sequence, + statusCode, + errorMessage: error instanceof ProxyError ? error.getDetailedErrorMessage() : error.message, + circuitState: getCircuitState(attempt.provider.id), + }); + + await launchAlternative(); + await finishIfExhausted(); + }; + + const commitWinner = async (attempt: StreamingHedgeAttempt, firstChunk: Uint8Array) => { + if (settled || winnerCommitted || attempt.settled || !attempt.response || !attempt.reader) + return; + + winnerCommitted = true; + + if (attempt.thresholdTimer) { + clearTimeout(attempt.thresholdTimer); + attempt.thresholdTimer = null; + } + + attempt.settled = true; + attempts.delete(attempt); + + if (attempt.session !== session) { + ProxyForwarder.syncWinningAttemptSession(session, attempt.session); + } + session.setProvider(attempt.provider); + + // Determine if this is truly a hedge winner or just a regular success + // Only mark as hedge_winner when an actual hedge race occurred + // Note: launchedProviderCount is the most reliable indicator - if > 1, multiple providers were launched + const isActualHedgeWin = launchedProviderCount > 1; + + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: isActualHedgeWin ? "hedge_winner" : "request_success", + attemptNumber: attempt.sequence, + statusCode: attempt.response.status, + }); + + abortAllAttempts(attempt, "hedge_loser"); + + if (session.sessionId) { + void (async () => { + const bindingResult = await SessionManager.updateSessionBindingSmart( + session.sessionId!, + attempt.provider.id, + attempt.provider.priority || 0, + launchedProviderCount === 1 && attempt.provider.id === initialProvider.id, + attempt.provider.id !== initialProvider.id + ); + + if (bindingResult.updated) { + logger.info("ProxyForwarder: Hedge winner binding updated", { + sessionId: session.sessionId, + providerId: attempt.provider.id, + providerName: attempt.provider.name, + reason: bindingResult.reason, + details: bindingResult.details, + }); + } + + await SessionManager.updateSessionProvider(session.sessionId!, { + providerId: attempt.provider.id, + providerName: attempt.provider.name, + }); + })().catch((bindingError) => { + logger.error("ProxyForwarder: Failed to update session provider info for hedge winner", { + error: bindingError, + }); + }); + } + + setDeferredStreamingFinalization(session, { + providerId: attempt.provider.id, + providerName: attempt.provider.name, + providerPriority: attempt.provider.priority || 0, + attemptNumber: attempt.sequence, + totalProvidersAttempted: launchedProviderCount, + isFirstAttempt: launchedProviderCount === 1 && attempt.provider.id === initialProvider.id, + isFailoverSuccess: attempt.provider.id !== initialProvider.id, + endpointId: attempt.endpointAudit.endpointId, + endpointUrl: attempt.endpointAudit.endpointUrl, + upstreamStatusCode: attempt.response.status, + isHedgeWinner: isActualHedgeWin, + }); + + const response = new Response( + ProxyForwarder.buildBufferedFirstChunkStream(firstChunk, attempt.reader), + { + status: attempt.response.status, + statusText: attempt.response.statusText, + headers: attempt.response.headers, + } + ); + + settleSuccess(response); + }; + + const startAttempt = async (provider: Provider, useOriginalSession: boolean) => { + if (settled || winnerCommitted || launchedProviderIds.has(provider.id)) return; + + launchedProviderIds.add(provider.id); + + let endpointSelection: { + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }; + try { + endpointSelection = await ProxyForwarder.resolveStreamingHedgeEndpoint(session, provider); + } catch (endpointError) { + lastError = endpointError as Error; + await launchAlternative(); + await finishIfExhausted(); + return; + } + + launchedProviderCount += 1; + + const attemptSession = useOriginalSession + ? session + : ProxyForwarder.createStreamingShadowSession(session, provider); + attemptSession.setProvider(provider); + + const attempt: StreamingHedgeAttempt = { + provider, + session: attemptSession, + endpointAudit: { + endpointId: endpointSelection.endpointId, + endpointUrl: endpointSelection.endpointUrl, + }, + responseController: null, + clearResponseTimeout: null, + firstByteTimeoutMs: + provider.firstByteTimeoutStreamingMs > 0 ? provider.firstByteTimeoutStreamingMs : 0, + sequence: launchedProviderCount, + settled: false, + thresholdTriggered: false, + thresholdTimer: null, + reader: null, + response: null, + }; + + attempts.add(attempt); + + // Record hedge participant launch in decision chain + // (first provider is already recorded via initial_selection or session_reuse) + if (launchedProviderCount > 1) { + session.addProviderToChain(provider, { + ...attempt.endpointAudit, + reason: "hedge_launched", + attemptNumber: attempt.sequence, + circuitState: getCircuitState(provider.id), + }); + } + + if (attempt.firstByteTimeoutMs > 0) { + attempt.thresholdTimer = setTimeout(() => { + if (settled || attempt.settled || attempt.thresholdTriggered) return; + attempt.thresholdTriggered = true; + attempt.session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "hedge_triggered", + attemptNumber: attempt.sequence, + circuitState: getCircuitState(attempt.provider.id), + }); + void launchAlternative(); + }, attempt.firstByteTimeoutMs); + } + + const providerForRequest = + provider.firstByteTimeoutStreamingMs > 0 + ? { ...provider, firstByteTimeoutStreamingMs: 0 } + : provider; + + void ProxyForwarder.doForward( + attemptSession, + providerForRequest, + endpointSelection.baseUrl, + attempt.endpointAudit, + 1 + ) + .then(async (response) => { + if (settled || winnerCommitted) { + const attemptRuntime = attemptSession as ProxySessionWithAttemptRuntime; + try { + attemptRuntime.responseController?.abort(new Error("hedge_loser")); + } catch { + // ignore + } + const cancelPromise = response.body?.cancel("hedge_loser"); + cancelPromise?.catch(() => { + // ignore + }); + return; + } + + const attemptRuntime = attemptSession as ProxySessionWithAttemptRuntime; + attempt.responseController = attemptRuntime.responseController ?? null; + attempt.clearResponseTimeout = attemptRuntime.clearResponseTimeout ?? null; + attempt.clearResponseTimeout?.(); + attempt.response = response; + + if (!response.body) { + await handleAttemptFailure( + attempt, + new EmptyResponseError(provider.id, provider.name, "empty_body") + ); + return; + } + + attempt.reader = response.body.getReader(); + + try { + const firstChunk = await ProxyForwarder.readFirstReadableChunk(attempt.reader); + if (firstChunk.done) { + await handleAttemptFailure( + attempt, + new EmptyResponseError(provider.id, provider.name, "empty_body") + ); + return; + } + + await commitWinner(attempt, firstChunk.value); + } catch (firstChunkError) { + const normalizedError = + firstChunkError instanceof Error + ? firstChunkError + : new Error(String(firstChunkError)); + if (settled || winnerCommitted) return; + await handleAttemptFailure(attempt, normalizedError); + } + }) + .catch(async (attemptError) => { + const normalizedError = + attemptError instanceof Error ? attemptError : new Error(String(attemptError)); + if (settled || winnerCommitted) return; + await handleAttemptFailure(attempt, normalizedError); + }); + }; + + if (session.clientAbortSignal) { + session.clientAbortSignal.addEventListener( + "abort", + () => { + if (settled || winnerCommitted) return; + noMoreProviders = true; + lastError = new ProxyError("Request aborted by client", 499); + for (const attempt of Array.from(attempts)) { + if (!attempt.settled) { + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "client_abort", + attemptNumber: attempt.sequence, + errorMessage: "Client aborted request", + }); + } + } + abortAllAttempts(undefined, "client_abort"); + void finishIfExhausted(); + }, + { once: true } + ); + } + + await startAttempt(initialProvider, true); + await finishIfExhausted(); + const result = await resultPromise; + if (result.error) { + throw result.error; + } + return result.response as Response; + } + + private static async resolveStreamingHedgeEndpoint( + session: ProxySession, + provider: Provider + ): Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }> { + const requestPath = session.requestUrl.pathname; + const providerVendorId = provider.providerVendorId ?? 0; + const isMcpRequest = + provider.providerType !== "gemini" && + provider.providerType !== "gemini-cli" && + !STANDARD_ENDPOINTS.includes(requestPath); + const shouldEnforceStrictEndpointPool = + !isMcpRequest && STRICT_STANDARD_ENDPOINTS.includes(requestPath) && providerVendorId > 0; + + if ( + !isMcpRequest && + provider.providerVendorId && + (await isVendorTypeCircuitOpen(provider.providerVendorId, provider.providerType)) + ) { + throw new ProxyError("Vendor-type circuit is open", 503, { + body: "", + providerId: provider.id, + providerName: provider.name, + }); + } + + const endpointCandidates: Array<{ endpointId: number | null; endpointUrl: string }> = []; + let endpointSelectionError: Error | null = null; + + if (isMcpRequest) { + const sanitizedUrl = sanitizeUrl(provider.url); + endpointCandidates.push({ endpointId: null, endpointUrl: sanitizedUrl }); + return { endpointId: null, baseUrl: provider.url, endpointUrl: sanitizedUrl }; + } + + if (providerVendorId > 0) { + try { + const preferred = await getPreferredProviderEndpoints({ + vendorId: providerVendorId, + providerType: provider.providerType, + }); + endpointCandidates.push( + ...preferred.map((endpoint) => ({ endpointId: endpoint.id, endpointUrl: endpoint.url })) + ); + } catch (error) { + endpointSelectionError = error instanceof Error ? error : new Error(String(error)); + } + } + + if (endpointCandidates.length === 0) { + if (shouldEnforceStrictEndpointPool) { + session.addProviderToChain(provider, { + reason: "endpoint_pool_exhausted", + attemptNumber: 1, + strictBlockCause: endpointSelectionError ? "selector_error" : "no_endpoint_candidates", + errorMessage: endpointSelectionError?.message, + }); + + if (endpointSelectionError) { + logger.warn("[ProxyForwarder] Failed to load provider endpoints (strict pool)", { + providerId: provider.id, + vendorId: providerVendorId, + providerType: provider.providerType, + error: endpointSelectionError.message, + }); + } + + throw new ProxyError("No available provider endpoints", 503, { + body: "", + providerId: provider.id, + providerName: provider.name, + }); + } + + const sanitizedUrl = sanitizeUrl(provider.url); + return { endpointId: null, baseUrl: provider.url, endpointUrl: sanitizedUrl }; + } + + return { + endpointId: endpointCandidates[0].endpointId, + baseUrl: endpointCandidates[0].endpointUrl, + endpointUrl: sanitizeUrl(endpointCandidates[0].endpointUrl), + }; + } + + private static createStreamingShadowSession( + session: ProxySession, + provider: Provider + ): ProxySession { + const shadow = Object.assign( + Object.create(Object.getPrototypeOf(session)) as ProxySession, + session + ); + const sourceState = session as unknown as { + originalHeaders: Headers; + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + providersSnapshot: Provider[] | null; + }; + const shadowState = shadow as unknown as { + request: ProxySession["request"]; + headers: Headers; + originalHeaders: Headers; + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + providersSnapshot: Provider[] | null; + }; + + shadowState.request = { + ...session.request, + message: structuredClone(session.request.message), + buffer: session.request.buffer ? session.request.buffer.slice(0) : undefined, + }; + shadow.requestUrl = new URL(session.requestUrl.toString()); + shadowState.headers = new Headers(session.headers); + shadowState.originalHeaders = new Headers(sourceState.originalHeaders); + shadowState.providerChain = [...sourceState.providerChain]; + shadowState.specialSettings = [...sourceState.specialSettings]; + shadowState.originalModelName = sourceState.originalModelName; + shadowState.originalUrlPathname = sourceState.originalUrlPathname; + shadowState.providersSnapshot = sourceState.providersSnapshot; + shadow.setCacheTtlResolved(session.getCacheTtlResolved()); + shadow.setContext1mApplied(session.getContext1mApplied()); + shadow.forwardedRequestBody = null; + shadow.sessionId = null; + shadow.messageContext = null; + shadow.setProvider(provider); + + return shadow; + } + + private static syncWinningAttemptSession(target: ProxySession, source: ProxySession): void { + target.request.message = source.request.message; + target.request.buffer = source.request.buffer; + target.request.log = source.request.log; + target.request.note = source.request.note; + target.request.model = source.request.model; + target.requestUrl = new URL(source.requestUrl.toString()); + target.forwardedRequestBody = source.forwardedRequestBody; + target.setCacheTtlResolved(source.getCacheTtlResolved()); + target.setContext1mApplied(source.getContext1mApplied()); + + const sourceState = source as unknown as { + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + }; + const targetState = target as unknown as { + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + clearResponseTimeout?: () => void; + responseController?: AbortController; + }; + const sourceRuntime = source as ProxySessionWithAttemptRuntime; + + const mergedProviderChain = [...targetState.providerChain]; + for (const item of sourceState.providerChain) { + const exists = mergedProviderChain.some( + (existing) => + existing.id === item.id && + existing.timestamp === item.timestamp && + existing.reason === item.reason && + existing.attemptNumber === item.attemptNumber + ); + if (!exists) { + mergedProviderChain.push(item); + } + } + targetState.providerChain = mergedProviderChain; + targetState.specialSettings = [...sourceState.specialSettings]; + targetState.originalModelName = sourceState.originalModelName; + targetState.originalUrlPathname = sourceState.originalUrlPathname; + targetState.clearResponseTimeout = sourceRuntime.clearResponseTimeout; + targetState.responseController = sourceRuntime.responseController; + } + + private static async clearSessionProviderBinding(session: ProxySession): Promise { + if (!session.sessionId) return; + await SessionManager.clearSessionProvider(session.sessionId); + } + + private static async readFirstReadableChunk( + reader: ReadableStreamDefaultReader + ): Promise> { + while (true) { + const result = await reader.read(); + if (result.done) { + return result; + } + // Skip zero-length chunks: some upstream providers (e.g. behind proxies/load-balancers) + // may emit empty chunks as keep-alive or framing artifacts. These carry no payload and + // must be silently skipped to avoid treating them as a valid "first byte" event. + if (result.value && result.value.byteLength > 0) { + return result; + } + } + } + + private static buildBufferedFirstChunkStream( + firstChunk: Uint8Array, + reader: ReadableStreamDefaultReader + ): ReadableStream { + let firstChunkSent = false; + + return new ReadableStream({ + async pull(controller) { + if (!firstChunkSent) { + firstChunkSent = true; + controller.enqueue(firstChunk); + return; + } + + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + if (value && value.byteLength > 0) { + controller.enqueue(value); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } catch { + // ignore + } + }, + }); + } + private static buildHeaders( session: ProxySession, provider: NonNullable @@ -2915,7 +3656,7 @@ export class ProxyForwarder { } const headerProcessor = HeaderProcessor.createForProxy({ - blacklist: ["content-length", "connection"], // 删除 content-length(动态计算)和 connection(undici 自动管理) + blacklist: OUTBOUND_TRANSPORT_HEADER_BLACKLIST, preserveClientIpHeaders: preserveClientIp, overrides, }); @@ -2960,7 +3701,11 @@ export class ProxyForwarder { } const headerProcessor = HeaderProcessor.createForProxy({ - blacklist: ["content-length", "connection", "x-api-key", GEMINI_PROTOCOL.HEADERS.API_KEY], + blacklist: [ + ...OUTBOUND_TRANSPORT_HEADER_BLACKLIST, + "x-api-key", + GEMINI_PROTOCOL.HEADERS.API_KEY, + ], preserveClientIpHeaders: preserveClientIp, overrides, }); diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 788d010b8..7a23eb214 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -98,72 +98,26 @@ function checkProviderGroupMatch(providerGroupTag: string | null, userGroups: st /** * 检查供应商是否支持指定模型(用于调度器匹配) * - * 核心逻辑: - * 1. Claude 模型请求 (claude-*): - * - Anthropic 提供商:根据 allowedModels 白名单判断 - * - 非 Anthropic 提供商:不支持 claude-* 模型调度 + * 核心逻辑(统一所有供应商类型): + * 1. 未设置 allowedModels(null 或空数组):接受任意模型(格式兼容性由 checkFormatProviderTypeCompatibility 保证) + * 2. 设置了 allowedModels:仅当原始请求模型命中 allowedModels 时才支持 + * 3. modelRedirects 仅在供应商已被选中后用于改写上游模型,不参与调度放行 * - * 2. 非 Claude 模型请求 (gpt-*, gemini-*, 或其他任意模型): - * - Anthropic 提供商:不支持(仅支持 Claude 模型) - * - 非 Anthropic 提供商(codex, gemini-cli, openai-compatible): - * a. 如果未设置 allowedModels(null 或空数组):接受任意模型 - * b. 如果设置了 allowedModels:检查模型是否在声明列表中,或有模型重定向配置 - * 注意:allowedModels 是声明性列表(用户可填写任意字符串),用于调度器匹配,不是真实模型校验 + * 注意:allowedModels 是声明性列表(用户可填写任意字符串),用于调度器匹配,不是真实模型校验。 + * 格式兼容性(如 claude 格式请求只路由到 claude 类型供应商)由 checkFormatProviderTypeCompatibility 独立保证。 * * @param provider - 供应商信息 * @param requestedModel - 用户请求的模型名称 * @returns 是否支持该模型(用于调度器筛选) */ function providerSupportsModel(provider: Provider, requestedModel: string): boolean { - const isClaudeModel = requestedModel.startsWith("claude-"); - const isClaudeProvider = - provider.providerType === "claude" || provider.providerType === "claude-auth"; - - // Case 1: Claude 模型请求 - if (isClaudeModel) { - // 1a. Anthropic 提供商 - if (isClaudeProvider) { - // 未设置 allowedModels 或为空数组:允许所有 claude 模型 - if (!provider.allowedModels || provider.allowedModels.length === 0) { - return true; - } - // 检查白名单 - return provider.allowedModels.includes(requestedModel); - } - - // 1b. 非 Anthropic 提供商不支持 Claude 模型调度 - return false; - } - - // Case 2: 非 Claude 模型请求(gpt-*, gemini-*, 或其他任意模型) - // 2a. 优先检查显式声明(支持跨类型代理) - // 原因:允许 Claude 类型供应商通过 allowedModels/modelRedirects 声明支持非 Claude 模型 - // 场景:Claude 供应商配置模型重定向,将 gemini-* 请求转发到真实的 Gemini 上游 - const explicitlyDeclared = !!( - provider.allowedModels?.includes(requestedModel) || provider.modelRedirects?.[requestedModel] - ); - - if (explicitlyDeclared) { - return true; // 显式声明优先级最高,允许跨类型代理 - } - - // 2b. Anthropic 提供商不支持非声明的非 Claude 模型 - // 保护机制:防止将非 Claude 模型误路由到 Anthropic API - if (isClaudeProvider) { - return false; - } - - // 2c. 非 Anthropic 提供商(codex, gemini, gemini-cli, openai-compatible) - // allowedModels 是声明列表,用于调度器匹配提供商 - // 用户可以手动填写任意模型名称(不限于真实模型),用于声明该提供商"支持"哪些模型 - - // 未设置 allowedModels 或为空数组:接受任意模型(由上游提供商判断) + // 1. 未设置 allowedModels(null 或空数组):接受任意模型 if (!provider.allowedModels || provider.allowedModels.length === 0) { return true; } - // 不在声明列表中且无重定向配置(前面已检查过 explicitlyDeclared) - return false; + // 2. 设置了 allowedModels:只按原始请求模型做白名单匹配 + return provider.allowedModels.includes(requestedModel); } /** @@ -578,7 +532,9 @@ export class ProxyProviderResolver { return null; } - // 检查模型支持(使用新的模型匹配逻辑) + // 检查模型支持 + // 注意:此处不检查格式兼容性(checkFormatProviderTypeCompatibility), + // 因为 session binding 仅由 pickRandomProvider 创建,该路径已保证格式兼容。 const requestedModel = session.getOriginalModel(); if (requestedModel && !providerSupportsModel(provider, requestedModel)) { logger.debug("ProviderSelector: Session provider does not support requested model", { @@ -1392,4 +1348,4 @@ export class ProxyProviderResolver { } // Export for testing -export { checkProviderGroupMatch }; +export { checkProviderGroupMatch, providerSupportsModel }; diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 36f54f06a..92c333723 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/src/app/v1/_lib/proxy/rate-limit-guard.ts @@ -64,7 +64,7 @@ export class ProxyRateLimitGuard { key.id, "key", key.limitTotalUsd ?? null, - { keyHash: key.key } + { keyHash: key.key, resetAt: user.costResetAt } ); if (!keyTotalCheck.allowed) { @@ -94,7 +94,8 @@ export class ProxyRateLimitGuard { const userTotalCheck = await RateLimitService.checkTotalCostLimit( user.id, "user", - user.limitTotalUsd ?? null + user.limitTotalUsd ?? null, + { resetAt: user.costResetAt } ); if (!userTotalCheck.allowed) { @@ -229,6 +230,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, // 仅检查 5h limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!key5hCheck.allowed) { @@ -265,6 +267,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!user5hCheck.allowed) { @@ -303,6 +306,7 @@ export class ProxyRateLimitGuard { daily_reset_time: key.dailyResetTime, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!keyDailyCheck.allowed) { @@ -376,6 +380,7 @@ export class ProxyRateLimitGuard { daily_reset_mode: user.dailyResetMode, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!userDailyCheck.allowed) { @@ -450,6 +455,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: key.limitWeeklyUsd, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!keyWeeklyCheck.allowed) { @@ -484,6 +490,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: user.limitWeeklyUsd ?? null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!userWeeklyCheck.allowed) { @@ -520,6 +527,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: key.limitMonthlyUsd, + cost_reset_at: user.costResetAt ?? null, }); if (!keyMonthlyCheck.allowed) { @@ -556,6 +564,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: user.limitMonthlyUsd ?? null, + cost_reset_at: user.costResetAt ?? null, }); if (!userMonthlyCheck.allowed) { diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 6ffc4aeea..07fd69549 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -6,6 +6,7 @@ import { requestCloudPriceTableSync } from "@/lib/price-sync/cloud-price-updater import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { RateLimitService } from "@/lib/rate-limit"; import type { LeaseWindowType } from "@/lib/rate-limit/lease"; +import { deleteLiveChain } from "@/lib/redis/live-chain-store"; import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; import type { CostBreakdown } from "@/lib/utils/cost-calculation"; @@ -275,6 +276,10 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( ): Promise { const meta = consumeDeferredStreamingFinalization(session); const provider = session.provider; + const clearSessionBinding = async () => { + if (!session.sessionId) return; + await SessionManager.clearSessionProvider(session.sessionId); + }; const providerIdForPersistence = meta?.providerId ?? provider?.id ?? null; @@ -322,6 +327,15 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( } } + const shouldClearSessionBindingOnFailure = + !streamEndedNormally || + detected.isError || + (upstreamStatusCode >= 400 && errorMessage !== null); + + if ((!meta || !provider) && shouldClearSessionBindingOnFailure) { + await clearSessionBinding(); + } + // 未启用延迟结算 / provider 缺失: // - 只返回“内部状态码 + 错误原因”,由调用方写入统计; // - 不在这里更新熔断/绑定(meta 缺失意味着 Forwarder 没有启用延迟结算;provider 缺失意味着无法归因)。 @@ -372,6 +386,8 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // - 客户端主动中断:不计入熔断器(这通常不是供应商问题) // - 非客户端中断:计入 provider/endpoint 熔断失败(与 timeout 路径保持一致) if (!streamEndedNormally) { + await clearSessionBinding(); + if (!clientAborted && session.getEndpointPolicy().allowCircuitBreakerAccounting) { try { // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 @@ -404,6 +420,8 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( } if (detected.isError) { + await clearSessionBinding(); + logger.warn("[ResponseHandler] SSE completed but body indicates error (fake 200)", { providerId: meta.providerId, providerName: meta.providerName, @@ -457,6 +475,8 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // ========== 非200状态码处理(流自然结束但HTTP状态码表示错误)========== if (upstreamStatusCode >= 400 && errorMessage !== null) { + await clearSessionBinding(); + logger.warn("[ResponseHandler] SSE completed but HTTP status indicates error", { providerId: meta.providerId, providerName: meta.providerName, @@ -523,57 +543,64 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( }); } - // 成功后绑定 session 到供应商(智能绑定策略) - if (session.sessionId) { - const result = await SessionManager.updateSessionBindingSmart( - session.sessionId, - meta.providerId, - meta.providerPriority, - meta.isFirstAttempt, - meta.isFailoverSuccess - ); + // Hedge winner: commitWinner() already performed session binding and chain logging. + // Skip duplicate operations to avoid double entries in the provider chain. + if (!meta.isHedgeWinner) { + // 成功后绑定 session 到供应商(智能绑定策略) + if (session.sessionId) { + const result = await SessionManager.updateSessionBindingSmart( + session.sessionId, + meta.providerId, + meta.providerPriority, + meta.isFirstAttempt, + meta.isFailoverSuccess + ); + + if (result.updated) { + logger.info("[ResponseHandler] Session binding updated (stream finalized)", { + sessionId: session.sessionId, + providerId: meta.providerId, + providerName: meta.providerName, + priority: meta.providerPriority, + reason: result.reason, + details: result.details, + attemptNumber: meta.attemptNumber, + totalProvidersAttempted: meta.totalProvidersAttempted, + }); + } else { + logger.debug("[ResponseHandler] Session binding not updated (stream finalized)", { + sessionId: session.sessionId, + providerId: meta.providerId, + providerName: meta.providerName, + priority: meta.providerPriority, + reason: result.reason, + details: result.details, + }); + } - if (result.updated) { - logger.info("[ResponseHandler] Session binding updated (stream finalized)", { - sessionId: session.sessionId, - providerId: meta.providerId, - providerName: meta.providerName, - priority: meta.providerPriority, - reason: result.reason, - details: result.details, - attemptNumber: meta.attemptNumber, - totalProvidersAttempted: meta.totalProvidersAttempted, - }); - } else { - logger.debug("[ResponseHandler] Session binding not updated (stream finalized)", { - sessionId: session.sessionId, + // 统一更新两个数据源(确保监控数据一致) + void SessionManager.updateSessionProvider(session.sessionId, { providerId: meta.providerId, providerName: meta.providerName, - priority: meta.providerPriority, - reason: result.reason, - details: result.details, + }).catch((err) => { + logger.error( + "[ResponseHandler] Failed to update session provider info (stream finalized)", + { + error: err, + } + ); }); } - // 统一更新两个数据源(确保监控数据一致) - void SessionManager.updateSessionProvider(session.sessionId, { - providerId: meta.providerId, - providerName: meta.providerName, - }).catch((err) => { - logger.error("[ResponseHandler] Failed to update session provider info (stream finalized)", { - error: err, - }); + session.addProviderToChain(providerForChain, { + endpointId: meta.endpointId, + endpointUrl: meta.endpointUrl, + reason: meta.isFirstAttempt ? "request_success" : "retry_success", + attemptNumber: meta.attemptNumber, + statusCode: meta.upstreamStatusCode, }); } - session.addProviderToChain(providerForChain, { - endpointId: meta.endpointId, - endpointUrl: meta.endpointUrl, - reason: meta.isFirstAttempt ? "request_success" : "retry_success", - attemptNumber: meta.attemptNumber, - statusCode: meta.upstreamStatusCode, - }); - logger.info("[ResponseHandler] Streaming request finalized as success", { providerId: meta.providerId, providerName: meta.providerName, @@ -784,11 +811,12 @@ export class ProxyResponseHandler { const processingPromise = (async () => { const finalizeNonStreamAbort = async (): Promise => { + const finalizedStatusCode = session.clientAbortSignal?.aborted ? 499 : statusCode; if (messageContext) { const duration = Date.now() - session.startTime; await updateMessageRequestDuration(messageContext.id, duration); await updateMessageRequestDetails(messageContext.id, { - statusCode: statusCode, + statusCode: finalizedStatusCode, ttfbMs: session.ttfbMs ?? duration, providerChain: session.getProviderChain(), model: session.getCurrentModel() ?? undefined, // 更新重定向后的模型 @@ -801,9 +829,11 @@ export class ProxyResponseHandler { } if (session.sessionId) { + await SessionManager.clearSessionProvider(session.sessionId); + const sessionUsagePayload: SessionUsageUpdate = { - status: statusCode >= 200 && statusCode < 300 ? "completed" : "error", - statusCode: statusCode, + status: finalizedStatusCode >= 200 && finalizedStatusCode < 300 ? "completed" : "error", + statusCode: finalizedStatusCode, }; void SessionManager.updateSessionUsage(session.sessionId, sessionUsagePayload).catch( @@ -1274,10 +1304,12 @@ export class ProxyResponseHandler { const pushChunk = (text: string, bytes: number) => { if (!text) return; - const pushToTail = () => { - tailChunks.push(text); - tailChunkBytes.push(bytes); - tailBufferedBytes += bytes; + const pushToTail = (tailText: string, tailBytes: number) => { + if (!tailText) return; + + tailChunks.push(tailText); + tailChunkBytes.push(tailBytes); + tailBufferedBytes += tailBytes; // 仅保留尾部窗口,避免内存无界增长 while (tailBufferedBytes > MAX_STATS_TAIL_BYTES && tailHead < tailChunkBytes.length) { @@ -1317,13 +1349,13 @@ export class ProxyResponseHandler { pushChunk(headPart, remainingHeadBytes); inTailMode = true; - pushChunk(tailPart, bytes - remainingHeadBytes); + pushToTail(tailPart, bytes - remainingHeadBytes); } else { headChunks.push(text); headBufferedBytes += bytes; } } else { - pushChunk(text, bytes); + pushToTail(text, bytes); } }; const decoder = new TextDecoder(); @@ -2528,9 +2560,7 @@ export function extractUsageMetrics(value: unknown): UsageMetrics | null { hasAny = true; } - // OpenAI Response API 格式:input_tokens_details.cached_tokens(嵌套结构) - // 仅在顶层字段不存在时使用(避免重复计算) - if (!result.cache_read_input_tokens) { + if (result.cache_read_input_tokens === undefined) { const inputTokensDetails = usage.input_tokens_details as Record | undefined; if (inputTokensDetails && typeof inputTokensDetails.cached_tokens === "number") { result.cache_read_input_tokens = inputTokensDetails.cached_tokens; @@ -2541,6 +2571,17 @@ export function extractUsageMetrics(value: unknown): UsageMetrics | null { } } + if (result.cache_read_input_tokens === undefined) { + const promptTokensDetails = usage.prompt_tokens_details as Record | undefined; + if (promptTokensDetails && typeof promptTokensDetails.cached_tokens === "number") { + result.cache_read_input_tokens = promptTokensDetails.cached_tokens; + hasAny = true; + logger.debug("[ResponseHandler] Parsed cached tokens from OpenAI Chat Completions format", { + cachedTokens: promptTokensDetails.cached_tokens, + }); + } + } + return hasAny ? result : null; } @@ -3104,6 +3145,10 @@ export async function finalizeRequestStats( specialSettings: session.getSpecialSettings() ?? undefined, }); + if (session.sessionId && session.requestSequence != null) { + void deleteLiveChain(session.sessionId, session.requestSequence); + } + return normalizedUsage; } @@ -3270,6 +3315,10 @@ async function persistRequestFailure(options: { specialSettings: session.getSpecialSettings() ?? undefined, }); + if (session.sessionId && session.requestSequence != null) { + void deleteLiveChain(session.sessionId, session.requestSequence); + } + const isAsyncWrite = getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "sync"; logger.info( isAsyncWrite diff --git a/src/app/v1/_lib/proxy/response-input-rectifier.ts b/src/app/v1/_lib/proxy/response-input-rectifier.ts new file mode 100644 index 000000000..a301fe442 --- /dev/null +++ b/src/app/v1/_lib/proxy/response-input-rectifier.ts @@ -0,0 +1,106 @@ +/** + * Response Input Rectifier + * + * OpenAI Responses API (/v1/responses) 的 input 字段支持多种格式: + * - 字符串简写: "hello" + * - 单对象: { role: "user", content: [...] } + * - 数组 (标准): [{ role: "user", content: [...] }] + * + * 下游代码 (format detection, converters) 要求 input 为数组。 + * 此整流器在 guard pipeline 之前将非数组 input 规范化为数组格式。 + */ + +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; +import { logger } from "@/lib/logger"; +import type { ProxySession } from "./session"; + +export type ResponseInputRectifierAction = + | "string_to_array" + | "object_to_array" + | "empty_string_to_empty_array" + | "passthrough"; + +export type ResponseInputRectifierResult = { + applied: boolean; + action: ResponseInputRectifierAction; + originalType: "string" | "object" | "array" | "other"; +}; + +/** + * 规范化 Response API 请求体的 input 字段。 + * 原地修改 message 对象(与现有整流器约定一致)。 + */ +export function rectifyResponseInput( + message: Record +): ResponseInputRectifierResult { + const input = message.input; + + // Case 1: 数组 -- passthrough + if (Array.isArray(input)) { + return { applied: false, action: "passthrough", originalType: "array" }; + } + + // Case 2: 字符串 + if (typeof input === "string") { + if (input === "") { + message.input = []; + return { applied: true, action: "empty_string_to_empty_array", originalType: "string" }; + } + + message.input = [ + { + role: "user", + content: [{ type: "input_text", text: input }], + }, + ]; + return { applied: true, action: "string_to_array", originalType: "string" }; + } + + // Case 3: 单对象 (MessageInput 有 role, ToolOutputsInput 有 type) + if (typeof input === "object" && input !== null) { + const obj = input as Record; + if ("role" in obj || "type" in obj) { + message.input = [input]; + return { applied: true, action: "object_to_array", originalType: "object" }; + } + } + + // Case 4: undefined/null/其他 -- passthrough,让下游处理错误 + return { + applied: false, + action: "passthrough", + originalType: "other", + }; +} + +/** + * 入口:检查系统设置,执行整流,记录审计。 + * 在 proxy-handler.ts 中格式检测确认 "response" 后调用。 + */ +export async function normalizeResponseInput(session: ProxySession): Promise { + const settings = await getCachedSystemSettings(); + const enabled = settings.enableResponseInputRectifier ?? true; + + if (!enabled) { + return; + } + + const message = session.request.message as Record; + const result = rectifyResponseInput(message); + + if (result.applied) { + session.addSpecialSetting({ + type: "response_input_rectifier", + scope: "request", + hit: true, + action: result.action, + originalType: result.originalType, + }); + + logger.info("[ResponseInputRectifier] Input normalized", { + action: result.action, + originalType: result.originalType, + sessionId: session.sessionId, + }); + } +} diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 67afc2ef5..659ffc7c4 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import { logger } from "@/lib/logger"; +import { writeLiveChain } from "@/lib/redis/live-chain-store"; import { clientRequestsContext1m as clientRequestsContext1mHelper } from "@/lib/special-attributes"; import { type ResolvedPricing, @@ -324,6 +325,7 @@ export class ProxySession { const value = Math.max(0, Date.now() - this.startTime); this.ttfbMs = value; + this.persistLiveChain(); return value; } @@ -453,7 +455,12 @@ export class ProxySession { | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(strict endpoint policy 阻止了 fallback) | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 - | "client_restriction_filtered"; // 供应商因客户端限制被跳过(会话复用路径) + | "client_restriction_filtered" // 供应商因客户端限制被跳过(会话复用路径) + | "hedge_triggered" // Hedge 计时器触发,启动备选供应商 + | "hedge_launched" // Hedge 备选供应商已启动(信息性记录) + | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) + | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消 + | "client_abort"; // 客户端在响应完成前断开连接 selectionMethod?: | "session_reuse" | "weighted_random" @@ -504,17 +511,26 @@ export class ProxySession { endpointFilterStats: metadata?.endpointFilterStats, }; - // 避免重复添加同一个供应商(除非是重试,即有 attemptNumber) + // 避免重复添加同一个供应商 + // 检查最后一条记录是否与当前记录完全相同(id + reason + attemptNumber) + const lastItem = this.providerChain[this.providerChain.length - 1]; const shouldAdd = this.providerChain.length === 0 || - this.providerChain[this.providerChain.length - 1].id !== provider.id || - metadata?.attemptNumber !== undefined; + lastItem.id !== provider.id || + lastItem.reason !== metadata?.reason || + (metadata?.attemptNumber !== undefined && lastItem.attemptNumber !== metadata.attemptNumber); if (shouldAdd) { this.providerChain.push(item); + this.persistLiveChain(); } } + private persistLiveChain(): void { + if (!this.sessionId || this.requestSequence == null) return; + void writeLiveChain(this.sessionId, this.requestSequence, this.providerChain); + } + /** * 获取决策链 */ diff --git a/src/app/v1/_lib/proxy/stream-finalization.ts b/src/app/v1/_lib/proxy/stream-finalization.ts index 9a915843e..3fd6b3da7 100644 --- a/src/app/v1/_lib/proxy/stream-finalization.ts +++ b/src/app/v1/_lib/proxy/stream-finalization.ts @@ -27,6 +27,8 @@ export type DeferredStreamingFinalization = { endpointId: number | null; endpointUrl: string; upstreamStatusCode: number; + /** When true, commitWinner() already performed session binding and chain logging; finalization should skip them. */ + isHedgeWinner?: boolean; }; const deferredMeta = new WeakMap(); diff --git a/src/components/analytics/model-breakdown-column.tsx b/src/components/analytics/model-breakdown-column.tsx new file mode 100644 index 000000000..93e8311af --- /dev/null +++ b/src/components/analytics/model-breakdown-column.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { + Activity, + ArrowDownRight, + ArrowUpRight, + Coins, + Database, + Hash, + Percent, + Target, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { formatTokenAmount } from "@/lib/utils"; +import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; + +export interface ModelBreakdownItem { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +export interface ModelBreakdownLabels { + unknownModel: string; + modal: { + requests: string; + cost: string; + inputTokens: string; + outputTokens: string; + cacheCreationTokens: string; + cacheReadTokens: string; + totalTokens: string; + costPercentage: string; + cacheHitRate: string; + cacheTokens: string; + performanceHigh: string; + performanceMedium: string; + performanceLow: string; + }; +} + +interface ModelBreakdownColumnProps { + pageItems: ModelBreakdownItem[]; + currencyCode: CurrencyCode; + totalCost: number; + keyPrefix: string; + pageOffset: number; + labels?: ModelBreakdownLabels; +} + +export function ModelBreakdownColumn({ + pageItems, + currencyCode, + totalCost, + keyPrefix, + pageOffset, + labels, +}: ModelBreakdownColumnProps) { + return ( +
+ {pageItems.map((item, index) => ( + + ))} +
+ ); +} + +interface ModelBreakdownRowProps { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + currencyCode: CurrencyCode; + totalCost: number; + labels?: ModelBreakdownLabels; +} + +function useLabels(labels?: ModelBreakdownLabels) { + const t = useTranslations("myUsage.stats"); + + if (labels) { + return { + unknownModel: labels.unknownModel, + modalRequests: labels.modal.requests, + modalCost: labels.modal.cost, + modalInputTokens: labels.modal.inputTokens, + modalOutputTokens: labels.modal.outputTokens, + modalCacheCreationTokens: labels.modal.cacheCreationTokens, + modalCacheReadTokens: labels.modal.cacheReadTokens, + modalTotalTokens: labels.modal.totalTokens, + modalCacheHitRate: labels.modal.cacheHitRate, + modalCacheTokens: labels.modal.cacheTokens, + modalPerformanceHigh: labels.modal.performanceHigh, + modalPerformanceMedium: labels.modal.performanceMedium, + modalPerformanceLow: labels.modal.performanceLow, + }; + } + + return { + unknownModel: t("unknownModel"), + modalRequests: t("modal.requests"), + modalCost: t("modal.cost"), + modalInputTokens: t("modal.inputTokens"), + modalOutputTokens: t("modal.outputTokens"), + modalCacheCreationTokens: t("modal.cacheWrite"), + modalCacheReadTokens: t("modal.cacheRead"), + modalTotalTokens: t("modal.totalTokens"), + modalCacheHitRate: t("modal.cacheHitRate"), + modalCacheTokens: t("modal.cacheTokens"), + modalPerformanceHigh: t("modal.performanceHigh"), + modalPerformanceMedium: t("modal.performanceMedium"), + modalPerformanceLow: t("modal.performanceLow"), + }; +} + +export function ModelBreakdownRow({ + model, + requests, + cost, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + currencyCode, + totalCost, + labels, +}: ModelBreakdownRowProps) { + const [open, setOpen] = useState(false); + const l = useLabels(labels); + + const totalAllTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; + const cacheHitRate = + totalInputTokens > 0 ? ((cacheReadTokens / totalInputTokens) * 100).toFixed(1) : "0.0"; + const costPercentage = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : "0.0"; + + const cacheHitRateNum = Number.parseFloat(cacheHitRate); + const cacheHitColor = + cacheHitRateNum >= 85 + ? "text-green-600 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "text-yellow-600 dark:text-yellow-400" + : "text-orange-600 dark:text-orange-400"; + + return ( + <> +
setOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen(true); + } + }} + > +
+ {model || l.unknownModel} +
+ + + {requests.toLocaleString()} + + + + {formatTokenAmount(totalAllTokens)} + + + + {cacheHitRate}% + +
+
+
+
{formatCurrency(cost, currencyCode)}
+
({costPercentage}%)
+
+
+ + + + + + + {model || l.unknownModel} + + +
+
+
+
+ + {l.modalRequests} +
+
{requests.toLocaleString()}
+
+ +
+
+ + {l.modalTotalTokens} +
+
+ {formatTokenAmount(totalAllTokens)} +
+
+ +
+
+ + {l.modalCost} +
+
+ {formatCurrency(cost, currencyCode)} +
+
+
+ + + +
+

+ + {l.modalTotalTokens} +

+
+
+
+ + {l.modalInputTokens} +
+
+ {formatTokenAmount(inputTokens)} +
+
+ +
+
+ + {l.modalOutputTokens} +
+
+ {formatTokenAmount(outputTokens)} +
+
+
+
+ + + +
+

+ + {l.modalCacheTokens} +

+
+
+
+ + {l.modalCacheCreationTokens} +
+
+ {formatTokenAmount(cacheCreationTokens)} +
+
+ +
+
+ + {l.modalCacheReadTokens} +
+
+ {formatTokenAmount(cacheReadTokens)} +
+
+
+ +
+
+
+ + {l.modalCacheHitRate} +
+
+ + {cacheHitRate}% + + = 85 + ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + : "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" + }`} + > + + {cacheHitRateNum >= 85 + ? l.modalPerformanceHigh + : cacheHitRateNum >= 60 + ? l.modalPerformanceMedium + : l.modalPerformanceLow} + +
+
+
+
+
+
+
+ + ); +} diff --git a/src/components/form/client-restrictions-editor.test.tsx b/src/components/form/client-restrictions-editor.test.tsx index 1c38dafa0..9de5191ad 100644 --- a/src/components/form/client-restrictions-editor.test.tsx +++ b/src/components/form/client-restrictions-editor.test.tsx @@ -23,6 +23,16 @@ const TEST_TRANSLATIONS = { "factory-cli": "Droid CLI", "codex-cli": "Codex CLI", }, + subClients: { + all: "All", + cli: "CLI", + vscode: "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action", + }, + nSelected: "{count} selected", }; function render(node: ReactNode) { diff --git a/src/components/form/client-restrictions-editor.tsx b/src/components/form/client-restrictions-editor.tsx index 078f7e7f1..7095b1f6d 100644 --- a/src/components/form/client-restrictions-editor.tsx +++ b/src/components/form/client-restrictions-editor.tsx @@ -1,19 +1,27 @@ "use client"; +import { ChevronDown } from "lucide-react"; import { useMemo } from "react"; import { ArrayTagInputField } from "@/components/form/form-field"; +import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CLIENT_RESTRICTION_PRESET_OPTIONS, + getSelectedChildren, + isAllChildrenSelected, isPresetSelected, mergePresetAndCustomClients, removePresetValues, + setChildSelection, splitPresetAndCustomClients, togglePresetSelection, } from "@/lib/client-restrictions/client-presets"; import { cn } from "@/lib/utils"; +const CLIENT_TAG_PATTERN = /^[a-zA-Z0-9_./*-]+$/; + export interface ClientRestrictionsEditorProps { allowed: string[]; blocked: string[]; @@ -31,6 +39,8 @@ export interface ClientRestrictionsEditorProps { customBlockedPlaceholder: string; customHelp: string; presetClients: Record; + subClients: Record; + nSelected: string; }; } @@ -68,6 +78,41 @@ export function ClientRestrictionsEditor({ } }; + const handleChildSelectionChange = (presetValue: string, selectedChildren: string[]) => { + const preset = CLIENT_RESTRICTION_PRESET_OPTIONS.find((p) => p.value === presetValue); + if (!preset) return; + const isInAllowed = isPresetSelected(allowed, presetValue); + const isInBlocked = isPresetSelected(blocked, presetValue); + if (isInAllowed) { + onAllowedChange(setChildSelection(allowed, preset, selectedChildren)); + } else if (isInBlocked) { + onBlockedChange(setChildSelection(blocked, preset, selectedChildren)); + } + }; + + const getChildDisplayText = (preset: (typeof CLIENT_RESTRICTION_PRESET_OPTIONS)[number]) => { + if (!preset.children) return null; + const activeList = isPresetSelected(allowed, preset.value) + ? allowed + : isPresetSelected(blocked, preset.value) + ? blocked + : null; + if (!activeList) return translations.subClients.all; + const selected = getSelectedChildren(activeList, preset); + if (selected.length === 0 || selected.length === preset.children.length) { + return translations.subClients.all; + } + if (selected.length <= 2) { + return selected + .map((v) => { + const child = preset.children!.find((c) => c.value === v); + return child ? translations.subClients[child.labelKey] : v; + }) + .join(", "); + } + return translations.nSelected.replace("{count}", String(selected.length)); + }; + const handleCustomAllowedChange = (newCustom: string[]) => { onAllowedChange(mergePresetAndCustomClients(allowed, newCustom)); }; @@ -76,7 +121,8 @@ export function ClientRestrictionsEditor({ onBlockedChange(mergePresetAndCustomClients(blocked, newCustom)); }; - const renderPresetRow = (value: string) => { + const renderPresetRow = (preset: (typeof CLIENT_RESTRICTION_PRESET_OPTIONS)[number]) => { + const { value } = preset; const isAllowed = isPresetSelected(allowed, value); const isBlocked = isPresetSelected(blocked, value); const displayLabel = translations.presetClients[value] ?? value; @@ -84,6 +130,74 @@ export function ClientRestrictionsEditor({ return (
{displayLabel} + {preset.children && ( + + + + + +
+
+ { + const allChildren = preset.children!.map((c) => c.value); + handleChildSelectionChange(value, checked ? allChildren : []); + }} + disabled={disabled || (!isAllowed && !isBlocked)} + /> + +
+
+ {preset.children.map((child) => { + const activeList = isAllowed ? allowed : isBlocked ? blocked : []; + const isChildChecked = + activeList.includes(preset.value) || activeList.includes(child.value); + return ( +
+ { + const currentSelected = getSelectedChildren(activeList, preset); + const next = checked + ? [...currentSelected, child.value] + : currentSelected.filter((v) => v !== child.value); + handleChildSelectionChange(value, next); + }} + disabled={disabled || (!isAllowed && !isBlocked)} + /> + +
+ ); + })} +
+ + + )}
{/* Preset client checkbox rows */}
- {CLIENT_RESTRICTION_PRESET_OPTIONS.map((client) => renderPresetRow(client.value))} + {CLIENT_RESTRICTION_PRESET_OPTIONS.map((client) => renderPresetRow(client))}
{/* Custom allowed patterns */} @@ -132,6 +246,7 @@ export function ClientRestrictionsEditor({ maxTagLength={64} maxTags={50} placeholder={translations.customAllowedPlaceholder} + validateTag={(tag: string) => CLIENT_TAG_PATTERN.test(tag)} value={customAllowed} onChange={handleCustomAllowedChange} disabled={disabled} @@ -145,6 +260,7 @@ export function ClientRestrictionsEditor({ maxTagLength={64} maxTags={50} placeholder={translations.customBlockedPlaceholder} + validateTag={(tag: string) => CLIENT_TAG_PATTERN.test(tag)} value={customBlocked} onChange={handleCustomBlockedChange} disabled={disabled} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index 6e9cbc01f..2b92ee2ff 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -170,87 +170,76 @@ function ChartTooltipContent({
{payload .filter((item: { type?: string }) => item.type !== "none") - .map( - ( - item: { - dataKey?: string | number; - name?: string; - payload?: { fill?: string }; - color?: string; - value?: number | string; - }, - index: number - ) => { - const key = `${nameKey || item.name || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload?.fill || item.color; - - return ( -
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", - indicator === "dot" && "items-center" - )} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter( - item.value, - item.name, - item as Parameters[2], - index, - payload - ) - ) : ( - <> - {itemConfig?.icon ? ( - - ) : ( - !hideIndicator && ( -
{ + const key = `${nameKey || item.name || String(item.dataKey ?? "") || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload?.fill || item.color; + + return ( +
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", + indicator === "dot" && "items-center" + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter( + item.value as string | number, + item.name as string, + item as Parameters[2], + index, + payload + ) + ) : ( + <> + {itemConfig?.icon ? ( + + ) : ( + !hideIndicator && ( +
- ) + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} +
-
- {nestLabel ? tooltipLabel : null} - - {itemConfig?.label || item.name} - -
- {item.value && ( - - {item.value.toLocaleString()} - - )} + > +
+ {nestLabel ? tooltipLabel : null} + + {itemConfig?.label || item.name} +
- - )} -
- ); - } - )} + {item.value && ( + + {item.value.toLocaleString()} + + )} +
+ + )} +
+ ); + })}
); diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ba354ea2a..7df820d8c 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -51,6 +51,7 @@ export const users = pgTable('users', { limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }), limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }), limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), + costResetAt: timestamp('cost_reset_at', { withTimezone: true }), limitConcurrentSessions: integer('limit_concurrent_sessions'), // Daily quota reset mode (fixed: reset at specific time, rolling: 24h window) @@ -259,13 +260,13 @@ export const providers = pgTable('providers', { // 超时配置(毫秒) // 注意:由于 undici fetch API 的限制,无法精确分离 DNS/TCP/TLS 连接阶段和响应头接收阶段 // 参考:https://github.com/nodejs/undici/discussions/1313 - // - firstByteTimeoutStreamingMs: 流式请求首字节超时(默认 30 秒,0 = 禁用)⭐ 核心 + // - firstByteTimeoutStreamingMs: 流式请求首字节超时(默认 0 = 不限制,非 0 时最小 1 秒)[核心] // 覆盖从请求开始到收到首字节的全过程:DNS + TCP + TLS + 请求发送 + 首字节接收 // 解决流式请求重试缓慢问题 - // - streamingIdleTimeoutMs: 流式请求静默期超时(默认 0 = 不限制)⭐ 核心 + // - streamingIdleTimeoutMs: 流式请求静默期超时(默认 0 = 不限制)[核心] // 解决流式中途卡住问题 // 注意:配置非 0 值时,最小必须为 60 秒 - // - requestTimeoutNonStreamingMs: 非流式请求总超时(默认 0 = 不限制)⭐ 核心 + // - requestTimeoutNonStreamingMs: 非流式请求总超时(默认 0 = 不限制)[核心] // 防止长请求无限挂起 firstByteTimeoutStreamingMs: integer('first_byte_timeout_streaming_ms').notNull().default(0), streamingIdleTimeoutMs: integer('streaming_idle_timeout_ms').notNull().default(0), @@ -715,6 +716,12 @@ export const systemSettings = pgTable('system_settings', { .notNull() .default(true), + // Response API input 整流器(默认开启) + // 开启后:当 /v1/responses 端点收到非数组 input 时,自动规范化为数组格式 + enableResponseInputRectifier: boolean('enable_response_input_rectifier') + .notNull() + .default(true), + // Codex Session ID 补全(默认开启) // 开启后:当 Codex 请求缺少 session_id / prompt_cache_key 时,自动补全或生成稳定的会话标识 enableCodexSessionIdCompletion: boolean('enable_codex_session_id_completion') diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4f6749282..4c4631532 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -291,6 +291,13 @@ export async function validateAuthToken( return validateKey(token, options); } + // Opaque mode: allow raw ADMIN_TOKEN for backward-compatible programmatic API access. + // Safe because admin token is a server-side env secret, not a user-issued DB key. + const adminToken = config.auth.adminToken; + if (adminToken && constantTimeEqual(token, adminToken)) { + return validateKey(token, options); + } + return null; } diff --git a/src/lib/client-restrictions/client-presets.test.ts b/src/lib/client-restrictions/client-presets.test.ts index 041974ca7..55e3780ad 100644 --- a/src/lib/client-restrictions/client-presets.test.ts +++ b/src/lib/client-restrictions/client-presets.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "vitest"; import { + CLIENT_RESTRICTION_PRESET_OPTIONS, + getSelectedChildren, + isAllChildrenSelected, isPresetClientValue, isPresetSelected, mergePresetAndCustomClients, removePresetValues, + setChildSelection, splitPresetAndCustomClients, togglePresetSelection, } from "./client-presets"; @@ -47,4 +51,70 @@ describe("client restriction presets", () => { mergePresetAndCustomClients(["claude-code-sdk-ts", "codex-cli"], ["my-ide", "codex-cli"]) ).toEqual(["claude-code-sdk-ts", "codex-cli", "my-ide"]); }); + + describe("child selection helpers", () => { + const claudeCodePreset = CLIENT_RESTRICTION_PRESET_OPTIONS[0]; + const geminiPreset = CLIENT_RESTRICTION_PRESET_OPTIONS[1]; + const allChildValues = claudeCodePreset.children!.map((c) => c.value); + + test("getSelectedChildren returns all children when parent value is present", () => { + expect(getSelectedChildren(["claude-code", "gemini-cli"], claudeCodePreset)).toEqual( + allChildValues + ); + }); + + test("getSelectedChildren returns specific children when individual values present", () => { + expect( + getSelectedChildren(["claude-code-cli", "claude-code-vscode"], claudeCodePreset) + ).toEqual(["claude-code-cli", "claude-code-vscode"]); + }); + + test("getSelectedChildren returns empty array for preset without children", () => { + expect(getSelectedChildren(["gemini-cli"], geminiPreset)).toEqual([]); + }); + + test("isAllChildrenSelected returns true when parent value is present", () => { + expect(isAllChildrenSelected(["claude-code"], claudeCodePreset)).toBe(true); + }); + + test("isAllChildrenSelected returns true when all 6 children individually present", () => { + expect(isAllChildrenSelected(allChildValues, claudeCodePreset)).toBe(true); + }); + + test("isAllChildrenSelected returns false for partial selection", () => { + expect( + isAllChildrenSelected(["claude-code-cli", "claude-code-vscode"], claudeCodePreset) + ).toBe(false); + }); + + test("setChildSelection auto-consolidates when all 6 children selected", () => { + expect(setChildSelection(["gemini-cli"], claudeCodePreset, allChildValues)).toEqual([ + "gemini-cli", + "claude-code", + ]); + }); + + test("setChildSelection stores individual values for partial selection", () => { + expect( + setChildSelection(["gemini-cli"], claudeCodePreset, [ + "claude-code-cli", + "claude-code-vscode", + ]) + ).toEqual(["gemini-cli", "claude-code-cli", "claude-code-vscode"]); + }); + + test("setChildSelection removes all preset values when selection is empty", () => { + expect(setChildSelection(["claude-code", "gemini-cli"], claudeCodePreset, [])).toEqual([ + "gemini-cli", + ]); + }); + + test("setChildSelection replaces existing child values with new selection", () => { + expect( + setChildSelection(["claude-code-cli", "gemini-cli"], claudeCodePreset, [ + "claude-code-vscode", + ]) + ).toEqual(["gemini-cli", "claude-code-vscode"]); + }); + }); }); diff --git a/src/lib/client-restrictions/client-presets.ts b/src/lib/client-restrictions/client-presets.ts index 825186b10..3d06d5008 100644 --- a/src/lib/client-restrictions/client-presets.ts +++ b/src/lib/client-restrictions/client-presets.ts @@ -1,6 +1,12 @@ +export interface ClientRestrictionChild { + value: string; + labelKey: string; +} + export interface ClientRestrictionPresetOption { value: string; aliases: readonly string[]; + children?: readonly ClientRestrictionChild[]; } const CLAUDE_CODE_ALIAS_VALUES = [ @@ -14,7 +20,18 @@ const CLAUDE_CODE_ALIAS_VALUES = [ ] as const; export const CLIENT_RESTRICTION_PRESET_OPTIONS: readonly ClientRestrictionPresetOption[] = [ - { value: "claude-code", aliases: CLAUDE_CODE_ALIAS_VALUES }, + { + value: "claude-code", + aliases: CLAUDE_CODE_ALIAS_VALUES, + children: [ + { value: "claude-code-cli", labelKey: "cli" }, + { value: "claude-code-vscode", labelKey: "vscode" }, + { value: "claude-code-sdk-ts", labelKey: "sdk-ts" }, + { value: "claude-code-sdk-py", labelKey: "sdk-py" }, + { value: "claude-code-cli-sdk", labelKey: "cli-sdk" }, + { value: "claude-code-gh-action", labelKey: "gh-action" }, + ], + }, { value: "gemini-cli", aliases: ["gemini-cli"] }, { value: "factory-cli", aliases: ["factory-cli"] }, { value: "codex-cli", aliases: ["codex-cli"] }, @@ -87,3 +104,37 @@ export function mergePresetAndCustomClients(values: string[], customValues: stri const filteredCustomValues = customValues.filter((value) => !PRESET_ALIAS_SET.has(value)); return uniqueOrdered([...presetValues, ...filteredCustomValues]); } + +export function getSelectedChildren( + values: string[], + preset: ClientRestrictionPresetOption +): string[] { + if (!preset.children) return []; + const childValues = preset.children.map((c) => c.value); + if (values.includes(preset.value)) return childValues; + return childValues.filter((v) => values.includes(v)); +} + +export function isAllChildrenSelected( + values: string[], + preset: ClientRestrictionPresetOption +): boolean { + if (!preset.children) return false; + if (values.includes(preset.value)) return true; + return preset.children.every((c) => values.includes(c.value)); +} + +export function setChildSelection( + values: string[], + preset: ClientRestrictionPresetOption, + selectedChildren: string[] +): string[] { + if (!preset.children) return values; + const allChildValues = new Set(preset.children.map((c) => c.value)); + const filtered = values.filter((v) => v !== preset.value && !allChildValues.has(v)); + if (selectedChildren.length === 0) return filtered; + if (selectedChildren.length === preset.children.length) { + return [...filtered, preset.value]; + } + return [...filtered, ...selectedChildren]; +} diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index dcdf167ef..c793cc4ed 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -129,6 +129,8 @@ export const EnvSchema = z.object({ FETCH_HEADERS_TIMEOUT: z.coerce.number().default(600_000), // 响应头接收超时(默认 600 秒) FETCH_CONNECT_TIMEOUT: z.coerce.number().default(30000), // TCP 连接建立超时(默认 30 秒) + DASHBOARD_LOGS_POLL_INTERVAL_MS: z.coerce.number().int().min(250).max(60000).default(5000), + // Langfuse Observability (optional, auto-enabled when keys are set) LANGFUSE_PUBLIC_KEY: z.string().optional(), LANGFUSE_SECRET_KEY: z.string().optional(), diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 9382f7ee9..4205d4f73 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -31,6 +31,7 @@ const DEFAULT_SETTINGS: Pick< | "enableThinkingSignatureRectifier" | "enableThinkingBudgetRectifier" | "enableBillingHeaderRectifier" + | "enableResponseInputRectifier" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" @@ -41,6 +42,7 @@ const DEFAULT_SETTINGS: Pick< enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, enableBillingHeaderRectifier: true, + enableResponseInputRectifier: true, enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, @@ -114,6 +116,7 @@ export async function getCachedSystemSettings(): Promise { enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: DEFAULT_SETTINGS.enableThinkingBudgetRectifier, enableBillingHeaderRectifier: DEFAULT_SETTINGS.enableBillingHeaderRectifier, + enableResponseInputRectifier: DEFAULT_SETTINGS.enableResponseInputRectifier, enableCodexSessionIdCompletion: DEFAULT_SETTINGS.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: DEFAULT_SETTINGS.enableClaudeMetadataUserIdInjection, enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, diff --git a/src/lib/dashboard/user-limit-usage-cache.test.ts b/src/lib/dashboard/user-limit-usage-cache.test.ts new file mode 100644 index 000000000..00e3684b3 --- /dev/null +++ b/src/lib/dashboard/user-limit-usage-cache.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + clearUsageCache, + getSharedUserLimitUsage, + peekCachedUserLimitUsage, +} from "./user-limit-usage-cache"; + +const { getUserAllLimitUsageMock } = vi.hoisted(() => ({ + getUserAllLimitUsageMock: vi.fn(), +})); + +vi.mock("@/actions/users", () => ({ + getUserAllLimitUsage: getUserAllLimitUsageMock, +})); + +const usagePayload = { + limit5h: { usage: 1, limit: 10 }, + limitDaily: { usage: 2, limit: 20 }, + limitWeekly: { usage: 3, limit: 30 }, + limitMonthly: { usage: 4, limit: 40 }, + limitTotal: { usage: 5, limit: 50 }, +}; + +describe("user-limit-usage-cache", () => { + beforeEach(() => { + clearUsageCache(); + getUserAllLimitUsageMock.mockReset(); + }); + + test("deduplicates concurrent requests for the same user", async () => { + let resolveRequest: ((value: { ok: true; data: typeof usagePayload }) => void) | undefined; + + getUserAllLimitUsageMock.mockImplementation( + () => + new Promise<{ ok: true; data: typeof usagePayload }>((resolve) => { + resolveRequest = resolve; + }) + ); + + const first = getSharedUserLimitUsage(7); + const second = getSharedUserLimitUsage(7); + + expect(getUserAllLimitUsageMock).toHaveBeenCalledTimes(1); + + resolveRequest?.({ ok: true, data: usagePayload }); + + await expect(first).resolves.toEqual(usagePayload); + await expect(second).resolves.toEqual(usagePayload); + expect(peekCachedUserLimitUsage(7)).toEqual(usagePayload); + }); + + test("returns fresh cached data without hitting the action again", async () => { + getUserAllLimitUsageMock.mockResolvedValue({ ok: true, data: usagePayload }); + + await expect(getSharedUserLimitUsage(9)).resolves.toEqual(usagePayload); + await expect(getSharedUserLimitUsage(9)).resolves.toEqual(usagePayload); + + expect(getUserAllLimitUsageMock).toHaveBeenCalledTimes(1); + }); + + test("logs error and returns null when getUserAllLimitUsage rejects", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const testError = new Error("network failure"); + getUserAllLimitUsageMock.mockRejectedValueOnce(testError); + + const result = await getSharedUserLimitUsage(42); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + "[user-limit-usage-cache] getUserAllLimitUsage failed", + expect.objectContaining({ userId: 42, error: testError }) + ); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/lib/dashboard/user-limit-usage-cache.ts b/src/lib/dashboard/user-limit-usage-cache.ts new file mode 100644 index 000000000..43502482c --- /dev/null +++ b/src/lib/dashboard/user-limit-usage-cache.ts @@ -0,0 +1,69 @@ +import { getUserAllLimitUsage } from "@/actions/users"; + +export interface LimitUsageData { + limit5h: { usage: number; limit: number | null }; + limitDaily: { usage: number; limit: number | null }; + limitWeekly: { usage: number; limit: number | null }; + limitMonthly: { usage: number; limit: number | null }; + limitTotal: { usage: number; limit: number | null }; +} + +export const LIMIT_USAGE_CACHE_TTL = 60 * 1000; + +const usageCache = new Map(); +const inFlightUsageRequests = new Map>(); + +function isFresh(cached: { data: LimitUsageData; timestamp: number } | undefined): cached is { + data: LimitUsageData; + timestamp: number; +} { + return Boolean(cached && Date.now() - cached.timestamp < LIMIT_USAGE_CACHE_TTL); +} + +export function clearUsageCache(userId?: number): void { + if (userId !== undefined) { + usageCache.delete(userId); + inFlightUsageRequests.delete(userId); + return; + } + + usageCache.clear(); + inFlightUsageRequests.clear(); +} + +export function peekCachedUserLimitUsage(userId: number): LimitUsageData | null { + const cached = usageCache.get(userId); + return isFresh(cached) ? cached.data : null; +} + +export async function getSharedUserLimitUsage(userId: number): Promise { + const cached = peekCachedUserLimitUsage(userId); + if (cached) { + return cached; + } + + const inFlight = inFlightUsageRequests.get(userId); + if (inFlight) { + return inFlight; + } + + const request = getUserAllLimitUsage(userId) + .then((result) => { + if (!result.ok || !result.data) { + return null; + } + + usageCache.set(userId, { data: result.data, timestamp: Date.now() }); + return result.data; + }) + .catch((error) => { + console.error("[user-limit-usage-cache] getUserAllLimitUsage failed", { userId, error }); + return null; + }) + .finally(() => { + inFlightUsageRequests.delete(userId); + }); + + inFlightUsageRequests.set(userId, request); + return request; +} diff --git a/src/lib/dashboard/user-usage-loader.test.ts b/src/lib/dashboard/user-usage-loader.test.ts new file mode 100644 index 000000000..f3ba82312 --- /dev/null +++ b/src/lib/dashboard/user-usage-loader.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { loadUserUsagePagesSequentially, USER_USAGE_IDLE_DELAY_MS } from "./user-usage-loader"; + +interface KeyUsageData { + todayUsage: number; + todayCallCount: number; + todayTokens: number; + lastUsedAt: Date | null; + lastProviderName: string | null; + modelStats: Array<{ + model: string; + callCount: number; + totalCost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + }>; +} + +function createUsage(keyId: number): Record { + return { + [keyId]: { + todayUsage: keyId, + todayCallCount: keyId, + todayTokens: keyId * 100, + lastUsedAt: null, + lastProviderName: null, + modelStats: [], + }, + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); +} + +describe("loadUserUsagePagesSequentially", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("waits for the idle window and fetches pages one by one", async () => { + vi.useFakeTimers(); + + const resolvers: Array<() => void> = []; + const fetchUsagePage = vi.fn( + (userIds: number[]) => + new Promise>((resolve) => { + resolvers.push(() => resolve(createUsage(userIds[0]))); + }) + ); + const onPageLoaded = vi.fn(); + const controller = new AbortController(); + + const task = loadUserUsagePagesSequentially({ + pageUserIds: [[11], [22]], + signal: controller.signal, + fetchUsagePage, + onPageLoaded, + }); + + await vi.advanceTimersByTimeAsync(USER_USAGE_IDLE_DELAY_MS - 1); + expect(fetchUsagePage).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(fetchUsagePage).toHaveBeenCalledTimes(1); + expect(fetchUsagePage).toHaveBeenNthCalledWith(1, [11]); + + resolvers[0]?.(); + await flushMicrotasks(); + expect(onPageLoaded).toHaveBeenCalledWith(createUsage(11)); + expect(fetchUsagePage).toHaveBeenCalledTimes(2); + expect(fetchUsagePage).toHaveBeenNthCalledWith(2, [22]); + + resolvers[1]?.(); + await flushMicrotasks(); + await task; + + expect(onPageLoaded).toHaveBeenNthCalledWith(2, createUsage(22)); + }); + + test("stops before the next page when aborted during an in-flight request", async () => { + vi.useFakeTimers(); + + let resolveFirstPage: ((value: Record) => void) | undefined; + const fetchUsagePage = vi.fn( + (userIds: number[]) => + new Promise>((resolve) => { + if (userIds[0] === 11) { + resolveFirstPage = resolve; + return; + } + resolve(createUsage(userIds[0])); + }) + ); + const onPageLoaded = vi.fn(); + const controller = new AbortController(); + + const task = loadUserUsagePagesSequentially({ + pageUserIds: [[11], [22]], + signal: controller.signal, + fetchUsagePage, + onPageLoaded, + }); + + await vi.advanceTimersByTimeAsync(USER_USAGE_IDLE_DELAY_MS); + expect(fetchUsagePage).toHaveBeenCalledTimes(1); + + controller.abort(); + resolveFirstPage?.(createUsage(11)); + await flushMicrotasks(); + await task; + + expect(onPageLoaded).not.toHaveBeenCalled(); + expect(fetchUsagePage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/dashboard/user-usage-loader.ts b/src/lib/dashboard/user-usage-loader.ts new file mode 100644 index 000000000..643eac15e --- /dev/null +++ b/src/lib/dashboard/user-usage-loader.ts @@ -0,0 +1,64 @@ +import type { KeyUsageData } from "@/actions/users"; + +export const USER_USAGE_IDLE_DELAY_MS = 250; + +interface LoadUserUsagePagesSequentiallyParams { + pageUserIds: number[][]; + signal: AbortSignal; + idleDelayMs?: number; + fetchUsagePage: (userIds: number[]) => Promise>; + onPageLoaded: (usageByKeyId: Record) => void; +} + +function waitForIdleWindow(delayMs: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(false); + return; + } + + const handleAbort = () => { + clearTimeout(timeoutId); + signal.removeEventListener("abort", handleAbort); + resolve(false); + }; + + const timeoutId = setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(true); + }, delayMs); + + signal.addEventListener("abort", handleAbort, { once: true }); + }); +} + +export async function loadUserUsagePagesSequentially({ + pageUserIds, + signal, + idleDelayMs = USER_USAGE_IDLE_DELAY_MS, + fetchUsagePage, + onPageLoaded, +}: LoadUserUsagePagesSequentiallyParams): Promise { + const canStart = await waitForIdleWindow(idleDelayMs, signal); + if (!canStart) { + return; + } + + for (const userIds of pageUserIds) { + if (signal.aborted || userIds.length === 0) { + if (signal.aborted) { + return; + } + continue; + } + + const usageByKeyId = await fetchUsagePage(userIds); + if (signal.aborted) { + return; + } + + if (Object.keys(usageByKeyId).length > 0) { + onPageLoaded(usageByKeyId); + } + } +} diff --git a/src/lib/langfuse/trace-proxy-request.test.ts b/src/lib/langfuse/trace-proxy-request.test.ts new file mode 100644 index 000000000..43a842c0b --- /dev/null +++ b/src/lib/langfuse/trace-proxy-request.test.ts @@ -0,0 +1,97 @@ +/** + * Unit tests for reason classification in trace-proxy-request. + * + * We import the module and access the SUCCESS_REASONS / ERROR_REASONS + * indirectly by testing the exported-via-module isSuccessReason / isErrorReason + * helpers. Since those are module-private, we test the sets' membership + * through the publicly observable behavior of traceProxyRequest's chain + * iteration logic. Here we directly test the sets by re-declaring them + * (mirror test pattern). + */ +import { describe, expect, test } from "vitest"; + +// Mirror the sets from trace-proxy-request.ts for unit-level validation. +// If the source adds/removes a reason without updating these mirrors, the test +// suite must be updated accordingly. +const SUCCESS_REASONS = new Set([ + "request_success", + "retry_success", + "initial_selection", + "session_reuse", + "hedge_winner", +]); + +const ERROR_REASONS = new Set([ + "system_error", + "vendor_type_all_timeout", + "endpoint_pool_exhausted", + "client_abort", +]); + +function isSuccessReason(reason: string | undefined): boolean { + return !!reason && SUCCESS_REASONS.has(reason); +} + +function isErrorReason(reason: string | undefined): boolean { + return !!reason && ERROR_REASONS.has(reason); +} + +describe("isSuccessReason", () => { + test("hedge_winner is a success reason", () => { + expect(isSuccessReason("hedge_winner")).toBe(true); + }); + + test("request_success is a success reason", () => { + expect(isSuccessReason("request_success")).toBe(true); + }); + + test("retry_success is a success reason", () => { + expect(isSuccessReason("retry_success")).toBe(true); + }); + + test("hedge_triggered is NOT a success reason", () => { + expect(isSuccessReason("hedge_triggered")).toBe(false); + }); + + test("hedge_loser_cancelled is NOT a success reason", () => { + expect(isSuccessReason("hedge_loser_cancelled")).toBe(false); + }); + + test("client_abort is NOT a success reason", () => { + expect(isSuccessReason("client_abort")).toBe(false); + }); + + test("undefined is NOT a success reason", () => { + expect(isSuccessReason(undefined)).toBe(false); + }); +}); + +describe("isErrorReason", () => { + test("client_abort is an error reason", () => { + expect(isErrorReason("client_abort")).toBe(true); + }); + + test("system_error is an error reason", () => { + expect(isErrorReason("system_error")).toBe(true); + }); + + test("hedge_winner is NOT an error reason", () => { + expect(isErrorReason("hedge_winner")).toBe(false); + }); + + test("hedge_triggered is NOT an error reason", () => { + expect(isErrorReason("hedge_triggered")).toBe(false); + }); + + test("hedge_loser_cancelled is NOT an error reason", () => { + expect(isErrorReason("hedge_loser_cancelled")).toBe(false); + }); + + test("retry_failed is NOT in the error set (it is WARNING level)", () => { + expect(isErrorReason("retry_failed")).toBe(false); + }); + + test("undefined is NOT an error reason", () => { + expect(isErrorReason(undefined)).toBe(false); + }); +}); diff --git a/src/lib/langfuse/trace-proxy-request.ts b/src/lib/langfuse/trace-proxy-request.ts index cc940b394..ad5f1f547 100644 --- a/src/lib/langfuse/trace-proxy-request.ts +++ b/src/lib/langfuse/trace-proxy-request.ts @@ -48,6 +48,7 @@ const SUCCESS_REASONS = new Set([ "retry_success", "initial_selection", "session_reuse", + "hedge_winner", ]); function isSuccessReason(reason: string | undefined): boolean { @@ -58,6 +59,7 @@ const ERROR_REASONS = new Set([ "system_error", "vendor_type_all_timeout", "endpoint_pool_exhausted", + "client_abort", ]); function isErrorReason(reason: string | undefined): boolean { @@ -275,8 +277,34 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { guardSpan.end(forwardStartDate); } - // 2. Provider attempt events (one per failed chain item) + // 2. Provider attempt events (one per failed/hedge chain item) for (const item of session.getProviderChain()) { + // Hedge trigger: informational event (not a success or failure) + if (item.reason === "hedge_triggered") { + const hedgeObs = rootSpan.startObservation( + "hedge-trigger", + { + level: "WARNING" as ObservationLevel, + input: { + providerId: item.id, + providerName: item.name, + attempt: item.attemptNumber, + }, + output: { + reason: item.reason, + circuitState: item.circuitState, + }, + metadata: { ...item }, + }, + { + asType: "event", + startTime: new Date(item.timestamp ?? session.startTime), + } as { asType: "event" } + ); + hedgeObs.end(); + continue; + } + if (!isSuccessReason(item.reason)) { const eventObs = rootSpan.startObservation( "provider-attempt", diff --git a/src/lib/log-cleanup/service.ts b/src/lib/log-cleanup/service.ts index b1d359619..f9fea232f 100644 --- a/src/lib/log-cleanup/service.ts +++ b/src/lib/log-cleanup/service.ts @@ -1,66 +1,61 @@ -import { and, between, gte, inArray, isNotNull, lte, type SQL, sql } from "drizzle-orm"; +import { and, between, gte, inArray, isNotNull, isNull, lte, type SQL, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; /** - * 日志清理条件 + * Log cleanup conditions */ export interface CleanupConditions { - // 时间范围 + // Time range beforeDate?: Date; afterDate?: Date; - // 用户维度 + // User dimension userIds?: number[]; - // 供应商维度 + // Provider dimension providerIds?: number[]; - // 状态维度 - statusCodes?: number[]; // 精确匹配状态码 + // Status dimension + statusCodes?: number[]; statusCodeRange?: { - // 状态码范围 (如 400-499) min: number; max: number; }; - onlyBlocked?: boolean; // 仅被拦截的请求 + onlyBlocked?: boolean; } /** - * 清理选项 + * Cleanup options */ export interface CleanupOptions { - batchSize?: number; // 批量删除大小(默认 10000) - dryRun?: boolean; // 仅预览,不实际删除 + batchSize?: number; + dryRun?: boolean; } /** - * 清理结果 + * Cleanup result */ export interface CleanupResult { totalDeleted: number; batchCount: number; durationMs: number; + softDeletedPurged: number; + vacuumPerformed: boolean; error?: string; } /** - * 触发信息 + * Trigger info */ export interface TriggerInfo { type: "manual" | "scheduled"; user?: string; } -/** - * 执行日志清理 - * - * @param conditions 清理条件 - * @param options 清理选项 - * @param triggerInfo 触发信息 - * @returns 清理结果 - */ +const BATCH_SLEEP_MS = 100; + // NOTE: usage_ledger is intentionally immune to log cleanup. // Only message_request rows are deleted here. export async function cleanupLogs( @@ -72,9 +67,10 @@ export async function cleanupLogs( const batchSize = options.batchSize || 10000; let totalDeleted = 0; let batchCount = 0; + let softDeletedPurged = 0; + let vacuumPerformed = false; try { - // 1. 构建 WHERE 条件 const whereConditions = buildWhereConditions(conditions); if (whereConditions.length === 0) { @@ -86,12 +82,13 @@ export async function cleanupLogs( totalDeleted: 0, batchCount: 0, durationMs: Date.now() - startTime, - error: "未指定任何清理条件", + softDeletedPurged: 0, + vacuumPerformed: false, + error: "No cleanup conditions specified", }; } if (options.dryRun) { - // 仅统计数量 const result = await db .select({ count: sql`count(*)::int` }) .from(messageRequest) @@ -107,12 +104,17 @@ export async function cleanupLogs( totalDeleted: result[0]?.count || 0, batchCount: 0, durationMs: Date.now() - startTime, + softDeletedPurged: 0, + vacuumPerformed: false, }; } - // 2. 分批删除 + // Main delete loop: only active rows (deleted_at IS NULL) to leverage partial indexes. + // Soft-deleted rows are handled separately by purgeSoftDeleted with the same scope. + const activeConditions = [...whereConditions, isNull(messageRequest.deletedAt)]; + while (true) { - const deleted = await deleteBatch(whereConditions, batchSize); + const deleted = await deleteBatch(activeConditions, batchSize); if (deleted === 0) break; @@ -126,24 +128,33 @@ export async function cleanupLogs( totalDeleted, }); - // 避免长时间锁表,短暂休息 if (deleted === batchSize) { - await sleep(100); + await sleep(BATCH_SLEEP_MS); } } + // Purge soft-deleted records scoped to the same cleanup conditions + softDeletedPurged = await purgeSoftDeleted(whereConditions, batchSize); + + // VACUUM ANALYZE to reclaim disk space + if (totalDeleted > 0 || softDeletedPurged > 0) { + vacuumPerformed = await runVacuum(); + } + const durationMs = Date.now() - startTime; logger.info({ action: "log_cleanup_complete", totalDeleted, batchCount, + softDeletedPurged, + vacuumPerformed, durationMs, triggerType: triggerInfo.type, user: triggerInfo.user, }); - return { totalDeleted, batchCount, durationMs }; + return { totalDeleted, batchCount, durationMs, softDeletedPurged, vacuumPerformed }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -159,21 +170,21 @@ export async function cleanupLogs( totalDeleted, batchCount, durationMs: Date.now() - startTime, + softDeletedPurged, + vacuumPerformed, error: errorMessage, }; } } /** - * 构建 WHERE 条件 + * Build WHERE conditions for cleanup query. + * Does NOT include deleted_at filter: the caller decides whether to target + * active rows (deleteBatch) or soft-deleted rows (purgeSoftDeleted). */ -function buildWhereConditions(conditions: CleanupConditions): SQL[] { +export function buildWhereConditions(conditions: CleanupConditions): SQL[] { const where: SQL[] = []; - // 排除软删除的记录(已经被软删除的不再处理) - where.push(sql`${messageRequest.deletedAt} IS NULL`); - - // 时间范围 if (conditions.beforeDate) { where.push(lte(messageRequest.createdAt, conditions.beforeDate)); } @@ -181,17 +192,14 @@ function buildWhereConditions(conditions: CleanupConditions): SQL[] { where.push(gte(messageRequest.createdAt, conditions.afterDate)); } - // 用户维度 if (conditions.userIds && conditions.userIds.length > 0) { where.push(inArray(messageRequest.userId, conditions.userIds)); } - // 供应商维度 if (conditions.providerIds && conditions.providerIds.length > 0) { where.push(inArray(messageRequest.providerId, conditions.providerIds)); } - // 状态维度 if (conditions.statusCodes && conditions.statusCodes.length > 0) { where.push(inArray(messageRequest.statusCode, conditions.statusCodes)); } @@ -212,40 +220,115 @@ function buildWhereConditions(conditions: CleanupConditions): SQL[] { } /** - * 批量删除 - * - * 使用 CTE (Common Table Expression) + DELETE 实现原子删除 - * 避免两步操作的竞态条件,性能更好 + * Batch delete with CTE + RETURNING 1 for driver-agnostic row counting. + * Uses FOR UPDATE SKIP LOCKED to prevent deadlocks with concurrent jobs. */ async function deleteBatch(whereConditions: SQL[], batchSize: number): Promise { - // 使用 CTE 实现原子批量删除 const result = await db.execute(sql` WITH ids_to_delete AS ( SELECT id FROM message_request WHERE ${and(...whereConditions)} ORDER BY created_at ASC LIMIT ${batchSize} - FOR UPDATE + FOR UPDATE SKIP LOCKED ) DELETE FROM message_request WHERE id IN (SELECT id FROM ids_to_delete) + RETURNING 1 `); return getAffectedRows(result); } -function getAffectedRows(result: unknown): number { +/** + * Purge soft-deleted records (deleted_at IS NOT NULL) in batches, + * scoped to the same cleanup conditions to avoid deleting beyond the user's intent. + */ +async function purgeSoftDeleted(whereConditions: SQL[], batchSize: number): Promise { + let totalPurged = 0; + const purgeConditions = [...whereConditions, isNotNull(messageRequest.deletedAt)]; + + while (true) { + const result = await db.execute(sql` + WITH ids_to_delete AS ( + SELECT id FROM message_request + WHERE ${and(...purgeConditions)} + ORDER BY created_at ASC + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM message_request + WHERE id IN (SELECT id FROM ids_to_delete) + RETURNING 1 + `); + + const deleted = getAffectedRows(result); + if (deleted === 0) break; + + totalPurged += deleted; + + logger.info({ + action: "log_cleanup_soft_delete_purge", + deletedInBatch: deleted, + totalPurged, + }); + + if (deleted === batchSize) { + await sleep(BATCH_SLEEP_MS); + } + } + + return totalPurged; +} + +/** + * Run VACUUM ANALYZE to reclaim disk space after deletions. + * NOTE: VACUUM cannot run inside a PostgreSQL transaction block. + * Drizzle's db.execute() typically runs outside transactions, but if the + * connection pool or middleware wraps calls, VACUUM will fail silently here. + * Failure is non-fatal: logged but does not fail the cleanup result. + */ +async function runVacuum(): Promise { + try { + await db.execute(sql`VACUUM ANALYZE message_request`); + logger.info({ action: "log_cleanup_vacuum_complete" }); + return true; + } catch (error) { + logger.warn({ + action: "log_cleanup_vacuum_failed", + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** + * Extract affected row count from db.execute() result. + * + * Priority: + * 1. Array with length > 0 (RETURNING rows) -> result.length + * 2. result.count (postgres.js, may be BigInt) + * 3. result.rowCount (node-postgres) + * 4. 0 + */ +export function getAffectedRows(result: unknown): number { if (!result || typeof result !== "object") { return 0; } + // RETURNING rows: postgres.js returns array of rows + if (Array.isArray(result) && result.length > 0) { + return result.length; + } + const r = result as { count?: unknown; rowCount?: unknown }; - // postgres.js returns count as BigInt; node-postgres uses rowCount as number + // postgres.js count (may be BigInt) if (r.count !== undefined) { return Number(r.count); } + // node-postgres rowCount if (typeof r.rowCount === "number") { return r.rowCount; } @@ -253,9 +336,6 @@ function getAffectedRows(result: unknown): number { return 0; } -/** - * 休眠函数 - */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/lib/rate-limit/lease-service.ts b/src/lib/rate-limit/lease-service.ts index 6ee2310c9..516f93457 100644 --- a/src/lib/rate-limit/lease-service.ts +++ b/src/lib/rate-limit/lease-service.ts @@ -43,6 +43,7 @@ export interface GetCostLeaseParams { limitAmount: number; resetTime?: string; resetMode?: DailyResetMode; + costResetAt?: Date | null; } /** @@ -106,6 +107,18 @@ export class LeaseService { return await LeaseService.refreshCostLeaseFromDb(params); } + // Check if costResetAt changed - force refresh if so + const paramResetAtMs = + params.costResetAt instanceof Date ? params.costResetAt.getTime() : null; + if ((lease.costResetAtMs ?? null) !== paramResetAtMs) { + logger.debug("[LeaseService] costResetAt changed, force refresh", { + key: leaseKey, + cachedResetAtMs: lease.costResetAtMs ?? null, + newResetAtMs: paramResetAtMs, + }); + return await LeaseService.refreshCostLeaseFromDb(params); + } + logger.debug("[LeaseService] Cache hit", { key: leaseKey, remaining: lease.remainingBudget, @@ -165,11 +178,17 @@ export class LeaseService { // Calculate time range for DB query const { startTime, endTime } = await getLeaseTimeRange(window, resetTime, resetMode); + // Clip startTime forward if costResetAt is more recent (limits-only reset) + const effectiveStartTime = + params.costResetAt instanceof Date && params.costResetAt > startTime + ? params.costResetAt + : startTime; + // Query DB for current usage const currentUsage = await LeaseService.queryDbUsage( entityType, entityId, - startTime, + effectiveStartTime, endTime ); @@ -194,6 +213,7 @@ export class LeaseService { limitAmount, remainingBudget, ttlSeconds, + costResetAtMs: params.costResetAt instanceof Date ? params.costResetAt.getTime() : null, }); // Store in Redis diff --git a/src/lib/rate-limit/lease.ts b/src/lib/rate-limit/lease.ts index 225b71bce..8e5b56e9e 100644 --- a/src/lib/rate-limit/lease.ts +++ b/src/lib/rate-limit/lease.ts @@ -38,6 +38,7 @@ export interface BudgetLease { limitAmount: number; remainingBudget: number; ttlSeconds: number; + costResetAtMs?: number | null; } /** diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 98b597788..5de042a14 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -165,6 +165,7 @@ export class RateLimitService { daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; limit_monthly_usd: number | null; + cost_reset_at?: Date | null; } ): Promise<{ allowed: boolean; reason?: string }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); @@ -214,7 +215,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_5h, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } } catch (error) { @@ -222,7 +228,12 @@ export class RateLimitService { "[RateLimit] 5h rolling window query failed, fallback to database:", error ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } else if (limit.period === "daily" && limit.resetMode === "rolling") { // daily 滚动窗口:使用 ZSET + Lua 脚本 @@ -246,7 +257,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_daily_rolling, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } } catch (error) { @@ -254,7 +270,12 @@ export class RateLimitService { "[RateLimit] Daily rolling window query failed, fallback to database:", error ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } else { // daily fixed/周/月使用普通 GET @@ -267,7 +288,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_${periodKey}, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } current = parseFloat((value as string) || "0"); @@ -287,10 +313,20 @@ export class RateLimitService { // Slow Path: Redis 不可用,降级到数据库 logger.warn(`[RateLimit] Redis unavailable, checking ${type} cost limits from database`); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } catch (error) { logger.error("[RateLimit] Check failed, fallback to database:", error); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } @@ -311,17 +347,18 @@ export class RateLimitService { try { let current = 0; const cacheKey = (() => { + const resetAtSuffix = + options?.resetAt instanceof Date && !Number.isNaN(options.resetAt.getTime()) + ? `:${options.resetAt.getTime()}` + : ""; if (entityType === "key") { - return `total_cost:key:${options?.keyHash}`; + return `total_cost:key:${options?.keyHash}${resetAtSuffix}`; } if (entityType === "user") { - return `total_cost:user:${entityId}`; + return `total_cost:user:${entityId}${resetAtSuffix}`; } - const resetAtMs = - options?.resetAt instanceof Date && !Number.isNaN(options.resetAt.getTime()) - ? options.resetAt.getTime() - : "none"; - return `total_cost:provider:${entityId}:${resetAtMs}`; + const resetAtMs = resetAtSuffix || ":none"; + return `total_cost:provider:${entityId}${resetAtMs}`; })(); const cacheTtl = 300; // 5 minutes @@ -339,9 +376,9 @@ export class RateLimitService { logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement"); return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -357,9 +394,9 @@ export class RateLimitService { if (!options?.keyHash) { return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -371,9 +408,9 @@ export class RateLimitService { logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement"); return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -401,7 +438,8 @@ export class RateLimitService { private static async checkCostLimitsFromDatabase( id: number, type: "key" | "provider" | "user", - costLimits: CostLimit[] + costLimits: CostLimit[], + costResetAt?: Date | null ): Promise<{ allowed: boolean; reason?: string }> { const { findKeyCostEntriesInTimeRange, @@ -422,6 +460,10 @@ export class RateLimitService { limit.resetMode ); + // Clip startTime forward if costResetAt is more recent + const effectiveStartTime = + costResetAt instanceof Date && costResetAt > startTime ? costResetAt : startTime; + // 查询数据库 let current = 0; let costEntries: Array<{ @@ -436,13 +478,13 @@ export class RateLimitService { if (isRollingWindow) { switch (type) { case "key": - costEntries = await findKeyCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findKeyCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; case "provider": - costEntries = await findProviderCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findProviderCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; case "user": - costEntries = await findUserCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findUserCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; default: costEntries = []; @@ -452,13 +494,13 @@ export class RateLimitService { } else { switch (type) { case "key": - current = await sumKeyCostInTimeRange(id, startTime, endTime); + current = await sumKeyCostInTimeRange(id, effectiveStartTime, endTime); break; case "provider": - current = await sumProviderCostInTimeRange(id, startTime, endTime); + current = await sumProviderCostInTimeRange(id, effectiveStartTime, endTime); break; case "user": - current = await sumUserCostInTimeRange(id, startTime, endTime); + current = await sumUserCostInTimeRange(id, effectiveStartTime, endTime); break; default: current = 0; @@ -1424,6 +1466,7 @@ export class RateLimitService { daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; limit_monthly_usd: number | null; + cost_reset_at?: Date | null; } ): Promise<{ allowed: boolean; reason?: string; failOpen?: boolean }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); @@ -1479,6 +1522,7 @@ export class RateLimitService { limitAmount: check.limit, resetTime: check.resetTime, resetMode: check.resetMode, + costResetAt: limits.cost_reset_at, }); // Fail-open if lease retrieval failed diff --git a/src/lib/redis/cost-cache-cleanup.ts b/src/lib/redis/cost-cache-cleanup.ts new file mode 100644 index 000000000..c24a706a2 --- /dev/null +++ b/src/lib/redis/cost-cache-cleanup.ts @@ -0,0 +1,159 @@ +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis"; +import { getKeyActiveSessionsKey, getUserActiveSessionsKey } from "@/lib/redis/active-session-keys"; +import { scanPattern } from "@/lib/redis/scan-helper"; + +export interface ClearUserCostCacheOptions { + userId: number; + keyIds: number[]; + keyHashes: string[]; + includeActiveSessions?: boolean; +} + +export interface ClearUserCostCacheResult { + costKeysDeleted: number; + activeSessionsDeleted: number; + durationMs: number; +} + +/** + * Scan and delete all Redis cost-cache keys for a user and their API keys. + * + * Covers: cost counters, total cost cache, lease budget slices, + * and optionally active session ZSETs. + * + * Returns null if Redis is not ready. Never throws -- logs errors internally. + */ +export async function clearUserCostCache( + options: ClearUserCostCacheOptions +): Promise { + const { userId, keyIds, keyHashes, includeActiveSessions = false } = options; + + const redis = getRedisClient(); + if (!redis || redis.status !== "ready") { + return null; + } + + const startTime = Date.now(); + + // Scan all cost patterns in parallel + const scanResults = await Promise.all([ + ...keyIds.map((keyId) => + scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { + logger.warn("Failed to scan key cost pattern", { keyId, error: err }); + return []; + }) + ), + scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { + logger.warn("Failed to scan user cost pattern", { userId, error: err }); + return []; + }), + // Total cost cache keys (with optional resetAt suffix) + scanPattern(redis, `total_cost:user:${userId}`).catch((err) => { + logger.warn("Failed to scan total cost pattern", { + userId, + pattern: `total_cost:user:${userId}`, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }), + scanPattern(redis, `total_cost:user:${userId}:*`).catch((err) => { + logger.warn("Failed to scan total cost pattern", { + userId, + pattern: `total_cost:user:${userId}:*`, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }), + ...keyHashes.map((keyHash) => + scanPattern(redis, `total_cost:key:${keyHash}`).catch((err) => { + logger.warn("Failed to scan total cost key pattern", { + keyHash, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }) + ), + ...keyHashes.map((keyHash) => + scanPattern(redis, `total_cost:key:${keyHash}:*`).catch((err) => { + logger.warn("Failed to scan total cost key pattern", { + keyHash, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }) + ), + // Lease cache keys (budget slices cached by LeaseService) + ...keyIds.map((keyId) => + scanPattern(redis, `lease:key:${keyId}:*`).catch((err) => { + logger.warn("Failed to scan lease key pattern", { + keyId, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }) + ), + scanPattern(redis, `lease:user:${userId}:*`).catch((err) => { + logger.warn("Failed to scan lease user pattern", { + userId, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }), + ]); + + const allCostKeys = scanResults.flat(); + let activeSessionsDeleted = 0; + + // Only create pipeline if there is work to do + if (allCostKeys.length === 0 && !includeActiveSessions) { + return { + costKeysDeleted: 0, + activeSessionsDeleted: 0, + durationMs: Date.now() - startTime, + }; + } + + const pipeline = redis.pipeline(); + + // Active sessions (only for full statistics reset) + if (includeActiveSessions) { + for (const keyId of keyIds) { + pipeline.del(getKeyActiveSessionsKey(keyId)); + } + pipeline.del(getUserActiveSessionsKey(userId)); + activeSessionsDeleted = keyIds.length + 1; + } + + // Cost keys + for (const key of allCostKeys) { + pipeline.del(key); + } + + let results: Array<[Error | null, unknown]> | null = null; + try { + results = await pipeline.exec(); + } catch (error) { + logger.warn("Redis pipeline.exec() failed during cost cache cleanup", { userId, error }); + return { + costKeysDeleted: allCostKeys.length, + activeSessionsDeleted, + durationMs: Date.now() - startTime, + }; + } + + // Check for pipeline errors + const errors = results?.filter(([err]) => err); + if (errors && errors.length > 0) { + logger.warn("Some Redis deletes failed during cost cache cleanup", { + errorCount: errors.length, + userId, + }); + } + + return { + costKeysDeleted: allCostKeys.length, + activeSessionsDeleted, + durationMs: Date.now() - startTime, + }; +} diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 26abb74ac..536b39f16 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -46,7 +46,7 @@ export interface LeaderboardFilters { providerType?: ProviderType; userTags?: string[]; userGroups?: string[]; - /** 仅 scope=provider 生效:是否包含按模型拆分的数据(ProviderLeaderboardEntry.modelStats) */ + /** scope=provider 或 scope=user 时生效:是否包含按模型拆分的数据 */ includeModelStats?: boolean; } @@ -65,7 +65,9 @@ function buildCacheKey( const now = new Date(); const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : ""; const includeModelStatsSuffix = - scope === "provider" && filters?.includeModelStats ? ":includeModelStats" : ""; + (scope === "provider" || scope === "user") && filters?.includeModelStats + ? ":includeModelStats" + : ""; let userFilterSuffix = ""; if (scope === "user") { @@ -116,7 +118,7 @@ async function queryDatabase( // 处理自定义日期范围 if (period === "custom" && dateRange) { if (scope === "user") { - return await findCustomRangeLeaderboard(dateRange, userFilters); + return await findCustomRangeLeaderboard(dateRange, userFilters, filters?.includeModelStats); } if (scope === "provider") { return await findCustomRangeProviderLeaderboard( @@ -134,15 +136,15 @@ async function queryDatabase( if (scope === "user") { switch (period) { case "daily": - return await findDailyLeaderboard(userFilters); + return await findDailyLeaderboard(userFilters, filters?.includeModelStats); case "weekly": - return await findWeeklyLeaderboard(userFilters); + return await findWeeklyLeaderboard(userFilters, filters?.includeModelStats); case "monthly": - return await findMonthlyLeaderboard(userFilters); + return await findMonthlyLeaderboard(userFilters, filters?.includeModelStats); case "allTime": - return await findAllTimeLeaderboard(userFilters); + return await findAllTimeLeaderboard(userFilters, filters?.includeModelStats); default: - return await findDailyLeaderboard(userFilters); + return await findDailyLeaderboard(userFilters, filters?.includeModelStats); } } if (scope === "provider") { diff --git a/src/lib/redis/live-chain-store.test.ts b/src/lib/redis/live-chain-store.test.ts new file mode 100644 index 000000000..79b79d1ab --- /dev/null +++ b/src/lib/redis/live-chain-store.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { inferPhase } from "./live-chain-store"; +import type { ProviderChainItem } from "@/types/message"; + +// Note: writeLiveChain/readLiveChain/readLiveChainBatch/deleteLiveChain +// require "server-only" + Redis, so they are tested via integration tests. +// This file tests the pure logic function: inferPhase. + +function makeChainItem(overrides: Partial = {}): ProviderChainItem { + return { id: 1, name: "provider-a", timestamp: Date.now(), ...overrides }; +} + +describe("inferPhase", () => { + it('returns "queued" for empty chain', () => { + expect(inferPhase([])).toBe("queued"); + }); + + it('returns "provider_selected" for initial_selection', () => { + expect(inferPhase([makeChainItem({ reason: "initial_selection" })])).toBe("provider_selected"); + }); + + it('returns "session_reused" for session_reuse', () => { + expect(inferPhase([makeChainItem({ reason: "session_reuse" })])).toBe("session_reused"); + }); + + it('returns "retrying" for retry_failed', () => { + expect( + inferPhase([ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed" }), + ]) + ).toBe("retrying"); + }); + + it('returns "retrying" for system_error', () => { + expect(inferPhase([makeChainItem({ reason: "system_error" })])).toBe("retrying"); + }); + + it('returns "retrying" for resource_not_found', () => { + expect(inferPhase([makeChainItem({ reason: "resource_not_found" })])).toBe("retrying"); + }); + + it('returns "hedge_racing" for hedge_triggered', () => { + expect(inferPhase([makeChainItem({ reason: "hedge_triggered" })])).toBe("hedge_racing"); + }); + + it('returns "hedge_racing" for hedge_launched', () => { + expect(inferPhase([makeChainItem({ reason: "hedge_launched" })])).toBe("hedge_racing"); + }); + + it('returns "hedge_resolved" for hedge_winner', () => { + expect( + inferPhase([ + makeChainItem({ reason: "hedge_triggered" }), + makeChainItem({ reason: "hedge_winner" }), + ]) + ).toBe("hedge_resolved"); + }); + + it('returns "hedge_resolved" for hedge_loser_cancelled', () => { + expect(inferPhase([makeChainItem({ reason: "hedge_loser_cancelled" })])).toBe("hedge_resolved"); + }); + + it('returns "streaming" for request_success', () => { + expect( + inferPhase([ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "request_success" }), + ]) + ).toBe("streaming"); + }); + + it('returns "streaming" for retry_success', () => { + expect( + inferPhase([ + makeChainItem({ reason: "retry_failed" }), + makeChainItem({ reason: "retry_success" }), + ]) + ).toBe("streaming"); + }); + + it('returns "aborted" for client_abort', () => { + expect(inferPhase([makeChainItem({ reason: "client_abort" })])).toBe("aborted"); + }); + + it('returns "forwarding" for unknown reasons', () => { + expect(inferPhase([makeChainItem({ reason: undefined })])).toBe("forwarding"); + }); + + it("uses last chain item to determine phase", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed" }), + makeChainItem({ reason: "request_success" }), + ]; + expect(inferPhase(chain)).toBe("streaming"); + }); +}); diff --git a/src/lib/redis/live-chain-store.ts b/src/lib/redis/live-chain-store.ts new file mode 100644 index 000000000..937439d3f --- /dev/null +++ b/src/lib/redis/live-chain-store.ts @@ -0,0 +1,92 @@ +import "server-only"; + +import type { ProviderChainItem } from "@/types/message"; +import { RedisKVStore } from "./redis-kv-store"; + +export interface LiveChainSnapshot { + chain: ProviderChainItem[]; + phase: string; + updatedAt: number; +} + +const SESSION_TTL = Number.parseInt(process.env.SESSION_TTL || "300", 10); + +const store = new RedisKVStore({ + prefix: "cch:live-chain:", + defaultTtlSeconds: SESSION_TTL, +}); + +function buildKey(sessionId: string, requestSequence: number): string { + return `${sessionId}:${requestSequence}`; +} + +export function inferPhase(chain: ProviderChainItem[]): string { + if (chain.length === 0) return "queued"; + const last = chain[chain.length - 1]; + switch (last.reason) { + case "initial_selection": + return "provider_selected"; + case "session_reuse": + return "session_reused"; + case "retry_failed": + case "system_error": + case "resource_not_found": + return "retrying"; + case "hedge_triggered": + case "hedge_launched": + return "hedge_racing"; + case "hedge_winner": + case "hedge_loser_cancelled": + return "hedge_resolved"; + case "request_success": + case "retry_success": + return "streaming"; + case "client_abort": + return "aborted"; + default: + return "forwarding"; + } +} + +export async function writeLiveChain( + sessionId: string, + requestSequence: number, + chain: ProviderChainItem[] +): Promise { + const snapshot: LiveChainSnapshot = { + chain, + phase: inferPhase(chain), + updatedAt: Date.now(), + }; + await store.set(buildKey(sessionId, requestSequence), snapshot); +} + +export async function readLiveChain( + sessionId: string, + requestSequence: number +): Promise { + return store.get(buildKey(sessionId, requestSequence)); +} + +export async function readLiveChainBatch( + keys: Array<{ sessionId: string; requestSequence: number }> +): Promise> { + const results = new Map(); + if (keys.length === 0) return results; + + const entries = await Promise.all( + keys.map(async (k) => { + const snapshot = await store.get(buildKey(k.sessionId, k.requestSequence)); + return { key: buildKey(k.sessionId, k.requestSequence), snapshot }; + }) + ); + + for (const { key, snapshot } of entries) { + if (snapshot) results.set(key, snapshot); + } + return results; +} + +export async function deleteLiveChain(sessionId: string, requestSequence: number): Promise { + await store.delete(buildKey(sessionId, requestSequence)); +} diff --git a/src/lib/security/api-key-auth-cache.ts b/src/lib/security/api-key-auth-cache.ts index 66fcc7027..42471dbf3 100644 --- a/src/lib/security/api-key-auth-cache.ts +++ b/src/lib/security/api-key-auth-cache.ts @@ -169,8 +169,10 @@ function hydrateUserFromCache(payload: CachedUserPayloadV1): User | null { const expiresAt = parseOptionalDate(user.expiresAt); const deletedAt = parseOptionalDate(user.deletedAt); + const costResetAt = parseOptionalDate(user.costResetAt); if (user.expiresAt != null && !expiresAt) return null; if (user.deletedAt != null && !deletedAt) return null; + // costResetAt: intentional fail-open on invalid date -- affects quota counting window, not access control return { ...(payload.user as User), @@ -178,6 +180,7 @@ function hydrateUserFromCache(payload: CachedUserPayloadV1): User | null { updatedAt, expiresAt: expiresAt === undefined ? undefined : expiresAt, deletedAt: deletedAt === undefined ? undefined : deletedAt, + costResetAt: costResetAt === undefined ? undefined : costResetAt, } as User; } diff --git a/src/lib/utils/cost-calculation.ts b/src/lib/utils/cost-calculation.ts index 6ffa14bac..f852cb305 100644 --- a/src/lib/utils/cost-calculation.ts +++ b/src/lib/utils/cost-calculation.ts @@ -91,9 +91,12 @@ function __calculateTieredCostWithSeparatePrices( function resolveLongContextThreshold(priceData: ModelPriceData): number { const has272kFields = typeof priceData.input_cost_per_token_above_272k_tokens === "number" || + typeof priceData.input_cost_per_token_above_272k_tokens_priority === "number" || typeof priceData.output_cost_per_token_above_272k_tokens === "number" || + typeof priceData.output_cost_per_token_above_272k_tokens_priority === "number" || typeof priceData.cache_creation_input_token_cost_above_272k_tokens === "number" || typeof priceData.cache_read_input_token_cost_above_272k_tokens === "number" || + typeof priceData.cache_read_input_token_cost_above_272k_tokens_priority === "number" || typeof priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens === "number"; const modelFamily = typeof priceData.model_family === "string" ? priceData.model_family : ""; @@ -104,6 +107,24 @@ function resolveLongContextThreshold(priceData: ModelPriceData): number { return CONTEXT_1M_TOKEN_THRESHOLD; } +function resolvePriorityAwareLongContextRate( + priorityServiceTierApplied: boolean, + fields: { + above272k?: number; + above272kPriority?: number; + above200k?: number; + above200kPriority?: number; + } +): number | undefined { + if (priorityServiceTierApplied) { + return ( + fields.above272kPriority ?? fields.above200kPriority ?? fields.above272k ?? fields.above200k + ); + } + + return fields.above272k ?? fields.above200k; +} + function getRequestInputContextTokens( usage: UsageMetrics, cache5mTokens?: number, @@ -207,12 +228,18 @@ export function calculateRequestCostBreakdown( } } - const inputAboveThreshold = - priceData.input_cost_per_token_above_272k_tokens ?? - priceData.input_cost_per_token_above_200k_tokens; - const outputAboveThreshold = - priceData.output_cost_per_token_above_272k_tokens ?? - priceData.output_cost_per_token_above_200k_tokens; + const inputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.input_cost_per_token_above_272k_tokens, + above272kPriority: priceData.input_cost_per_token_above_272k_tokens_priority, + above200k: priceData.input_cost_per_token_above_200k_tokens, + above200kPriority: priceData.input_cost_per_token_above_200k_tokens_priority, + }); + const outputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.output_cost_per_token_above_272k_tokens, + above272kPriority: priceData.output_cost_per_token_above_272k_tokens_priority, + above200k: priceData.output_cost_per_token_above_200k_tokens, + above200kPriority: priceData.output_cost_per_token_above_200k_tokens_priority, + }); const cacheCreationAboveThreshold = priceData.cache_creation_input_token_cost_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_200k_tokens; @@ -220,9 +247,12 @@ export function calculateRequestCostBreakdown( priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens ?? cacheCreationAboveThreshold; - const cacheReadAboveThreshold = - priceData.cache_read_input_token_cost_above_272k_tokens ?? - priceData.cache_read_input_token_cost_above_200k_tokens; + const cacheReadAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.cache_read_input_token_cost_above_272k_tokens, + above272kPriority: priceData.cache_read_input_token_cost_above_272k_tokens_priority, + above200k: priceData.cache_read_input_token_cost_above_200k_tokens, + above200kPriority: priceData.cache_read_input_token_cost_above_200k_tokens_priority, + }); const longContextThreshold = resolveLongContextThreshold(priceData); const longContextThresholdExceeded = getRequestInputContextTokens(usage, cache5mTokens, cache1hTokens) > longContextThreshold; @@ -434,12 +464,18 @@ export function calculateRequestCost( } } - const inputAboveThreshold = - priceData.input_cost_per_token_above_272k_tokens ?? - priceData.input_cost_per_token_above_200k_tokens; - const outputAboveThreshold = - priceData.output_cost_per_token_above_272k_tokens ?? - priceData.output_cost_per_token_above_200k_tokens; + const inputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.input_cost_per_token_above_272k_tokens, + above272kPriority: priceData.input_cost_per_token_above_272k_tokens_priority, + above200k: priceData.input_cost_per_token_above_200k_tokens, + above200kPriority: priceData.input_cost_per_token_above_200k_tokens_priority, + }); + const outputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.output_cost_per_token_above_272k_tokens, + above272kPriority: priceData.output_cost_per_token_above_272k_tokens_priority, + above200k: priceData.output_cost_per_token_above_200k_tokens, + above200kPriority: priceData.output_cost_per_token_above_200k_tokens_priority, + }); const cacheCreationAboveThreshold = priceData.cache_creation_input_token_cost_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_200k_tokens; @@ -447,9 +483,12 @@ export function calculateRequestCost( priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens ?? cacheCreationAboveThreshold; - const cacheReadAboveThreshold = - priceData.cache_read_input_token_cost_above_272k_tokens ?? - priceData.cache_read_input_token_cost_above_200k_tokens; + const cacheReadAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.cache_read_input_token_cost_above_272k_tokens, + above272kPriority: priceData.cache_read_input_token_cost_above_272k_tokens_priority, + above200k: priceData.cache_read_input_token_cost_above_200k_tokens, + above200kPriority: priceData.cache_read_input_token_cost_above_200k_tokens_priority, + }); const longContextThreshold = resolveLongContextThreshold(priceData); const longContextThresholdExceeded = getRequestInputContextTokens(usage, cache5mTokens, cache1hTokens) > longContextThreshold; diff --git a/src/lib/utils/price-data.ts b/src/lib/utils/price-data.ts index 01a903820..8ac3c0f11 100644 --- a/src/lib/utils/price-data.ts +++ b/src/lib/utils/price-data.ts @@ -17,11 +17,17 @@ function collectNumericCosts(priceData: ModelPriceData): unknown[] { priceData.cache_creation_input_token_cost_above_200k_tokens, priceData.cache_read_input_token_cost_above_200k_tokens, priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens, + priceData.input_cost_per_token_above_200k_tokens_priority, + priceData.output_cost_per_token_above_200k_tokens_priority, + priceData.cache_read_input_token_cost_above_200k_tokens_priority, priceData.input_cost_per_token_above_272k_tokens, priceData.output_cost_per_token_above_272k_tokens, priceData.cache_creation_input_token_cost_above_272k_tokens, priceData.cache_read_input_token_cost_above_272k_tokens, priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens, + priceData.input_cost_per_token_above_272k_tokens_priority, + priceData.output_cost_per_token_above_272k_tokens_priority, + priceData.cache_read_input_token_cost_above_272k_tokens_priority, priceData.input_cost_per_token_priority, priceData.output_cost_per_token_priority, priceData.cache_read_input_token_cost_priority, diff --git a/src/lib/utils/pricing-resolution.ts b/src/lib/utils/pricing-resolution.ts index 6b440f9e9..c38fd9784 100644 --- a/src/lib/utils/pricing-resolution.ts +++ b/src/lib/utils/pricing-resolution.ts @@ -49,11 +49,17 @@ const DETAIL_FIELDS = [ "cache_creation_input_token_cost_above_200k_tokens", "cache_read_input_token_cost_above_200k_tokens", "cache_creation_input_token_cost_above_1hr_above_200k_tokens", + "input_cost_per_token_above_200k_tokens_priority", + "output_cost_per_token_above_200k_tokens_priority", + "cache_read_input_token_cost_above_200k_tokens_priority", "input_cost_per_token_above_272k_tokens", "output_cost_per_token_above_272k_tokens", "cache_creation_input_token_cost_above_272k_tokens", "cache_read_input_token_cost_above_272k_tokens", "cache_creation_input_token_cost_above_1hr_above_272k_tokens", + "input_cost_per_token_above_272k_tokens_priority", + "output_cost_per_token_above_272k_tokens_priority", + "cache_read_input_token_cost_above_272k_tokens_priority", "input_cost_per_token_priority", "output_cost_per_token_priority", "cache_read_input_token_cost_priority", diff --git a/src/lib/utils/provider-chain-display.test.ts b/src/lib/utils/provider-chain-display.test.ts new file mode 100644 index 000000000..19148ecb0 --- /dev/null +++ b/src/lib/utils/provider-chain-display.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { shouldShowCostBadgeInCell } from "./provider-chain-display"; +import type { ProviderChainItem } from "@/types/message"; + +function makeChainItem(overrides: Partial = {}): ProviderChainItem { + return { id: 1, name: "provider-a", ...overrides }; +} + +describe("shouldShowCostBadgeInCell", () => { + it("returns false when costMultiplier is null", () => { + expect(shouldShowCostBadgeInCell([], null)).toBe(false); + }); + + it("returns false when costMultiplier is undefined", () => { + expect(shouldShowCostBadgeInCell([], undefined)).toBe(false); + }); + + it("returns false when costMultiplier is 1", () => { + expect(shouldShowCostBadgeInCell([], 1)).toBe(false); + }); + + it("returns false when costMultiplier is NaN", () => { + expect(shouldShowCostBadgeInCell([], Number.NaN)).toBe(false); + }); + + it("returns false when costMultiplier is Infinity", () => { + expect(shouldShowCostBadgeInCell([], Number.POSITIVE_INFINITY)).toBe(false); + }); + + it("returns true for simple request (empty chain) with multiplier != 1", () => { + expect(shouldShowCostBadgeInCell([], 1.5)).toBe(true); + expect(shouldShowCostBadgeInCell(null, 0.8)).toBe(true); + expect(shouldShowCostBadgeInCell(undefined, 2.0)).toBe(true); + }); + + it("returns true for single-request chain (no retries, no hedge)", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 1.5)).toBe(true); + }); + + it("returns false when chain has retries", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed", statusCode: 500 }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 1.5)).toBe(false); + }); + + it("returns false when chain has hedge race", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "hedge_triggered" }), + makeChainItem({ reason: "hedge_launched" }), + makeChainItem({ reason: "hedge_winner", statusCode: 200 }), + makeChainItem({ reason: "hedge_loser_cancelled" }), + ]; + expect(shouldShowCostBadgeInCell(chain, 1.5)).toBe(false); + }); + + it("returns false for mixed retry + hedge chain", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed" }), + makeChainItem({ reason: "hedge_triggered" }), + makeChainItem({ reason: "hedge_winner", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 2.0)).toBe(false); + }); + + it("returns true for session_reuse (no retry, no hedge)", () => { + const chain = [ + makeChainItem({ reason: "session_reuse" }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 0.5)).toBe(true); + }); + + it("returns true for multiplier < 1 (discount)", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 0.5)).toBe(true); + }); +}); diff --git a/src/lib/utils/provider-chain-display.ts b/src/lib/utils/provider-chain-display.ts new file mode 100644 index 000000000..2f02a7f5e --- /dev/null +++ b/src/lib/utils/provider-chain-display.ts @@ -0,0 +1,24 @@ +import type { ProviderChainItem } from "@/types/message"; +import { getRetryCount, isHedgeRace } from "./provider-chain-formatter"; + +/** + * Determine whether the cost multiplier badge should render + * in the TABLE CELL (outside the popover trigger). + * + * Rules: + * - Must have a cost badge (multiplier != 1) + * - Must NOT have retries (retries show badge inside popover) + * - Must NOT be a hedge race (hedge shows badge inside popover) + */ +export function shouldShowCostBadgeInCell( + providerChain: ProviderChainItem[] | null | undefined, + costMultiplier: number | null | undefined +): boolean { + if (costMultiplier == null || costMultiplier === 1) return false; + if (!Number.isFinite(costMultiplier)) return false; + const chain = providerChain ?? []; + if (chain.length === 0) return true; // no chain = simple request + if (getRetryCount(chain) > 0) return false; // retries -> badge in popover + if (isHedgeRace(chain)) return false; // hedge -> badge in popover + return true; +} diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index d1f9f6950..8864f4020 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -6,6 +6,10 @@ import { formatProviderDescription, formatProviderSummary, formatProviderTimeline, + getFinalProviderName, + getRetryCount, + isActualRequest, + isHedgeRace, } from "./provider-chain-formatter"; /** @@ -522,3 +526,372 @@ describe("unknown reason graceful degradation", () => { expect(timeline).toContain("timeline.unknown"); }); }); + +describe("hedge and client_abort reason handling", () => { + test("hedge_winner with statusCode is treated as success", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 1 }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 2, + }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + // hedge_winner should appear in timeline + expect(timeline).toContain("p2"); + }); + + test("hedge_triggered is not an actual request", () => { + const item: ProviderChainItem = { + id: 1, + name: "p1", + reason: "hedge_triggered", + timestamp: 1000, + }; + // formatProviderDescription should handle hedge_triggered + const desc = formatProviderDescription([item], mockT); + expect(desc).toBeDefined(); + }); + + test("hedge_loser_cancelled is an actual request", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 1000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + expect(timeline).toContain("p1"); + }); + + test("client_abort is an actual request", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "client_abort", timestamp: 1000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + expect(timeline).toContain("p1"); + }); + + test("formatProviderSummary handles hedge_winner chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 1000 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 2000, attemptNumber: 1 }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 3000, + attemptNumber: 2, + }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 3000, attemptNumber: 1 }, + ]; + const summary = formatProviderSummary(chain, mockT); + expect(summary).toBeDefined(); + }); +}); + +// ============================================================================= +// isHedgeRace and getRetryCount tests +// ============================================================================= + +describe("isHedgeRace", () => { + test("returns true when chain contains hedge_triggered", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns true when chain contains hedge_launched", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_launched", timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns true when chain contains hedge_winner", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_winner", statusCode: 200, timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns true when chain contains hedge_loser_cancelled", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns false for regular retry chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "p2", reason: "retry_success", statusCode: 200, timestamp: 2000 }, + ]; + expect(isHedgeRace(chain)).toBe(false); + }); + + test("returns false for single success", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "request_success", statusCode: 200, timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(false); + }); +}); + +describe("getRetryCount", () => { + test("returns 0 for hedge race (not a retry)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001 }, + { id: 2, name: "p2", reason: "hedge_winner", statusCode: 200, timestamp: 2000 }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000 }, + ]; + expect(getRetryCount(chain)).toBe(0); + }); + + test("returns 0 for single successful request", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "request_success", statusCode: 200, timestamp: 1000 }, + ]; + expect(getRetryCount(chain)).toBe(0); + }); + + test("returns 1 for one retry (2 actual requests)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "p2", reason: "retry_success", statusCode: 200, timestamp: 2000 }, + ]; + expect(getRetryCount(chain)).toBe(1); + }); + + test("returns 2 for two retries (3 actual requests)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "p2", reason: "retry_failed", timestamp: 2000 }, + { id: 3, name: "p3", reason: "retry_success", statusCode: 200, timestamp: 3000 }, + ]; + expect(getRetryCount(chain)).toBe(2); + }); +}); + +describe("hedge_launched reason handling", () => { + test("hedge_launched is not an actual request", () => { + const item: ProviderChainItem = { + id: 2, + name: "p2", + reason: "hedge_launched", + timestamp: 1001, + }; + expect(isActualRequest(item)).toBe(false); + }); + + test("hedge_launched appears in timeline", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 1 }, + { + id: 2, + name: "p2", + reason: "hedge_launched", + timestamp: 1001, + attemptNumber: 2, + circuitState: "closed", + }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 2, + }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + expect(timeline).toContain("timeline.hedgeLaunched"); + expect(timeline).toContain("p2"); + }); +}); + +describe("Edge cases for hedge race detection", () => { + test("isHedgeRace returns false for empty chain", () => { + const chain: ProviderChainItem[] = []; + expect(isHedgeRace(chain)).toBe(false); + }); + + test("getRetryCount returns 0 for empty chain", () => { + const chain: ProviderChainItem[] = []; + expect(getRetryCount(chain)).toBe(0); + }); + + test("isHedgeRace returns true for incomplete hedge chain (only hedge_launched, no winner)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001, attemptNumber: 2 }, + // System crashed or request cancelled before winner determined + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("getRetryCount returns 0 for incomplete hedge chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001, attemptNumber: 2 }, + ]; + expect(getRetryCount(chain)).toBe(0); + }); + + test("mixed scenario: retry + hedge race (hedge takes precedence)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 0 }, + { id: 2, name: "p2", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 2 }, + { id: 3, name: "p3", reason: "hedge_launched", timestamp: 1001, attemptNumber: 3 }, + { + id: 3, + name: "p3", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 3, + }, + { id: 2, name: "p2", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 2 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + expect(getRetryCount(chain)).toBe(0); // Hedge race takes precedence over retry count + }); + + test("multiple hedge_launched entries (3+ concurrent providers)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 1 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001, attemptNumber: 2 }, + { id: 3, name: "p3", reason: "hedge_launched", timestamp: 1002, attemptNumber: 3 }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 2, + }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, + { id: 3, name: "p3", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 3 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + expect(getRetryCount(chain)).toBe(0); + + // Verify all hedge_launched entries are not counted as actual requests + const actualRequests = chain.filter(isActualRequest); + expect(actualRequests).toHaveLength(3); // winner + 2 losers + expect( + actualRequests.every( + (item) => item.reason === "hedge_winner" || item.reason === "hedge_loser_cancelled" + ) + ).toBe(true); + }); +}); + +// ============================================================================= +// getFinalProviderName tests +// ============================================================================= + +describe("getFinalProviderName", () => { + test("returns null for empty chain", () => { + expect(getFinalProviderName([])).toBeNull(); + }); + + test("returns null for null/undefined chain", () => { + expect(getFinalProviderName(null as unknown as ProviderChainItem[])).toBeNull(); + expect(getFinalProviderName(undefined as unknown as ProviderChainItem[])).toBeNull(); + }); + + test("returns provider name for single request_success", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "request_success", statusCode: 200, timestamp: 1000 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-a"); + }); + + test("returns hedge_winner provider when hedge_loser_cancelled is last", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "provider-a", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "provider-b", reason: "hedge_launched", timestamp: 1001 }, + { + id: 2, + name: "provider-b", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + }, + { id: 1, name: "provider-a", reason: "hedge_loser_cancelled", timestamp: 2001 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); + + test("returns retry_success provider for retry chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "retry_failed", timestamp: 1000 }, + { + id: 2, + name: "provider-b", + reason: "retry_success", + statusCode: 200, + timestamp: 2000, + }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); + + test("returns last entry name when all entries are failures", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "provider-b", reason: "retry_failed", timestamp: 2000 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); + + test("returns last entry name for intermediate-only chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "initial_selection", timestamp: 0 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-a"); + }); + + test("returns fallback for retry_success without statusCode", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "retry_success", timestamp: 1000 }, + ]; + // No statusCode means it's an intermediate state, falls through to last-entry fallback + expect(getFinalProviderName(chain)).toBe("provider-a"); + }); + + test("hedge_winner takes priority over request_success earlier in chain", () => { + // Edge case: both hedge_winner and request_success present + const chain: ProviderChainItem[] = [ + { + id: 1, + name: "provider-a", + reason: "request_success", + statusCode: 200, + timestamp: 500, + }, + { + id: 2, + name: "provider-b", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); +}); diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 1f7c72150..3629d5f8b 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -56,7 +56,12 @@ export function formatProbabilityCompact(probability: number | undefined | null) */ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | "↓" | null { // 成功标记:必须有 statusCode 且是成功状态码 - if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { + if ( + (item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner") && + item.statusCode + ) { return "✓"; } // 失败标记 @@ -66,10 +71,15 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" || - item.reason === "vendor_type_all_timeout" + item.reason === "vendor_type_all_timeout" || + item.reason === "client_abort" ) { return "✗"; } + // Hedge 输家:取消标记 + if (item.reason === "hedge_loser_cancelled") { + return "✗"; + } // 并发限制失败 if (item.reason === "concurrent_limit_failed") { return "⚡"; @@ -78,14 +88,21 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " if (item.reason === "http2_fallback") { return "↓"; } + // Hedge 触发和启动(信息性事件,不是请求结果) + if (item.reason === "hedge_triggered" || item.reason === "hedge_launched") { + return null; + } // 中间状态(选择成功但还没有请求结果) return null; } /** - * 辅助函数:判断是否为实际请求记录(排除中间状态) + * Determine if a chain item represents an actual upstream request + * (as opposed to intermediate states like initial_selection or hedge_triggered). + * + * Shared by provider-chain-popover and virtualized-logs-table. */ -function isActualRequest(item: ProviderChainItem): boolean { +export function isActualRequest(item: ProviderChainItem): boolean { // 并发限制失败:算作一次尝试 if (item.reason === "concurrent_limit_failed") return true; @@ -96,11 +113,18 @@ function isActualRequest(item: ProviderChainItem): boolean { item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" || - item.reason === "vendor_type_all_timeout" + item.reason === "vendor_type_all_timeout" || + item.reason === "client_abort" ) { return true; } + // Hedge 相关:winner 和 loser 都是实际请求 + if (item.reason === "hedge_winner" || item.reason === "hedge_loser_cancelled") return true; + + // Hedge 触发和启动:信息性事件,不算实际请求 + if (item.reason === "hedge_triggered" || item.reason === "hedge_launched") return false; + // HTTP/2 回退:算作一次中间事件(显示但不计入失败) if (item.reason === "http2_fallback") return true; @@ -113,6 +137,75 @@ function isActualRequest(item: ProviderChainItem): boolean { return false; } +/** + * Determine if a decision chain contains a hedge race + * (concurrent attempts, not sequential retries). + */ +export function isHedgeRace(chain: ProviderChainItem[]): boolean { + return chain.some( + (item) => + item.reason === "hedge_triggered" || + item.reason === "hedge_launched" || + item.reason === "hedge_winner" || + item.reason === "hedge_loser_cancelled" + ); +} + +/** + * Determine the final (winning) provider from a decision chain. + * + * Priority order: + * 1. hedge_winner -- the provider that won a hedge race + * 2. Last request_success / retry_success with a statusCode + * 3. Fallback to the last entry's name + * + * Returns null for empty / nullish chains. + */ +export function getFinalProviderName(chain: ProviderChainItem[] | null | undefined): string | null { + if (!chain || chain.length === 0) return null; + + // Priority 1: hedge_winner + const hedgeWinner = chain.find((item) => item.reason === "hedge_winner"); + if (hedgeWinner) return hedgeWinner.name; + + // Priority 2: last successful request (must have statusCode) + for (let i = chain.length - 1; i >= 0; i--) { + const item = chain[i]; + if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { + return item.name; + } + } + + // Priority 3: fallback to last entry + return chain[chain.length - 1].name; +} + +/** + * Count real retries (excluding hedge race concurrent attempts). + * + * Design Decision: + * - Hedge races are concurrent attempts, NOT sequential retries + * - When a chain contains hedge race markers, we prioritize showing "Hedge Race" + * instead of retry count, as it's more important information for users + * + * Mixed Scenario Handling: + * - If a chain contains BOTH sequential retries AND hedge race (e.g., retry_failed → hedge_triggered), + * this function returns 0 to indicate "no sequential retries to display" + * - The UI will show "Hedge Race" badge instead of retry count + * - This is intentional: hedge race takes precedence as it indicates concurrent provider competition + * + * @param chain - Provider decision chain + * @returns Number of sequential retries (0 if hedge race detected) + */ +export function getRetryCount(chain: ProviderChainItem[]): number { + if (isHedgeRace(chain)) { + return 0; + } + + const actualRequests = chain.filter(isActualRequest); + return Math.max(0, actualRequests.length - 1); +} + /** * 辅助函数:翻译熔断状态 */ @@ -883,6 +976,17 @@ export function formatProviderTimeline( continue; } + // === Hedge 备选供应商启动 === + if (item.reason === "hedge_launched") { + timeline += `${t("timeline.hedgeLaunched")}\n\n`; + timeline += `${t("timeline.provider", { provider: item.name })}\n`; + timeline += `${t("timeline.attemptNumber", { number: actualAttemptNumber || item.attemptNumber || 0 })}\n`; + if (item.circuitState) { + timeline += `${t("timeline.circuitCurrent", { state: translateCircuitState(item.circuitState, t) })}\n`; + } + continue; + } + // 并发限制失败 if (item.reason === "concurrent_limit_failed") { timeline += `${t("timeline.attemptFailed", { attempt: actualAttemptNumber ?? 0 })}\n\n`; diff --git a/src/lib/utils/provider-display.ts b/src/lib/utils/provider-display.ts new file mode 100644 index 000000000..621b19502 --- /dev/null +++ b/src/lib/utils/provider-display.ts @@ -0,0 +1,21 @@ +/** + * Determine whether a request entry has been finalized. + * + * A request is considered finalized when: + * - It was blocked by a guard (blockedBy is set), OR + * - It has a non-empty providerChain (written at finalization time), OR + * - It has a statusCode (set when the response completes) + * + * Before finalization, provider info is unreliable because the upstream + * may change due to fallback, hedge, timeout, or fake-200 detection. + */ +export function isProviderFinalized(entry: { + providerChain?: unknown[] | null; + statusCode?: number | null; + blockedBy?: string | null; +}): boolean { + if (entry.blockedBy) return true; + if (Array.isArray(entry.providerChain) && entry.providerChain.length > 0) return true; + if (entry.statusCode != null) return true; + return false; +} diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index ffd33ada6..0b9e2e2e4 100644 --- a/src/lib/utils/special-settings.ts +++ b/src/lib/utils/special-settings.ts @@ -124,6 +124,8 @@ function buildSettingKey(setting: SpecialSetting): string { setting.actualServiceTier, setting.effectivePriority, ]); + case "response_input_rectifier": + return JSON.stringify([setting.type, setting.hit, setting.action, setting.originalType]); default: { // 兜底:保证即使未来扩展类型也不会导致运行时崩溃 const _exhaustive: never = setting; diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index f00b6d777..657634ca7 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -942,6 +942,8 @@ export const UpdateSystemSettingsSchema = z.object({ enableThinkingBudgetRectifier: z.boolean().optional(), // billing header 整流器(可选) enableBillingHeaderRectifier: z.boolean().optional(), + // Response API input 整流器(可选) + enableResponseInputRectifier: z.boolean().optional(), // Codex Session ID 补全(可选) enableCodexSessionIdCompletion: z.boolean().optional(), // Claude metadata.user_id 注入(可选) diff --git a/src/repository/_shared/transformers.test.ts b/src/repository/_shared/transformers.test.ts index c835d28fd..e622410f7 100644 --- a/src/repository/_shared/transformers.test.ts +++ b/src/repository/_shared/transformers.test.ts @@ -202,9 +202,9 @@ describe("src/repository/_shared/transformers.ts", () => { expect(result.maxRetryAttempts).toBe(3); expect(result.circuitBreakerFailureThreshold).toBe(5); expect(result.circuitBreakerOpenDuration).toBe(1800000); - expect(result.firstByteTimeoutStreamingMs).toBe(30000); - expect(result.streamingIdleTimeoutMs).toBe(10000); - expect(result.requestTimeoutNonStreamingMs).toBe(600000); + expect(result.firstByteTimeoutStreamingMs).toBe(0); + expect(result.streamingIdleTimeoutMs).toBe(0); + expect(result.requestTimeoutNonStreamingMs).toBe(0); expect(result.createdAt).toEqual(now); expect(result.updatedAt).toEqual(now); }); diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 0bc58987f..c11c465c5 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -1,3 +1,4 @@ +import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { Key } from "@/types/key"; import type { MessageRequest } from "@/types/message"; @@ -47,6 +48,7 @@ export function toUser(dbUser: any): User { dailyResetTime: dbUser?.dailyResetTime ?? "00:00", isEnabled: dbUser?.isEnabled ?? true, expiresAt: dbUser?.expiresAt ? new Date(dbUser.expiresAt) : null, + costResetAt: dbUser?.costResetAt ? new Date(dbUser.costResetAt) : null, allowedClients: dbUser?.allowedClients ?? [], blockedClients: dbUser?.blockedClients ?? [], allowedModels: dbUser?.allowedModels ?? [], @@ -116,9 +118,14 @@ export function toProvider(dbProvider: any): Provider { circuitBreakerHalfOpenSuccessThreshold: dbProvider?.circuitBreakerHalfOpenSuccessThreshold ?? 2, proxyUrl: dbProvider?.proxyUrl ?? null, proxyFallbackToDirect: dbProvider?.proxyFallbackToDirect ?? false, - firstByteTimeoutStreamingMs: dbProvider?.firstByteTimeoutStreamingMs ?? 30000, - streamingIdleTimeoutMs: dbProvider?.streamingIdleTimeoutMs ?? 10000, - requestTimeoutNonStreamingMs: dbProvider?.requestTimeoutNonStreamingMs ?? 600000, + firstByteTimeoutStreamingMs: + dbProvider?.firstByteTimeoutStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS, + streamingIdleTimeoutMs: + dbProvider?.streamingIdleTimeoutMs ?? PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS, + requestTimeoutNonStreamingMs: + dbProvider?.requestTimeoutNonStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS, websiteUrl: dbProvider?.websiteUrl ?? null, faviconUrl: dbProvider?.faviconUrl ?? null, cacheTtlPreference: dbProvider?.cacheTtlPreference ?? null, @@ -201,6 +208,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { enableThinkingSignatureRectifier: dbSettings?.enableThinkingSignatureRectifier ?? true, enableThinkingBudgetRectifier: dbSettings?.enableThinkingBudgetRectifier ?? true, enableBillingHeaderRectifier: dbSettings?.enableBillingHeaderRectifier ?? true, + enableResponseInputRectifier: dbSettings?.enableResponseInputRectifier ?? true, enableCodexSessionIdCompletion: dbSettings?.enableCodexSessionIdCompletion ?? true, enableClaudeMetadataUserIdInjection: dbSettings?.enableClaudeMetadataUserIdInjection ?? true, enableResponseFixer: dbSettings?.enableResponseFixer ?? true, diff --git a/src/repository/admin-user-insights.ts b/src/repository/admin-user-insights.ts new file mode 100644 index 000000000..bda743c72 --- /dev/null +++ b/src/repository/admin-user-insights.ts @@ -0,0 +1,141 @@ +"use server"; + +import { and, desc, eq, gte, lt, sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { providers, usageLedger } from "@/drizzle/schema"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; +import { getSystemSettings } from "./system-config"; + +export interface AdminUserModelBreakdownItem { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +/** + * Get model-level usage breakdown for a specific user. + * Groups by the billingModelSource-resolved model field and orders by cost DESC. + */ +export interface AdminUserProviderBreakdownItem { + providerId: number; + providerName: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +/** + * Get model-level usage breakdown for a specific user. + * Groups by the billingModelSource-resolved model field and orders by cost DESC. + */ +export async function getUserModelBreakdown( + userId: number, + startDate?: string, + endDate?: string, + filters?: { keyId?: number; providerId?: number } +): Promise { + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + + const rawModelField = + billingModelSource === "original" + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; + + const conditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.userId, userId)]; + + if (startDate) { + conditions.push(gte(usageLedger.createdAt, sql`${startDate}::date`)); + } + + if (endDate) { + conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); + } + + if (filters?.keyId) { + conditions.push( + sql`${usageLedger.key} = (SELECT k."key" FROM "keys" k WHERE k."id" = ${filters.keyId})` + ); + } + + if (filters?.providerId) { + conditions.push(eq(usageLedger.finalProviderId, filters.providerId)); + } + + const rows = await db + .select({ + model: modelField, + requests: sql`count(*)::int`, + cost: sql`COALESCE(sum(${usageLedger.costUsd})::double precision, 0)`, + inputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0)`, + outputTokens: sql`COALESCE(sum(${usageLedger.outputTokens})::double precision, 0)`, + cacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens})::double precision, 0)`, + cacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens})::double precision, 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(modelField) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); + + return rows; +} + +/** + * Get provider-level usage breakdown for a specific user. + * JOINs usageLedger with providers table and groups by provider. + */ +export async function getUserProviderBreakdown( + userId: number, + startDate?: string, + endDate?: string, + filters?: { keyId?: number; model?: string } +): Promise { + const conditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.userId, userId)]; + + if (startDate) { + conditions.push(gte(usageLedger.createdAt, sql`${startDate}::date`)); + } + + if (endDate) { + conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); + } + + if (filters?.keyId) { + conditions.push( + sql`${usageLedger.key} = (SELECT k."key" FROM "keys" k WHERE k."id" = ${filters.keyId})` + ); + } + + if (filters?.model) { + conditions.push( + sql`(${usageLedger.model} ILIKE ${filters.model} OR ${usageLedger.originalModel} ILIKE ${filters.model})` + ); + } + + const rows = await db + .select({ + providerId: providers.id, + providerName: providers.name, + requests: sql`count(*)::int`, + cost: sql`COALESCE(sum(${usageLedger.costUsd})::double precision, 0)`, + inputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0)`, + outputTokens: sql`COALESCE(sum(${usageLedger.outputTokens})::double precision, 0)`, + cacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens})::double precision, 0)`, + cacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens})::double precision, 0)`, + }) + .from(usageLedger) + .innerJoin(providers, eq(usageLedger.finalProviderId, providers.id)) + .where(and(...conditions)) + .groupBy(providers.id, providers.name) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); + + return rows; +} diff --git a/src/repository/key.ts b/src/repository/key.ts index 15f03abc7..62b0383e2 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -546,6 +546,7 @@ export async function validateApiKeyAndGetUser( limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -609,6 +610,7 @@ export async function validateApiKeyAndGetUser( userLimitWeeklyUsd: users.limitWeeklyUsd, userLimitMonthlyUsd: users.limitMonthlyUsd, userLimitTotalUsd: users.limitTotalUsd, + userCostResetAt: users.costResetAt, userLimitConcurrentSessions: users.limitConcurrentSessions, userDailyResetMode: users.dailyResetMode, userDailyResetTime: users.dailyResetTime, @@ -650,6 +652,7 @@ export async function validateApiKeyAndGetUser( limitWeeklyUsd: row.userLimitWeeklyUsd, limitMonthlyUsd: row.userLimitMonthlyUsd, limitTotalUsd: row.userLimitTotalUsd, + costResetAt: row.userCostResetAt, limitConcurrentSessions: row.userLimitConcurrentSessions, dailyResetMode: row.userDailyResetMode, dailyResetTime: row.userDailyResetTime, diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 38d1a7971..fe0cf81bc 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -13,12 +13,20 @@ const clampRatio01 = (value: number | null | undefined) => Math.min(Math.max(val /** * 排行榜条目类型 */ +export interface UserModelStat { + model: string | null; + totalRequests: number; + totalCost: number; + totalTokens: number; +} + export interface LeaderboardEntry { userId: number; userName: string; totalRequests: number; totalCost: number; totalTokens: number; + modelStats?: UserModelStat[]; } /** @@ -113,10 +121,11 @@ export interface ModelLeaderboardEntry { * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 */ export async function findDailyLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters); + return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters, includeModelStats); } /** @@ -124,10 +133,17 @@ export async function findDailyLeaderboard( * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 */ export async function findMonthlyLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("monthly", timezone, undefined, userFilters); + return findLeaderboardWithTimezone( + "monthly", + timezone, + undefined, + userFilters, + includeModelStats + ); } /** @@ -135,20 +151,28 @@ export async function findMonthlyLeaderboard( * 使用 SQL AT TIME ZONE 进行时区转换,确保"本周"基于系统时区 */ export async function findWeeklyLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters); + return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters, includeModelStats); } /** * 查询全部时间消耗排行榜(不限制数量) */ export async function findAllTimeLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("allTime", timezone, undefined, userFilters); + return findLeaderboardWithTimezone( + "allTime", + timezone, + undefined, + userFilters, + includeModelStats + ); } /** @@ -230,7 +254,8 @@ async function findLeaderboardWithTimezone( period: LeaderboardPeriod, timezone: string, dateRange?: DateRangeParams, - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const whereConditions = [ LEDGER_BILLING_CONDITION, @@ -284,13 +309,62 @@ async function findLeaderboardWithTimezone( .groupBy(usageLedger.userId, users.name) .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); - return rankings.map((entry) => ({ + const baseEntries: LeaderboardEntry[] = rankings.map((entry) => ({ userId: entry.userId, userName: entry.userName, totalRequests: entry.totalRequests, totalCost: parseFloat(entry.totalCost), totalTokens: entry.totalTokens, })); + + if (!includeModelStats) return baseEntries; + + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + const rawModelField = + billingModelSource === "original" + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; + + const modelRows = await db + .select({ + userId: usageLedger.userId, + model: modelField, + totalRequests: sql`count(*)::double precision`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + totalTokens: sql`COALESCE( + sum( + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) + )::double precision, + 0::double precision + )`, + }) + .from(usageLedger) + .innerJoin(users, and(sql`${usageLedger.userId} = ${users.id}`, isNull(users.deletedAt))) + .where(and(...whereConditions)) + .groupBy(usageLedger.userId, modelField) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); + + const modelStatsByUser = new Map(); + for (const row of modelRows) { + const stats = modelStatsByUser.get(row.userId) ?? []; + stats.push({ + model: row.model, + totalRequests: row.totalRequests, + totalCost: parseFloat(row.totalCost), + totalTokens: row.totalTokens, + }); + modelStatsByUser.set(row.userId, stats); + } + + return baseEntries.map((entry) => ({ + ...entry, + modelStats: modelStatsByUser.get(entry.userId) ?? [], + })); } /** @@ -298,10 +372,11 @@ async function findLeaderboardWithTimezone( */ export async function findCustomRangeLeaderboard( dateRange: DateRangeParams, - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters); + return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters, includeModelStats); } /** diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index f8ad21c67..0dfbb8ed9 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -193,21 +193,26 @@ export async function findAllLatestPricesPaginated( const total = Number(countResult.total); // 获取分页数据 + // 子查询: DISTINCT ON 要求 ORDER BY 首列与其一致,用于去重选出每个模型的最优记录 + // 外层: 按 updatedAt 降序排列,最近更新的模型排在前面 const dataQuery = sql` - SELECT DISTINCT ON (model_name) - id, - model_name as "modelName", - price_data as "priceData", - source, - created_at as "createdAt", - updated_at as "updatedAt" - FROM model_prices - ${whereCondition} - ORDER BY - model_name, - (source = 'manual') DESC, - created_at DESC NULLS LAST, - id DESC + SELECT * FROM ( + SELECT DISTINCT ON (model_name) + id, + model_name as "modelName", + price_data as "priceData", + source, + created_at as "createdAt", + updated_at as "updatedAt" + FROM model_prices + ${whereCondition} + ORDER BY + model_name, + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC + ) sub + ORDER BY sub."updatedAt" DESC NULLS LAST LIMIT ${pageSize} OFFSET ${offset} `; diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 5737fe272..7395fb345 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -4,6 +4,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from "drizzle-orm" import { db } from "@/drizzle/db"; import { providerEndpoints, providers } from "@/drizzle/schema"; import { getCachedProviders } from "@/lib/cache/provider-cache"; +import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; @@ -210,9 +211,14 @@ export async function createProvider(providerData: CreateProviderData): Promise< providerData.circuit_breaker_half_open_success_threshold ?? 2, proxyUrl: providerData.proxy_url ?? null, proxyFallbackToDirect: providerData.proxy_fallback_to_direct ?? false, - firstByteTimeoutStreamingMs: providerData.first_byte_timeout_streaming_ms ?? 30000, - streamingIdleTimeoutMs: providerData.streaming_idle_timeout_ms ?? 10000, - requestTimeoutNonStreamingMs: providerData.request_timeout_non_streaming_ms ?? 600000, + firstByteTimeoutStreamingMs: + providerData.first_byte_timeout_streaming_ms ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS, + streamingIdleTimeoutMs: + providerData.streaming_idle_timeout_ms ?? PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS, + requestTimeoutNonStreamingMs: + providerData.request_timeout_non_streaming_ms ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS, websiteUrl: providerData.website_url ?? null, faviconUrl: providerData.favicon_url ?? null, cacheTtlPreference: providerData.cache_ttl_preference ?? null, diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 7df64e5d0..ad3ed0d01 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -463,13 +463,24 @@ export async function sumUserCostToday(userId: number): Promise { * @param keyHash - API Key hash * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ -export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365): Promise { +export async function sumKeyTotalCost( + keyHash: string, + maxAgeDays: number = 365, + resetAt?: Date | null +): Promise { const conditions = [eq(usageLedger.key, keyHash), LEDGER_BILLING_CONDITION]; - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Use the more recent of resetAt and maxAgeDays cutoff + const maxAgeCutoff = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + let cutoff = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoff = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } + if (cutoff) { + conditions.push(gte(usageLedger.createdAt, cutoff)); } const result = await db @@ -485,13 +496,24 @@ export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365) * @param userId - User ID * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ -export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365): Promise { +export async function sumUserTotalCost( + userId: number, + maxAgeDays: number = 365, + resetAt?: Date | null +): Promise { const conditions = [eq(usageLedger.userId, userId), LEDGER_BILLING_CONDITION]; - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Use the more recent of resetAt and maxAgeDays cutoff + const maxAgeCutoff = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + let cutoff = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoff = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } + if (cutoff) { + conditions.push(gte(usageLedger.createdAt, cutoff)); } const result = await db @@ -510,28 +532,55 @@ export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365) */ export async function sumUserTotalCostBatch( userIds: number[], - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAtMap?: Map ): Promise> { const result = new Map(); if (userIds.length === 0) return result; + for (const id of userIds) result.set(id, 0); - const conditions: SQL[] = [inArray(usageLedger.userId, userIds), LEDGER_BILLING_CONDITION]; - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Split users: those with costResetAt need individual queries + const resetUserIds: number[] = []; + const batchUserIds: number[] = []; + for (const id of userIds) { + if (resetAtMap?.has(id)) { + resetUserIds.push(id); + } else { + batchUserIds.push(id); + } } - const rows = await db - .select({ - userId: usageLedger.userId, - total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, - }) - .from(usageLedger) - .where(and(...conditions)) - .groupBy(usageLedger.userId); + // Individual queries for users with costResetAt + if (resetUserIds.length > 0) { + const resetResults = await Promise.all( + resetUserIds.map(async (id) => ({ + id, + total: await sumUserTotalCost(id, maxAgeDays, resetAtMap!.get(id)), + })) + ); + for (const { id, total } of resetResults) result.set(id, total); + } + + // Batch query for users without costResetAt + if (batchUserIds.length > 0) { + const conditions: SQL[] = [inArray(usageLedger.userId, batchUserIds), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + + const rows = await db + .select({ + userId: usageLedger.userId, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(usageLedger.userId); + + for (const row of rows) result.set(row.userId, Number(row.total || 0)); + } - for (const id of userIds) result.set(id, 0); - for (const row of rows) result.set(row.userId, Number(row.total || 0)); return result; } @@ -545,7 +594,8 @@ export async function sumUserTotalCostBatch( */ export async function sumKeyTotalCostBatchByIds( keyIds: number[], - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAtMap?: Map ): Promise> { const result = new Map(); if (keyIds.length === 0) return result; @@ -558,29 +608,59 @@ export async function sumKeyTotalCostBatchByIds( .where(inArray(keys.id, keyIds)); const keyStringToId = new Map(keyMappings.map((k) => [k.key, k.id])); + const idToKeyString = new Map(keyMappings.map((k) => [k.id, k.key])); const keyStrings = keyMappings.map((k) => k.key); if (keyStrings.length === 0) return result; - // Step 2: Aggregate on usage_ledger directly (hits idx_usage_ledger_key_cost) - const conditions: SQL[] = [inArray(usageLedger.key, keyStrings), LEDGER_BILLING_CONDITION]; - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Split keys: those with costResetAt need individual queries + const resetKeyIds: number[] = []; + const batchKeyStrings: string[] = []; + for (const mapping of keyMappings) { + if (resetAtMap?.has(mapping.id)) { + resetKeyIds.push(mapping.id); + } else { + batchKeyStrings.push(mapping.key); + } } - const rows = await db - .select({ - key: usageLedger.key, - total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, - }) - .from(usageLedger) - .where(and(...conditions)) - .groupBy(usageLedger.key); + // Individual queries for keys with costResetAt + if (resetKeyIds.length > 0) { + const resetResults = await Promise.all( + resetKeyIds.map(async (id) => { + const keyString = idToKeyString.get(id); + if (!keyString) return { id, total: 0 }; + return { + id, + total: await sumKeyTotalCost(keyString, maxAgeDays, resetAtMap!.get(id)), + }; + }) + ); + for (const { id, total } of resetResults) result.set(id, total); + } - for (const row of rows) { - const keyId = keyStringToId.get(row.key); - if (keyId !== undefined) result.set(keyId, Number(row.total || 0)); + // Step 2: Batch aggregate for keys without costResetAt + if (batchKeyStrings.length > 0) { + const conditions: SQL[] = [inArray(usageLedger.key, batchKeyStrings), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + + const rows = await db + .select({ + key: usageLedger.key, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(usageLedger.key); + + for (const row of rows) { + const keyId = keyStringToId.get(row.key); + if (keyId !== undefined) result.set(keyId, Number(row.total || 0)); + } } + return result; } @@ -692,12 +772,18 @@ interface QuotaCostSummary { export async function sumUserQuotaCosts( userId: number, ranges: QuotaCostRanges, - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAt?: Date | null ): Promise { - const cutoffDate = + const maxAgeCutoff = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; + // Use the more recent of maxAgeCutoff and resetAt + let cutoffDate = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoffDate = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } const scanStart = cutoffDate ? new Date( @@ -757,17 +843,23 @@ export async function sumUserQuotaCosts( export async function sumKeyQuotaCostsById( keyId: number, ranges: QuotaCostRanges, - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAt?: Date | null ): Promise { const keyString = await getKeyStringByIdCached(keyId); if (!keyString) { return { cost5h: 0, costDaily: 0, costWeekly: 0, costMonthly: 0, costTotal: 0 }; } - const cutoffDate = + const maxAgeCutoff = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; + // Use the more recent of maxAgeCutoff and resetAt + let cutoffDate = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoffDate = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } const scanStart = cutoffDate ? new Date( diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 67e063492..e68459381 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -152,6 +152,7 @@ function createFallbackSettings(): SystemSettings { enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, enableBillingHeaderRectifier: true, + enableResponseInputRectifier: true, enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, @@ -196,6 +197,7 @@ export async function getSystemSettings(): Promise { enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, + enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, enableResponseFixer: systemSettings.enableResponseFixer, @@ -356,6 +358,11 @@ export async function updateSystemSettings( updates.enableBillingHeaderRectifier = payload.enableBillingHeaderRectifier; } + // Response API input 整流器开关(如果提供) + if (payload.enableResponseInputRectifier !== undefined) { + updates.enableResponseInputRectifier = payload.enableResponseInputRectifier; + } + // Codex Session ID 补全开关(如果提供) if (payload.enableCodexSessionIdCompletion !== undefined) { updates.enableCodexSessionIdCompletion = payload.enableCodexSessionIdCompletion; @@ -420,6 +427,8 @@ export async function updateSystemSettings( interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, + enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, + enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, enableResponseFixer: systemSettings.enableResponseFixer, diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 1cebc0251..7bf3d48c0 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -67,6 +67,7 @@ export interface UsageLogRow { context1mApplied: boolean | null; // 是否应用了1M上下文窗口 swapCacheTtlApplied: boolean | null; // 是否启用了swap cache TTL billing specialSettings: SpecialSetting[] | null; // 特殊设置(审计/展示) + _liveChain?: { chain: ProviderChainItem[]; phase: string; updatedAt: number } | null; } export interface UsageLogSummary { diff --git a/src/repository/user.ts b/src/repository/user.ts index d031e6d48..7a34d3cd7 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -79,6 +79,7 @@ export async function createUser(userData: CreateUserData): Promise { limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -112,6 +113,7 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -358,6 +360,7 @@ export async function findUserListBatch( limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -416,6 +419,7 @@ export async function findUserById(id: number): Promise { limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -511,6 +515,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -541,6 +546,19 @@ export async function deleteUser(id: number): Promise { return result.length > 0; } +export async function resetUserCostResetAt(userId: number, resetAt: Date | null): Promise { + const result = await db + .update(users) + .set({ costResetAt: resetAt, updatedAt: new Date() }) + .where(and(eq(users.id, userId), isNull(users.deletedAt))) + .returning({ id: users.id }); + + if (result.length > 0) { + await invalidateCachedUser(userId).catch(() => {}); + } + return result.length > 0; +} + /** * Mark an expired user as disabled (idempotent operation) * Only updates if the user is currently enabled diff --git a/src/types/message.ts b/src/types/message.ts index 21a6552a7..3ac6d136d 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -35,7 +35,12 @@ export interface ProviderChainItem { | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级) | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 - | "client_restriction_filtered"; // Provider skipped due to client restriction (neutral, no circuit breaker) + | "client_restriction_filtered" // Provider skipped due to client restriction (neutral, no circuit breaker) + | "hedge_triggered" // Hedge 计时器触发,启动备选供应商 + | "hedge_launched" // Hedge 备选供应商已启动(信息性记录,不算实际请求) + | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) + | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消 + | "client_abort"; // 客户端在响应完成前断开连接 // === 选择方法(细化) === selectionMethod?: diff --git a/src/types/model-price.ts b/src/types/model-price.ts index 59da132ba..9e1b86d2d 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -18,6 +18,9 @@ export interface ModelPriceData { cache_creation_input_token_cost_above_200k_tokens?: number; cache_read_input_token_cost_above_200k_tokens?: number; cache_creation_input_token_cost_above_1hr_above_200k_tokens?: number; + input_cost_per_token_above_200k_tokens_priority?: number; + output_cost_per_token_above_200k_tokens_priority?: number; + cache_read_input_token_cost_above_200k_tokens_priority?: number; // 272K 分层价格(GPT-5.4 等模型保留扩展) input_cost_per_token_above_272k_tokens?: number; @@ -25,6 +28,9 @@ export interface ModelPriceData { cache_creation_input_token_cost_above_272k_tokens?: number; cache_read_input_token_cost_above_272k_tokens?: number; cache_creation_input_token_cost_above_1hr_above_272k_tokens?: number; + input_cost_per_token_above_272k_tokens_priority?: number; + output_cost_per_token_above_272k_tokens_priority?: number; + cache_read_input_token_cost_above_272k_tokens_priority?: number; // 优先服务等级价格(例如 OpenAI priority tier) input_cost_per_token_priority?: number; diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index aae2ce19c..e8ad72cfa 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -18,7 +18,8 @@ export type SpecialSetting = | AnthropicContext1mHeaderOverrideSpecialSetting | GeminiGoogleSearchOverrideSpecialSetting | PricingResolutionSpecialSetting - | CodexServiceTierResultSpecialSetting; + | CodexServiceTierResultSpecialSetting + | ResponseInputRectifierSpecialSetting; export type SpecialSettingChangeValue = string | number | boolean | null; @@ -225,3 +226,17 @@ export type CodexServiceTierResultSpecialSetting = { actualServiceTier: string | null; effectivePriority: boolean; }; + +/** + * Response Input 整流器审计 + * + * 用于记录:当 /v1/responses 端点收到非数组格式的 input 时, + * 系统自动将其规范化为数组格式的行为,便于在请求日志中审计。 + */ +export type ResponseInputRectifierSpecialSetting = { + type: "response_input_rectifier"; + scope: "request"; + hit: boolean; + action: "string_to_array" | "object_to_array" | "empty_string_to_empty_array" | "passthrough"; + originalType: "string" | "object" | "array" | "other"; +}; diff --git a/src/types/system-config.ts b/src/types/system-config.ts index d45e4cf20..e6eda9888 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -58,6 +58,11 @@ export interface SystemSettings { // 防止 Amazon Bedrock 等非原生 Anthropic 上游返回 400 错误 enableBillingHeaderRectifier: boolean; + // Response API input 整流器(默认开启) + // 目标:当 /v1/responses 端点收到非数组 input(字符串或单对象)时, + // 自动规范化为数组格式,确保下游处理兼容 OpenAI 完整规范 + enableResponseInputRectifier: boolean; + // Codex Session ID 补全(默认开启) // 目标:当 Codex 请求缺少 session_id / prompt_cache_key 时,自动补全或生成稳定的会话标识 enableCodexSessionIdCompletion: boolean; @@ -123,6 +128,9 @@ export interface UpdateSystemSettingsInput { // billing header 整流器(可选) enableBillingHeaderRectifier?: boolean; + // Response API input 整流器(可选) + enableResponseInputRectifier?: boolean; + // Codex Session ID 补全(可选) enableCodexSessionIdCompletion?: boolean; diff --git a/src/types/user.ts b/src/types/user.ts index 7a1307c76..fd47995af 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -18,6 +18,7 @@ export interface User { limitWeeklyUsd?: number; // 周消费上限(美元) limitMonthlyUsd?: number; // 月消费上限(美元) limitTotalUsd?: number | null; // 总消费上限(美元) + costResetAt?: Date | null; // Cost reset timestamp for limits-only reset limitConcurrentSessions?: number; // 并发 Session 上限 // Daily quota reset mode dailyResetMode: "fixed" | "rolling"; // 每日限额重置模式 @@ -150,6 +151,7 @@ export interface UserDisplay { limitWeeklyUsd?: number | null; limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; + costResetAt?: Date | null; // Cost reset timestamp for limits-only reset limitConcurrentSessions?: number | null; // Daily quota reset mode dailyResetMode?: "fixed" | "rolling"; diff --git a/tests/integration/batch-edit-prefill.test.ts b/tests/integration/batch-edit-prefill.test.ts new file mode 100644 index 000000000..596690797 --- /dev/null +++ b/tests/integration/batch-edit-prefill.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderDisplay } from "@/types/provider"; +import { createInitialState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context"; + +describe("批量编辑预填充集成测试", () => { + it("应该在批量模式下预填充相同的设置值", () => { + const providers: ProviderDisplay[] = [ + { + id: 1, + name: "Provider A", + priority: 10, + weight: 5, + costMultiplier: 1.5, + modelRedirects: { "model-a": "model-b" }, + allowedModels: ["model-1", "model-2"], + limit5hUsd: 100, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 300000, // 5 minutes + } as ProviderDisplay, + { + id: 2, + name: "Provider B", + priority: 10, + weight: 5, + costMultiplier: 1.5, + modelRedirects: { "model-a": "model-b" }, + allowedModels: ["model-1", "model-2"], + limit5hUsd: 100, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 300000, + } as ProviderDisplay, + ]; + + const state = createInitialState("batch", undefined, undefined, undefined, providers); + + // 验证预填充的值 + expect(state.routing.priority).toBe(10); + expect(state.routing.weight).toBe(5); + expect(state.routing.costMultiplier).toBe(1.5); + expect(state.routing.modelRedirects).toEqual({ "model-a": "model-b" }); + expect(state.routing.allowedModels).toEqual(["model-1", "model-2"]); + expect(state.rateLimit.limit5hUsd).toBe(100); + expect(state.circuitBreaker.failureThreshold).toBe(5); + expect(state.circuitBreaker.openDurationMinutes).toBe(5); + }); + + it("应该在批量模式下对不同的设置值使用默认值", () => { + const providers: ProviderDisplay[] = [ + { + id: 1, + name: "Provider A", + priority: 10, + weight: 5, + } as ProviderDisplay, + { + id: 2, + name: "Provider B", + priority: 20, // 不同的值 + weight: 10, // 不同的值 + } as ProviderDisplay, + ]; + + const state = createInitialState("batch", undefined, undefined, undefined, providers); + + // 验证使用默认值 + expect(state.routing.priority).toBe(0); // 默认值 + expect(state.routing.weight).toBe(1); // 默认值 + }); + + it("应该在没有 batchProviders 时使用默认值", () => { + const state = createInitialState("batch"); + + // 验证所有字段都是默认值 + expect(state.routing.priority).toBe(0); + expect(state.routing.weight).toBe(1); + expect(state.routing.costMultiplier).toBe(1.0); + expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.allowedModels).toEqual([]); + expect(state.rateLimit.limit5hUsd).toBeNull(); + expect(state.circuitBreaker.failureThreshold).toBeUndefined(); + }); +}); diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index 396d73b6f..54d45a74e 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -562,6 +562,74 @@ describe("Billing model source - Redis session cost vs DB cost", () => { expect(dbCosts[0]).toBe("64"); }); + it("codex fast: uses long-context priority pricing when request is priority and response omits service_tier", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); + vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); + vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); + + vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "gpt-5.4") { + return makePriceRecord(modelName, { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 1, + output_cost_per_token: 10, + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + }, + }, + }); + } + return null; + }); + + const dbCosts: string[] = []; + vi.mocked(updateMessageRequestCost).mockImplementation( + async (_id: number, costUsd: unknown) => { + dbCosts.push(String(costUsd)); + } + ); + + const sessionCosts: string[] = []; + vi.mocked(SessionManager.updateSessionUsage).mockImplementation( + async (_sessionId: string, payload: Record) => { + if (typeof payload.costUsd === "string") { + sessionCosts.push(payload.costUsd); + } + } + ); + + const session = createSession({ + originalModel: "gpt-5.4", + redirectedModel: "gpt-5.4", + sessionId: "sess-gpt54-priority-requested-long-context", + messageId: 3203, + providerOverrides: { + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + providerType: "codex", + }, + requestMessage: { service_tier: "priority" }, + }); + + const response = createNonStreamResponse({ input_tokens: 272001, output_tokens: 2 }); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(dbCosts[0]).toBe("1904147"); + expect(sessionCosts[0]).toBe("1904147"); + }); + it("codex fast: does not use priority pricing when response explicitly reports non-priority tier", async () => { vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); diff --git a/tests/unit/actions/admin-user-insights.test.ts b/tests/unit/actions/admin-user-insights.test.ts new file mode 100644 index 000000000..7e3cf447e --- /dev/null +++ b/tests/unit/actions/admin-user-insights.test.ts @@ -0,0 +1,568 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetSession = vi.hoisted(() => vi.fn()); +const mockFindUserById = vi.hoisted(() => vi.fn()); +const mockGetOverviewWithCache = vi.hoisted(() => vi.fn()); +const mockGetStatisticsWithCache = vi.hoisted(() => vi.fn()); +const mockGetUserModelBreakdown = vi.hoisted(() => vi.fn()); +const mockGetUserProviderBreakdown = vi.hoisted(() => vi.fn()); +const mockGetSystemSettings = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/auth", () => ({ + getSession: mockGetSession, +})); + +vi.mock("@/repository/user", () => ({ + findUserById: mockFindUserById, +})); + +vi.mock("@/lib/redis/overview-cache", () => ({ + getOverviewWithCache: mockGetOverviewWithCache, +})); + +vi.mock("@/lib/redis/statistics-cache", () => ({ + getStatisticsWithCache: mockGetStatisticsWithCache, +})); + +vi.mock("@/repository/admin-user-insights", () => ({ + getUserModelBreakdown: mockGetUserModelBreakdown, + getUserProviderBreakdown: mockGetUserProviderBreakdown, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mockGetSystemSettings, +})); + +function createAdminSession() { + return { + user: { id: 1, name: "Admin", role: "admin" }, + key: { id: 1, key: "sk-admin" }, + }; +} + +function createUserSession() { + return { + user: { id: 2, name: "User", role: "user" }, + key: { id: 2, key: "sk-user" }, + }; +} + +function createMockUser() { + return { + id: 10, + name: "Target User", + description: "", + role: "user" as const, + rpm: null, + dailyQuota: null, + providerGroup: "default", + isEnabled: true, + expiresAt: null, + dailyResetMode: "fixed" as const, + dailyResetTime: "00:00", + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function createMockOverview() { + return { + todayRequests: 50, + todayCost: 5.5, + avgResponseTime: 200, + todayErrorRate: 2.0, + yesterdaySamePeriodRequests: 40, + yesterdaySamePeriodCost: 4.0, + yesterdaySamePeriodAvgResponseTime: 220, + recentMinuteRequests: 2, + }; +} + +function createMockSettings() { + return { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + enableBillingHeaderRectifier: true, + enableCodexSessionIdCompletion: true, + enableClaudeMetadataUserIdInjection: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 50, + maxFixSize: 1048576, + }, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function createMockBreakdown() { + return [ + { + model: "claude-sonnet-4-20250514", + requests: 30, + cost: 3.5, + inputTokens: 10000, + outputTokens: 5000, + cacheCreationTokens: 2000, + cacheReadTokens: 8000, + }, + { + model: "claude-opus-4-20250514", + requests: 20, + cost: 2.0, + inputTokens: 8000, + outputTokens: 3000, + cacheCreationTokens: 1000, + cacheReadTokens: 5000, + }, + ]; +} + +describe("getUserInsightsOverview", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockFindUserById).not.toHaveBeenCalled(); + }); + + it("returns unauthorized when not logged in", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + }); + + it("returns error for non-existent user", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockFindUserById.mockResolvedValueOnce(null); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(999); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("User not found"); + } + expect(mockFindUserById).toHaveBeenCalledWith(999); + }); + + it("returns overview data for valid admin request", async () => { + const user = createMockUser(); + const overview = createMockOverview(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockFindUserById.mockResolvedValueOnce(user); + mockGetOverviewWithCache.mockResolvedValueOnce(overview); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(10); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.user).toEqual(user); + expect(result.data.overview).toEqual(overview); + expect(result.data.currencyCode).toBe("USD"); + } + expect(mockFindUserById).toHaveBeenCalledWith(10); + expect(mockGetOverviewWithCache).toHaveBeenCalledWith(10); + }); +}); + +describe("getUserInsightsKeyTrend", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "today"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockGetStatisticsWithCache).not.toHaveBeenCalled(); + }); + + it("validates timeRange parameter", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "invalidRange"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Invalid timeRange"); + } + expect(mockGetStatisticsWithCache).not.toHaveBeenCalled(); + }); + + it("returns trend data for valid request", async () => { + const mockStats = [ + { key_id: 1, key_name: "sk-key-1", date: "2026-03-09", api_calls: 10, total_cost: 1.5 }, + { key_id: 2, key_name: "sk-key-2", date: "2026-03-08", api_calls: 15, total_cost: 2.0 }, + ]; + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetStatisticsWithCache.mockResolvedValueOnce(mockStats); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "7days"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(2); + expect(result.data[0].date).toBe("2026-03-09"); + expect(result.data[0].key_id).toBe(1); + expect(result.data[0].key_name).toBe("sk-key-1"); + expect(result.data[0].api_calls).toBe(10); + expect(result.data[1].date).toBe("2026-03-08"); + } + expect(mockGetStatisticsWithCache).toHaveBeenCalledWith("7days", "keys", 10); + }); + + it("normalizes Date objects to ISO strings", async () => { + const mockStats = [ + { + key_id: 1, + key_name: "sk-key-1", + date: new Date("2026-03-09T12:00:00Z"), + api_calls: 10, + total_cost: 1.5, + }, + ]; + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetStatisticsWithCache.mockResolvedValueOnce(mockStats); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "today"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(typeof result.data[0].date).toBe("string"); + expect(result.data[0].date).toContain("2026-03-09"); + } + }); + + it("accepts all valid timeRange values", async () => { + const validRanges = ["today", "7days", "30days", "thisMonth"]; + + for (const range of validRanges) { + vi.clearAllMocks(); + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetStatisticsWithCache.mockResolvedValueOnce([]); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, range); + + expect(result.ok).toBe(true); + } + }); +}); + +describe("getUserInsightsModelBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); + + it("returns breakdown data for valid request", async () => { + const breakdown = createMockBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.breakdown).toEqual(breakdown); + expect(result.data.currencyCode).toBe("USD"); + } + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, undefined, undefined, undefined); + }); + + it("passes date range to getUserModelBreakdown", async () => { + const breakdown = createMockBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "2026-03-09"); + + expect(result.ok).toBe(true); + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith( + 10, + "2026-03-01", + "2026-03-09", + undefined + ); + }); + + it("passes filter params to getUserModelBreakdown", async () => { + const breakdown = createMockBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const filters = { keyId: 5, providerId: 3 }; + const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "2026-03-09", filters); + + expect(result.ok).toBe(true); + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, "2026-03-01", "2026-03-09", filters); + }); + + it("rejects invalid startDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "not-a-date"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects invalid endDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "03/09/2026"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("endDate"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects startDate after endDate", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "2026-03-09", "2026-03-01"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate must not be after endDate"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); +}); + +function createMockProviderBreakdown() { + return [ + { + providerId: 1, + providerName: "Provider A", + requests: 40, + cost: 4.0, + inputTokens: 12000, + outputTokens: 6000, + cacheCreationTokens: 2500, + cacheReadTokens: 9000, + }, + { + providerId: 2, + providerName: "Provider B", + requests: 10, + cost: 1.5, + inputTokens: 6000, + outputTokens: 2000, + cacheCreationTokens: 500, + cacheReadTokens: 4000, + }, + ]; +} + +describe("getUserInsightsProviderBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); + + it("returns unauthorized when not logged in", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + }); + + it("returns breakdown data for valid request", async () => { + const breakdown = createMockProviderBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.breakdown).toEqual(breakdown); + expect(result.data.breakdown[0].providerName).toBe("Provider A"); + expect(result.data.currencyCode).toBe("USD"); + } + expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith(10, undefined, undefined, undefined); + }); + + it("passes date range to getUserProviderBreakdown", async () => { + const breakdown = createMockProviderBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "2026-03-09"); + + expect(result.ok).toBe(true); + expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith( + 10, + "2026-03-01", + "2026-03-09", + undefined + ); + }); + + it("passes filter params to getUserProviderBreakdown", async () => { + const breakdown = createMockProviderBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const filters = { keyId: 5, model: "claude-sonnet-4-20250514" }; + const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "2026-03-09", filters); + + expect(result.ok).toBe(true); + expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith( + 10, + "2026-03-01", + "2026-03-09", + filters + ); + }); + + it("rejects invalid startDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "not-a-date"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects invalid endDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "03/09/2026"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("endDate"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects startDate after endDate", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "2026-03-09", "2026-03-01"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate must not be after endDate"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/actions/key-quota-concurrent-inherit.test.ts b/tests/unit/actions/key-quota-concurrent-inherit.test.ts index f07d9f834..6c061270d 100644 --- a/tests/unit/actions/key-quota-concurrent-inherit.test.ts +++ b/tests/unit/actions/key-quota-concurrent-inherit.test.ts @@ -41,8 +41,10 @@ vi.mock("@/lib/rate-limit/time-utils", () => ({ })); const sumKeyCostInTimeRangeMock = vi.fn(async () => 0); +const sumKeyTotalCostMock = vi.fn(async () => 0); vi.mock("@/repository/statistics", () => ({ sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, + sumKeyTotalCost: sumKeyTotalCostMock, })); const limitMock = vi.fn(); @@ -59,8 +61,10 @@ vi.mock("@/drizzle/db", () => ({ vi.mock("@/lib/logger", () => ({ logger: { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), }, })); @@ -89,6 +93,7 @@ describe("getKeyQuotaUsage - concurrent limit inheritance", () => { limitConcurrentSessions: 0, }, userLimitConcurrentSessions: 15, + userCostResetAt: null, }, ]); diff --git a/tests/unit/actions/key-quota-cost-reset.test.ts b/tests/unit/actions/key-quota-cost-reset.test.ts new file mode 100644 index 000000000..d668a9dc3 --- /dev/null +++ b/tests/unit/actions/key-quota-cost-reset.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +// Mock getSession +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +// Mock next-intl +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(async () => (key: string) => key), + getLocale: vi.fn(async () => "en"), +})); + +// Mock getSystemSettings +const getSystemSettingsMock = vi.fn(); +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: getSystemSettingsMock, +})); + +// Mock statistics +const sumKeyCostInTimeRangeMock = vi.fn(); +const sumKeyTotalCostMock = vi.fn(); +vi.mock("@/repository/statistics", () => ({ + sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, + sumKeyTotalCost: sumKeyTotalCostMock, +})); + +// Mock time-utils +const getTimeRangeForPeriodWithModeMock = vi.fn(); +const getTimeRangeForPeriodMock = vi.fn(); +vi.mock("@/lib/rate-limit/time-utils", () => ({ + getTimeRangeForPeriodWithMode: getTimeRangeForPeriodWithModeMock, + getTimeRangeForPeriod: getTimeRangeForPeriodMock, +})); + +// Mock SessionTracker +const getKeySessionCountMock = vi.fn(); +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { getKeySessionCount: getKeySessionCountMock }, +})); + +// Mock resolveKeyConcurrentSessionLimit +vi.mock("@/lib/rate-limit/concurrent-session-limit", () => ({ + resolveKeyConcurrentSessionLimit: vi.fn(() => 0), +})); + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// Mock drizzle db - need select().from().leftJoin().where().limit() chain +const dbLimitMock = vi.fn(); +const dbWhereMock = vi.fn(() => ({ limit: dbLimitMock })); +const dbLeftJoinMock = vi.fn(() => ({ where: dbWhereMock })); +const dbFromMock = vi.fn(() => ({ leftJoin: dbLeftJoinMock })); +const dbSelectMock = vi.fn(() => ({ from: dbFromMock })); +vi.mock("@/drizzle/db", () => ({ + db: { select: dbSelectMock }, +})); + +// Common date fixtures +const NOW = new Date("2026-03-01T12:00:00Z"); +const FIVE_HOURS_AGO = new Date("2026-03-01T07:00:00Z"); +const DAILY_START = new Date("2026-03-01T00:00:00Z"); +const WEEKLY_START = new Date("2026-02-23T00:00:00Z"); +const MONTHLY_START = new Date("2026-02-01T00:00:00Z"); + +function makeTimeRange(startTime: Date, endTime: Date = NOW) { + return { startTime, endTime }; +} + +const DEFAULT_KEY_ROW = { + id: 42, + key: "sk-test-key-hash", + name: "Test Key", + userId: 10, + isEnabled: true, + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: "10.00", + limitDailyUsd: "20.00", + limitWeeklyUsd: "50.00", + limitMonthlyUsd: "100.00", + limitTotalUsd: "500.00", + limitConcurrentSessions: 0, + deletedAt: null, +}; + +function setupTimeRangeMocks() { + getTimeRangeForPeriodWithModeMock.mockResolvedValue(makeTimeRange(DAILY_START)); + getTimeRangeForPeriodMock.mockImplementation(async (period: string) => { + switch (period) { + case "5h": + return makeTimeRange(FIVE_HOURS_AGO); + case "weekly": + return makeTimeRange(WEEKLY_START); + case "monthly": + return makeTimeRange(MONTHLY_START); + default: + return makeTimeRange(DAILY_START); + } + }); +} + +function setupDefaultMocks(costResetAt: Date | null = null) { + getSessionMock.mockResolvedValue({ user: { id: 10, role: "user" } }); + getSystemSettingsMock.mockResolvedValue({ currencyDisplay: "USD" }); + dbLimitMock.mockResolvedValue([ + { + key: DEFAULT_KEY_ROW, + userLimitConcurrentSessions: null, + userCostResetAt: costResetAt, + }, + ]); + setupTimeRangeMocks(); + sumKeyCostInTimeRangeMock.mockResolvedValue(1.5); + sumKeyTotalCostMock.mockResolvedValue(10.0); + getKeySessionCountMock.mockResolvedValue(2); +} + +describe("getKeyQuotaUsage costResetAt clipping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("user with costResetAt -- period costs use clipped startTime", async () => { + // costResetAt is 2 hours ago -- should clip 5h range (7h ago) but not daily (midnight) + const costResetAt = new Date("2026-03-01T10:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledTimes(4); + // 1st call = 5h: clipped (07:00 < 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(1, 42, costResetAt, NOW); + // 2nd call = daily: clipped (00:00 < 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(2, 42, costResetAt, NOW); + // 3rd call = weekly: clipped (Feb 23 < Mar 1 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(3, 42, costResetAt, NOW); + // 4th call = monthly: clipped (Feb 1 < Mar 1 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(4, 42, costResetAt, NOW); + + // sumKeyTotalCost receives costResetAt as 3rd argument + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", Infinity, costResetAt); + }); + + test("user without costResetAt (null) -- original time ranges unchanged", async () => { + setupDefaultMocks(null); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // 5h: original start used (no clipping) + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, FIVE_HOURS_AGO, NOW); + // daily: original start + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, DAILY_START, NOW); + // weekly + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, WEEKLY_START, NOW); + // monthly + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, MONTHLY_START, NOW); + // total cost: null costResetAt + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", Infinity, null); + }); + + test("costResetAt older than all period starts -- no clipping effect", async () => { + // costResetAt is 1 year ago, older than even monthly start + const costResetAt = new Date("2025-01-01T00:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // clipStart returns original start because costResetAt < start + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, FIVE_HOURS_AGO, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, DAILY_START, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, WEEKLY_START, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, MONTHLY_START, NOW); + // total still receives costResetAt (sumKeyTotalCost handles it internally) + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", Infinity, costResetAt); + }); + + test("costResetAt in the middle of daily range -- clips daily correctly", async () => { + // costResetAt is 6AM today -- after daily start (midnight) but before now (noon) + const costResetAt = new Date("2026-03-01T06:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // Daily start (midnight) < costResetAt (6AM) => clipped + // Check the second call (daily) uses costResetAt + const calls = sumKeyCostInTimeRangeMock.mock.calls; + // 5h call: 7AM > 6AM => 5h start is AFTER costResetAt, so original 5h start used + expect(calls[0]).toEqual([42, FIVE_HOURS_AGO, NOW]); + // daily call: midnight < 6AM => clipped to costResetAt + expect(calls[1]).toEqual([42, costResetAt, NOW]); + // weekly: before costResetAt => clipped + expect(calls[2]).toEqual([42, costResetAt, NOW]); + // monthly: before costResetAt => clipped + expect(calls[3]).toEqual([42, costResetAt, NOW]); + }); + + test("permission denied for non-owner non-admin", async () => { + getSessionMock.mockResolvedValue({ user: { id: 99, role: "user" } }); + getSystemSettingsMock.mockResolvedValue({ currencyDisplay: "USD" }); + dbLimitMock.mockResolvedValue([ + { + key: { ...DEFAULT_KEY_ROW, userId: 10 }, + userLimitConcurrentSessions: null, + userCostResetAt: null, + }, + ]); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(sumKeyCostInTimeRangeMock).not.toHaveBeenCalled(); + }); + + test("key not found", async () => { + getSessionMock.mockResolvedValue({ user: { id: 10, role: "admin" } }); + dbLimitMock.mockResolvedValue([]); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(999); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); + }); +}); diff --git a/tests/unit/actions/my-usage-concurrent-inherit.test.ts b/tests/unit/actions/my-usage-concurrent-inherit.test.ts index e1aabddc8..adb4b614d 100644 --- a/tests/unit/actions/my-usage-concurrent-inherit.test.ts +++ b/tests/unit/actions/my-usage-concurrent-inherit.test.ts @@ -57,8 +57,10 @@ vi.mock("@/drizzle/db", () => ({ vi.mock("@/lib/logger", () => ({ logger: { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), }, })); diff --git a/tests/unit/actions/total-usage-semantics.test.ts b/tests/unit/actions/total-usage-semantics.test.ts index 7908f4db6..7cd5c924f 100644 --- a/tests/unit/actions/total-usage-semantics.test.ts +++ b/tests/unit/actions/total-usage-semantics.test.ts @@ -145,7 +145,8 @@ describe("total-usage-semantics", () => { expect(sumKeyQuotaCostsByIdMock).toHaveBeenCalledWith( 1, expect.any(Object), - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + null ); }); @@ -195,7 +196,8 @@ describe("total-usage-semantics", () => { expect(sumUserQuotaCostsMock).toHaveBeenCalledWith( 1, expect.any(Object), - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + null ); }); }); @@ -228,7 +230,11 @@ describe("total-usage-semantics", () => { await getUserAllLimitUsage(1); // Verify sumUserTotalCost was called with Infinity (all-time) - expect(sumUserTotalCostMock).toHaveBeenCalledWith(1, Infinity); + // 3rd arg is user.costResetAt (undefined when not set on mock user) + const calls = sumUserTotalCostMock.mock.calls; + expect(calls.length).toBe(1); + expect(calls[0][0]).toBe(1); + expect(calls[0][1]).toBe(Infinity); }); }); @@ -237,38 +243,4 @@ describe("total-usage-semantics", () => { expect(ALL_TIME_MAX_AGE_DAYS).toBe(Infinity); }); }); - - describe("source code verification", () => { - it("should verify sumUserCost passes ALL_TIME_MAX_AGE_DAYS when period is total", async () => { - // This test verifies the implementation by reading the source code pattern - // Ensure we call quota aggregation functions with ALL_TIME_MAX_AGE_DAYS for all-time usage. - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - - const myUsagePath = path.join(process.cwd(), "src/actions/my-usage.ts"); - const content = await fs.readFile(myUsagePath, "utf-8"); - - // Verify the constant is defined as Infinity - expect(content).toContain("const ALL_TIME_MAX_AGE_DAYS = Infinity"); - - // Verify quota aggregation uses the constant for all-time usage - expect(content).toMatch(/sumUserQuotaCosts\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); - - expect(content).toMatch(/sumKeyQuotaCostsById\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); - }); - - it("should verify getUserAllLimitUsage passes ALL_TIME_MAX_AGE_DAYS", async () => { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - - const usersPath = path.join(process.cwd(), "src/actions/users.ts"); - const content = await fs.readFile(usersPath, "utf-8"); - - // Verify the constant is defined as Infinity - expect(content).toContain("const ALL_TIME_MAX_AGE_DAYS = Infinity"); - - // Verify sumUserTotalCost is called with the constant - expect(content).toContain("sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS)"); - }); - }); }); diff --git a/tests/unit/actions/users-reset-all-statistics.test.ts b/tests/unit/actions/users-reset-all-statistics.test.ts index 5a6d0a5ae..60937f793 100644 --- a/tests/unit/actions/users-reset-all-statistics.test.ts +++ b/tests/unit/actions/users-reset-all-statistics.test.ts @@ -22,11 +22,13 @@ vi.mock("next/cache", () => ({ // Mock repository/user const findUserByIdMock = vi.fn(); +const resetUserCostResetAtMock = vi.fn(); vi.mock("@/repository/user", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, findUserById: findUserByIdMock, + resetUserCostResetAt: resetUserCostResetAtMock, }; }); @@ -41,11 +43,20 @@ vi.mock("@/repository/key", async (importOriginal) => { }); // Mock drizzle db -const dbDeleteWhereMock = vi.fn(); -const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock })); +const txDeleteWhereMock = vi.fn(); +const txDeleteMock = vi.fn(() => ({ where: txDeleteWhereMock })); +const txUpdateSetMock = vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })); +const txUpdateMock = vi.fn(() => ({ set: txUpdateSetMock })); +const txMock = { + delete: txDeleteMock, + update: txUpdateMock, +}; +const dbTransactionMock = vi.fn(async (fn: (tx: typeof txMock) => Promise) => { + await fn(txMock); +}); vi.mock("@/drizzle/db", () => ({ db: { - delete: dbDeleteMock, + transaction: dbTransactionMock, }, })); @@ -59,6 +70,12 @@ vi.mock("@/lib/logger", () => ({ logger: loggerMock, })); +// Mock invalidateCachedUser (called directly after transaction) +const invalidateCachedUserMock = vi.fn(); +vi.mock("@/lib/security/api-key-auth-cache", () => ({ + invalidateCachedUser: invalidateCachedUserMock, +})); + // Mock Redis const redisPipelineMock = { del: vi.fn().mockReturnThis(), @@ -86,7 +103,9 @@ describe("resetUserAllStatistics", () => { redisMock.status = "ready"; redisPipelineMock.exec.mockResolvedValue([]); // DB delete returns resolved promise - dbDeleteWhereMock.mockResolvedValue(undefined); + txDeleteWhereMock.mockResolvedValue(undefined); + resetUserCostResetAtMock.mockResolvedValue(true); + invalidateCachedUserMock.mockResolvedValue(undefined); }); test("should return PERMISSION_DENIED for non-admin user", async () => { @@ -119,7 +138,7 @@ describe("resetUserAllStatistics", () => { expect(result.ok).toBe(false); expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); - expect(dbDeleteMock).not.toHaveBeenCalled(); + expect(dbTransactionMock).not.toHaveBeenCalled(); }); test("should successfully reset all user statistics", async () => { @@ -133,9 +152,10 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); - // DB delete called - expect(dbDeleteMock).toHaveBeenCalled(); - expect(dbDeleteWhereMock).toHaveBeenCalled(); + // DB transaction called (delete + update wrapped in transaction) + expect(dbTransactionMock).toHaveBeenCalled(); + expect(txDeleteMock).toHaveBeenCalled(); + expect(txDeleteWhereMock).toHaveBeenCalled(); // Redis operations expect(redisMock.pipeline).toHaveBeenCalled(); expect(redisPipelineMock.del).toHaveBeenCalled(); @@ -156,8 +176,8 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); - // DB delete still called - expect(dbDeleteMock).toHaveBeenCalled(); + // DB transaction still called + expect(dbTransactionMock).toHaveBeenCalled(); // Redis pipeline NOT called (status not ready) expect(redisMock.pipeline).not.toHaveBeenCalled(); }); @@ -177,8 +197,9 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); + // Pipeline partial failures logged as warn inside clearUserCostCache expect(loggerMock.warn).toHaveBeenCalledWith( - "Some Redis deletes failed during user statistics reset", + "Some Redis deletes failed during cost cache cleanup", expect.objectContaining({ errorCount: 1, userId: 123 }) ); }); @@ -199,21 +220,21 @@ describe("resetUserAllStatistics", () => { expect(loggerMock.warn).toHaveBeenCalled(); }); - test("should succeed with error log when pipeline.exec throws", async () => { + test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); findKeyListMock.mockResolvedValue([{ id: 1 }]); scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); - // pipeline.exec throws - caught by outer try-catch + // pipeline.exec throws - caught inside clearUserCostCache (never-throws contract) redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed")); const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - // Should still succeed - DB logs already deleted + // clearUserCostCache catches pipeline.exec throw internally, logs warn expect(result.ok).toBe(true); - expect(loggerMock.error).toHaveBeenCalledWith( - "Failed to clear Redis cache during user statistics reset", + expect(loggerMock.warn).toHaveBeenCalledWith( + "Redis pipeline.exec() failed during cost cache cleanup", expect.objectContaining({ userId: 123 }) ); }); @@ -241,6 +262,6 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); - expect(dbDeleteMock).toHaveBeenCalled(); + expect(dbTransactionMock).toHaveBeenCalled(); }); }); diff --git a/tests/unit/actions/users-reset-limits-only.test.ts b/tests/unit/actions/users-reset-limits-only.test.ts new file mode 100644 index 000000000..326848bbe --- /dev/null +++ b/tests/unit/actions/users-reset-limits-only.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +// Mock getSession +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +// Mock next-intl +const getTranslationsMock = vi.fn(async () => (key: string) => key); +vi.mock("next-intl/server", () => ({ + getTranslations: getTranslationsMock, + getLocale: vi.fn(async () => "en"), +})); + +// Mock next/cache +const revalidatePathMock = vi.fn(); +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +// Mock repository/user +const findUserByIdMock = vi.fn(); +const resetUserCostResetAtMock = vi.fn(); +vi.mock("@/repository/user", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findUserById: findUserByIdMock, + resetUserCostResetAt: resetUserCostResetAtMock, + }; +}); + +// Mock repository/key +const findKeyListMock = vi.fn(); +vi.mock("@/repository/key", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findKeyList: findKeyListMock, + }; +}); + +// Mock drizzle db - need update().set().where() chain +const dbUpdateWhereMock = vi.fn(); +const dbUpdateSetMock = vi.fn(() => ({ where: dbUpdateWhereMock })); +const dbUpdateMock = vi.fn(() => ({ set: dbUpdateSetMock })); +const dbDeleteWhereMock = vi.fn(); +const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock })); +vi.mock("@/drizzle/db", () => ({ + db: { + update: dbUpdateMock, + delete: dbDeleteMock, + }, +})); + +// Mock logger +const loggerMock = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +// Mock Redis +const redisPipelineMock = { + del: vi.fn().mockReturnThis(), + exec: vi.fn(), +}; +const redisMock = { + status: "ready", + pipeline: vi.fn(() => redisPipelineMock), +}; +const getRedisClientMock = vi.fn(() => redisMock); +vi.mock("@/lib/redis", () => ({ + getRedisClient: getRedisClientMock, +})); + +// Mock scanPattern +const scanPatternMock = vi.fn(); +vi.mock("@/lib/redis/scan-helper", () => ({ + scanPattern: scanPatternMock, +})); + +describe("resetUserLimitsOnly", () => { + beforeEach(() => { + vi.clearAllMocks(); + redisMock.status = "ready"; + redisPipelineMock.exec.mockResolvedValue([]); + dbUpdateWhereMock.mockResolvedValue(undefined); + resetUserCostResetAtMock.mockResolvedValue(true); + }); + + test("should return PERMISSION_DENIED for non-admin user", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } }); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(findUserByIdMock).not.toHaveBeenCalled(); + }); + + test("should return PERMISSION_DENIED when no session", async () => { + // TODO(#890): Consider returning UNAUTHORIZED for null session (current: PERMISSION_DENIED for both null + non-admin) + getSessionMock.mockResolvedValue(null); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + }); + + test("should return NOT_FOUND for non-existent user", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue(null); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(999); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); + expect(resetUserCostResetAtMock).not.toHaveBeenCalled(); + }); + + test("should set costResetAt and clear Redis cost cache", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([ + { id: 1, key: "sk-hash-1" }, + { id: 2, key: "sk-hash-2" }, + ]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily", "user:123:cost_weekly"]); + redisPipelineMock.exec.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + // costResetAt set via repository function + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); + // Redis cost keys scanned and deleted + expect(scanPatternMock).toHaveBeenCalled(); + expect(redisMock.pipeline).toHaveBeenCalled(); + expect(redisPipelineMock.del).toHaveBeenCalled(); + expect(redisPipelineMock.exec).toHaveBeenCalled(); + // Revalidate path + expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users"); + // No DB deletes (messageRequest/usageLedger must NOT be deleted) + expect(dbDeleteMock).not.toHaveBeenCalled(); + }); + + test("should NOT delete messageRequest or usageLedger rows", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + await resetUserLimitsOnly(123); + + // Core assertion: db.delete must never be called + expect(dbDeleteMock).not.toHaveBeenCalled(); + expect(dbDeleteWhereMock).not.toHaveBeenCalled(); + }); + + test("should succeed when Redis is not ready", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + redisMock.status = "connecting"; + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + // costResetAt still set via repo function + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); + // Redis pipeline NOT called + expect(redisMock.pipeline).not.toHaveBeenCalled(); + }); + + test("should succeed with warning when Redis has partial failures", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [new Error("Connection reset"), null], + ]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Some Redis deletes failed during cost cache cleanup", + expect.objectContaining({ errorCount: 1, userId: 123 }) + ); + }); + + test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); + redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed")); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + // pipeline.exec throw is now caught inside clearUserCostCache (never-throws contract) + // so resetUserLimitsOnly still succeeds without hitting its own catch block + expect(result.ok).toBe(true); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Redis pipeline.exec() failed during cost cache cleanup", + expect.objectContaining({ userId: 123 }) + ); + }); + + test("should return OPERATION_FAILED on unexpected error", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockRejectedValue(new Error("Database connection failed")); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + test("should handle user with no keys", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([]); + scanPatternMock.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); + // No DB deletes + expect(dbDeleteMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index 255018760..c358e93bd 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -287,4 +287,69 @@ describe("GET /api/leaderboard", () => { expect(body[0].modelStats).toHaveLength(0); }); }); + + describe("user scope includeUserModelStats", () => { + it("admin + includeUserModelStats=1 returns 200 with correct cache call and private headers", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "admin", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + userId: 1, + userName: "user-a", + totalRequests: 100, + totalCost: 5.0, + totalTokens: 1000, + modelStats: [ + { model: "claude-3-opus", totalRequests: 60, totalCost: 3.0, totalTokens: 600 }, + { model: null, totalRequests: 40, totalCost: 2.0, totalTokens: 400 }, + ], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + + const options = mocks.getLeaderboardWithCache.mock.calls[0][4]; + expect(options.includeModelStats).toBe(true); + + expect(body[0].modelStats).toHaveLength(2); + expect(body[0].modelStats[0]).toHaveProperty("totalCostFormatted"); + expect(body[0].modelStats[1].model).toBeNull(); + }); + + it("non-admin + includeUserModelStats=1 returns 403", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 2, name: "user", role: "user" } }); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED"); + }); + + it("non-admin with allowGlobalUsageView + includeUserModelStats=1 returns 403", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 2, name: "user", role: "user" } }); + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + allowGlobalUsageView: true, + }); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED"); + }); + }); }); diff --git a/tests/unit/auth/admin-token-opaque-fallback.test.ts b/tests/unit/auth/admin-token-opaque-fallback.test.ts new file mode 100644 index 000000000..da91dfbf6 --- /dev/null +++ b/tests/unit/auth/admin-token-opaque-fallback.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted mocks +const mockCookies = vi.hoisted(() => vi.fn()); +const mockHeaders = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockValidateApiKeyAndGetUser = vi.hoisted(() => vi.fn()); +const mockFindKeyList = vi.hoisted(() => vi.fn()); +const mockReadSession = vi.hoisted(() => vi.fn()); +const mockCookieStore = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +})); +const mockHeadersStore = vi.hoisted(() => ({ + get: vi.fn(), +})); +const mockConfig = vi.hoisted(() => ({ + auth: { adminToken: "test-admin-secret-token-12345" }, +})); + +vi.mock("next/headers", () => ({ + cookies: mockCookies, + headers: mockHeaders, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/repository/key", () => ({ + validateApiKeyAndGetUser: mockValidateApiKeyAndGetUser, + findKeyList: mockFindKeyList, +})); + +vi.mock("@/lib/auth-session-store/redis-session-store", () => ({ + RedisSessionStore: class { + read = mockReadSession; + create = vi.fn(); + revoke = vi.fn(); + rotate = vi.fn(); + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("@/lib/config/config", () => ({ + config: mockConfig, +})); + +function setSessionMode(mode: "legacy" | "dual" | "opaque") { + mockGetEnvConfig.mockReturnValue({ + SESSION_TOKEN_MODE: mode, + ENABLE_SECURE_COOKIES: false, + }); +} + +function setAuthCookie(token?: string) { + mockCookieStore.get.mockReturnValue(token ? { value: token } : undefined); +} + +function setBearerHeader(token?: string) { + mockHeadersStore.get.mockReturnValue(token ? `Bearer ${token}` : null); +} + +describe("admin token opaque-mode fallback", () => { + const ADMIN_TOKEN = "test-admin-secret-token-12345"; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + mockCookies.mockResolvedValue(mockCookieStore); + mockHeaders.mockResolvedValue(mockHeadersStore); + mockHeadersStore.get.mockReturnValue(null); + mockCookieStore.get.mockReturnValue(undefined); + + setSessionMode("opaque"); + mockReadSession.mockResolvedValue(null); + mockFindKeyList.mockResolvedValue([]); + mockValidateApiKeyAndGetUser.mockResolvedValue(null); + mockConfig.auth.adminToken = ADMIN_TOKEN; + }); + + it("opaque mode + raw admin token via cookie -> auth succeeds", async () => { + setAuthCookie(ADMIN_TOKEN); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).not.toBeNull(); + expect(session!.user.id).toBe(-1); + expect(session!.user.role).toBe("admin"); + expect(session!.key.name).toBe("ADMIN_TOKEN"); + }); + + it("opaque mode + raw non-admin API key via cookie -> auth fails", async () => { + setAuthCookie("sk-regular-user-key"); + // Even if this key is valid in DB, opaque mode must reject raw keys + mockValidateApiKeyAndGetUser.mockResolvedValue({ + user: { id: 1, name: "user", role: "user", isEnabled: true }, + key: { + id: 1, + userId: 1, + name: "key-1", + key: "sk-regular-user-key", + isEnabled: true, + canLoginWebUi: true, + }, + }); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).toBeNull(); + // Must NOT fall back to validateApiKeyAndGetUser for non-admin keys + expect(mockValidateApiKeyAndGetUser).not.toHaveBeenCalled(); + }); + + it("opaque mode + admin token via Bearer header -> auth succeeds", async () => { + // No cookie set; use Authorization header instead + setBearerHeader(ADMIN_TOKEN); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).not.toBeNull(); + expect(session!.user.id).toBe(-1); + expect(session!.user.role).toBe("admin"); + expect(session!.key.name).toBe("ADMIN_TOKEN"); + }); + + it("opaque mode + valid opaque session -> auth succeeds (original logic unchanged)", async () => { + const crypto = await import("node:crypto"); + const keyString = "sk-opaque-source-key"; + const fingerprint = `sha256:${crypto.createHash("sha256").update(keyString, "utf8").digest("hex")}`; + + setAuthCookie("sid_valid_session"); + mockReadSession.mockResolvedValue({ + sessionId: "sid_valid_session", + keyFingerprint: fingerprint, + userId: 42, + userRole: "user", + createdAt: Date.now() - 1000, + expiresAt: Date.now() + 86400_000, + }); + mockFindKeyList.mockResolvedValue([ + { + id: 1, + userId: 42, + name: "key-1", + key: keyString, + isEnabled: true, + canLoginWebUi: true, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + providerGroup: null, + cacheTtlPreference: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + mockValidateApiKeyAndGetUser.mockResolvedValue({ + user: { + id: 42, + name: "user-42", + description: "test", + role: "user", + rpm: 100, + dailyQuota: 100, + providerGroup: null, + tags: [], + isEnabled: true, + expiresAt: null, + allowedClients: [], + allowedModels: [], + limit5hUsd: 0, + limitWeeklyUsd: 0, + limitMonthlyUsd: 0, + limitTotalUsd: null, + limitConcurrentSessions: 0, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + createdAt: new Date(), + updatedAt: new Date(), + }, + key: { + id: 1, + userId: 42, + name: "key-1", + key: keyString, + isEnabled: true, + canLoginWebUi: true, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + providerGroup: null, + cacheTtlPreference: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession({ allowReadOnlyAccess: true }); + + expect(session).not.toBeNull(); + expect(session!.user.id).toBe(42); + }); + + it("legacy mode -> behavior unchanged (admin token works via validateKey)", async () => { + setSessionMode("legacy"); + setAuthCookie(ADMIN_TOKEN); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).not.toBeNull(); + expect(session!.user.id).toBe(-1); + expect(session!.user.role).toBe("admin"); + // Legacy mode should NOT touch opaque session store + expect(mockReadSession).not.toHaveBeenCalled(); + }); + + it("opaque mode + admin token not configured -> auth fails for raw token", async () => { + mockConfig.auth.adminToken = ""; + setAuthCookie("some-random-token"); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).toBeNull(); + }); +}); diff --git a/tests/unit/batch-edit/analyze-batch-settings.test.ts b/tests/unit/batch-edit/analyze-batch-settings.test.ts new file mode 100644 index 000000000..e4883a7ea --- /dev/null +++ b/tests/unit/batch-edit/analyze-batch-settings.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderDisplay } from "@/types/provider"; +import { analyzeBatchProviderSettings } from "@/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings"; + +describe("analyzeBatchProviderSettings", () => { + describe("空列表", () => { + it("应该返回所有字段为 empty 状态", () => { + const result = analyzeBatchProviderSettings([]); + + expect(result.routing.priority.status).toBe("empty"); + expect(result.routing.weight.status).toBe("empty"); + expect(result.rateLimit.limit5hUsd.status).toBe("empty"); + }); + }); + + describe("uniform 值", () => { + it("应该识别所有供应商有相同的基本类型值", () => { + const providers: ProviderDisplay[] = [ + { priority: 10, weight: 5, costMultiplier: 1.5 } as ProviderDisplay, + { priority: 10, weight: 5, costMultiplier: 1.5 } as ProviderDisplay, + { priority: 10, weight: 5, costMultiplier: 1.5 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.priority).toEqual({ status: "uniform", value: 10 }); + expect(result.routing.weight).toEqual({ status: "uniform", value: 5 }); + expect(result.routing.costMultiplier).toEqual({ status: "uniform", value: 1.5 }); + }); + + it("应该识别所有供应商有相同的对象值", () => { + const providers: ProviderDisplay[] = [ + { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, + { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.modelRedirects).toEqual({ + status: "uniform", + value: { "model-a": "model-b" }, + }); + }); + + it("应该识别所有供应商有相同的数组值", () => { + const providers: ProviderDisplay[] = [ + { allowedModels: ["model-1", "model-2"] } as ProviderDisplay, + { allowedModels: ["model-1", "model-2"] } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.allowedModels).toEqual({ + status: "uniform", + value: ["model-1", "model-2"], + }); + }); + + it("应该识别所有供应商都为 null 的字段", () => { + const providers: ProviderDisplay[] = [ + { limit5hUsd: null } as ProviderDisplay, + { limit5hUsd: null } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.rateLimit.limit5hUsd.status).toBe("empty"); + }); + }); + + describe("mixed 值", () => { + it("应该识别供应商有不同的基本类型值", () => { + const providers: ProviderDisplay[] = [ + { priority: 10 } as ProviderDisplay, + { priority: 20 } as ProviderDisplay, + { priority: 30 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.priority.status).toBe("mixed"); + if (result.routing.priority.status === "mixed") { + expect(result.routing.priority.values).toEqual([10, 20, 30]); + } + }); + + it("应该识别供应商有不同的对象值", () => { + const providers: ProviderDisplay[] = [ + { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, + { modelRedirects: { "model-c": "model-d" } } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.modelRedirects.status).toBe("mixed"); + if (result.routing.modelRedirects.status === "mixed") { + expect(result.routing.modelRedirects.values).toEqual([ + { "model-a": "model-b" }, + { "model-c": "model-d" }, + ]); + } + }); + + it("应该去重 mixed 值", () => { + const providers: ProviderDisplay[] = [ + { priority: 10 } as ProviderDisplay, + { priority: 20 } as ProviderDisplay, + { priority: 10 } as ProviderDisplay, // 重复 + { priority: 20 } as ProviderDisplay, // 重复 + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.priority.status).toBe("mixed"); + if (result.routing.priority.status === "mixed") { + expect(result.routing.priority.values).toEqual([10, 20]); + } + }); + }); + + describe("复杂字段", () => { + it("应该正确处理 groupTag 字段(字符串转数组)", () => { + const providers: ProviderDisplay[] = [ + { groupTag: "tag1, tag2" } as ProviderDisplay, + { groupTag: "tag1, tag2" } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.groupTag).toEqual({ + status: "uniform", + value: ["tag1", "tag2"], + }); + }); + + it("应该正确处理空 groupTag", () => { + const providers: ProviderDisplay[] = [ + { groupTag: null } as ProviderDisplay, + { groupTag: "" } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.groupTag).toEqual({ + status: "uniform", + value: [], + }); + }); + + it("应该正确处理 circuitBreaker 时间单位转换(ms -> minutes)", () => { + const providers: ProviderDisplay[] = [ + { circuitBreakerOpenDuration: 300000 } as ProviderDisplay, // 5 分钟 + { circuitBreakerOpenDuration: 300000 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.circuitBreaker.openDurationMinutes).toEqual({ + status: "uniform", + value: 5, + }); + }); + + it("应该正确处理 network 时间单位转换(ms -> seconds)", () => { + const providers: ProviderDisplay[] = [ + { firstByteTimeoutStreamingMs: 30000 } as ProviderDisplay, // 30 秒 + { firstByteTimeoutStreamingMs: 30000 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.network.firstByteTimeoutStreamingSeconds).toEqual({ + status: "uniform", + value: 30, + }); + }); + + it("应该正确处理 anthropicAdaptiveThinking 复杂对象", () => { + const config = { + effort: "high" as const, + modelMatchMode: "specific" as const, + models: ["claude-opus-4-6"], + }; + + const providers: ProviderDisplay[] = [ + { anthropicAdaptiveThinking: config } as ProviderDisplay, + { anthropicAdaptiveThinking: config } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.anthropicAdaptiveThinking).toEqual({ + status: "uniform", + value: config, + }); + }); + }); + + describe("默认值处理", () => { + it("应该为未设置的字段使用默认值", () => { + const providers: ProviderDisplay[] = [ + { + preserveClientIp: false, + cacheTtlPreference: "inherit", + dailyResetMode: "fixed", + } as ProviderDisplay, + { + preserveClientIp: false, + cacheTtlPreference: "inherit", + dailyResetMode: "fixed", + } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + // 检查一些有默认值的字段 + expect(result.routing.cacheTtlPreference).toEqual({ + status: "uniform", + value: "inherit", + }); + expect(result.routing.preserveClientIp).toEqual({ + status: "uniform", + value: false, + }); + expect(result.rateLimit.dailyResetMode).toEqual({ + status: "uniform", + value: "fixed", + }); + }); + }); +}); diff --git a/tests/unit/batch-edit/deep-equals.test.ts b/tests/unit/batch-edit/deep-equals.test.ts new file mode 100644 index 000000000..99a859351 --- /dev/null +++ b/tests/unit/batch-edit/deep-equals.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { deepEquals } from "@/app/[locale]/settings/providers/_components/batch-edit/deep-equals"; + +describe("deepEquals", () => { + describe("基本类型", () => { + it("应该正确比较相同的基本类型", () => { + expect(deepEquals(1, 1)).toBe(true); + expect(deepEquals("test", "test")).toBe(true); + expect(deepEquals(true, true)).toBe(true); + expect(deepEquals(null, null)).toBe(true); + expect(deepEquals(undefined, undefined)).toBe(true); + }); + + it("应该正确比较不同的基本类型", () => { + expect(deepEquals(1, 2)).toBe(false); + expect(deepEquals("test", "other")).toBe(false); + expect(deepEquals(true, false)).toBe(false); + expect(deepEquals(null, undefined)).toBe(false); + }); + + it("应该正确处理 NaN", () => { + expect(deepEquals(Number.NaN, Number.NaN)).toBe(true); + }); + + it("应该正确处理 +0 和 -0", () => { + expect(deepEquals(0, -0)).toBe(false); + expect(deepEquals(+0, -0)).toBe(false); + }); + }); + + describe("数组", () => { + it("应该正确比较相同的数组", () => { + expect(deepEquals([1, 2, 3], [1, 2, 3])).toBe(true); + expect(deepEquals(["a", "b"], ["a", "b"])).toBe(true); + expect(deepEquals([], [])).toBe(true); + }); + + it("应该正确比较不同的数组", () => { + expect(deepEquals([1, 2, 3], [1, 2, 4])).toBe(false); + expect(deepEquals([1, 2], [1, 2, 3])).toBe(false); + expect(deepEquals(["a"], ["b"])).toBe(false); + }); + + it("应该正确比较嵌套数组", () => { + expect( + deepEquals( + [ + [1, 2], + [3, 4], + ], + [ + [1, 2], + [3, 4], + ] + ) + ).toBe(true); + expect( + deepEquals( + [ + [1, 2], + [3, 4], + ], + [ + [1, 2], + [3, 5], + ] + ) + ).toBe(false); + }); + }); + + describe("对象", () => { + it("应该正确比较相同的对象", () => { + expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + expect(deepEquals({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); // 键顺序不同 + expect(deepEquals({}, {})).toBe(true); + }); + + it("应该正确比较不同的对象", () => { + expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false); + expect(deepEquals({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEquals({ a: 1 }, { b: 1 })).toBe(false); + }); + + it("应该正确比较嵌套对象", () => { + expect(deepEquals({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true); + expect(deepEquals({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false); + }); + + it("应该正确比较包含数组的对象", () => { + expect(deepEquals({ a: [1, 2] }, { a: [1, 2] })).toBe(true); + expect(deepEquals({ a: [1, 2] }, { a: [1, 3] })).toBe(false); + }); + }); + + describe("混合类型", () => { + it("应该正确比较不同类型", () => { + expect(deepEquals(1, "1")).toBe(false); + expect(deepEquals([], {})).toBe(false); + expect(deepEquals(null, {})).toBe(false); + expect(deepEquals(undefined, null)).toBe(false); + }); + + it("应该正确比较复杂嵌套结构", () => { + const obj1 = { + a: 1, + b: [2, 3, { c: 4 }], + d: { e: [5, 6], f: { g: 7 } }, + }; + const obj2 = { + a: 1, + b: [2, 3, { c: 4 }], + d: { e: [5, 6], f: { g: 7 } }, + }; + const obj3 = { + a: 1, + b: [2, 3, { c: 4 }], + d: { e: [5, 6], f: { g: 8 } }, // 不同 + }; + + expect(deepEquals(obj1, obj2)).toBe(true); + expect(deepEquals(obj1, obj3)).toBe(false); + }); + }); +}); diff --git a/tests/unit/components/model-breakdown-column.test.tsx b/tests/unit/components/model-breakdown-column.test.tsx new file mode 100644 index 000000000..b9dc95ca8 --- /dev/null +++ b/tests/unit/components/model-breakdown-column.test.tsx @@ -0,0 +1,313 @@ +/** + * @vitest-environment happy-dom + */ + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { + ModelBreakdownItem, + ModelBreakdownLabels, +} from "@/components/analytics/model-breakdown-column"; +import { + ModelBreakdownColumn, + ModelBreakdownRow, +} from "@/components/analytics/model-breakdown-column"; + +// -- mocks -- + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => `t:${key}`, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +vi.mock("@/lib/utils/currency", async () => { + const actual = + await vi.importActual("@/lib/utils/currency"); + return { + ...actual, + formatCurrency: (value: number) => `$${value.toFixed(2)}`, + }; +}); + +// -- helpers -- + +function makeItem(overrides: Partial = {}): ModelBreakdownItem { + return { + model: "claude-opus-4", + requests: 150, + cost: 3.5, + inputTokens: 10000, + outputTokens: 5000, + cacheCreationTokens: 2000, + cacheReadTokens: 8000, + ...overrides, + }; +} + +const customLabels: ModelBreakdownLabels = { + unknownModel: "Custom Unknown", + modal: { + requests: "Custom Requests", + cost: "Custom Cost", + inputTokens: "Custom Input", + outputTokens: "Custom Output", + cacheCreationTokens: "Custom Cache Write", + cacheReadTokens: "Custom Cache Read", + totalTokens: "Custom Total Tokens", + costPercentage: "Custom Cost %", + cacheHitRate: "Custom Cache Hit", + cacheTokens: "Custom Cache Tokens", + performanceHigh: "Custom High", + performanceMedium: "Custom Medium", + performanceLow: "Custom Low", + }, +}; + +function renderText(element: React.ReactElement): string { + const markup = renderToStaticMarkup(element); + const container = document.createElement("div"); + // Safe: content comes from our own renderToStaticMarkup, not user input + container.textContent = ""; + const template = document.createElement("template"); + template.innerHTML = markup; + container.appendChild(template.content.cloneNode(true)); + return container.textContent ?? ""; +} + +// -- tests -- + +describe("ModelBreakdownColumn", () => { + it("renders model name for each page item", () => { + const items = [makeItem({ model: "gpt-4.1" }), makeItem({ model: "claude-sonnet-4" })]; + + const text = renderText( + + ); + + expect(text).toContain("gpt-4.1"); + expect(text).toContain("claude-sonnet-4"); + }); + + it("renders unknownModel label for null model", () => { + const items = [makeItem({ model: null })]; + + const text = renderText( + + ); + + // Falls back to useTranslations which returns "t:unknownModel" + expect(text).toContain("t:unknownModel"); + }); + + it("renders request count and token amounts", () => { + const items = [ + makeItem({ + requests: 42, + inputTokens: 1500, + outputTokens: 500, + cacheCreationTokens: 200, + cacheReadTokens: 300, + }), + ]; + + const text = renderText( + + ); + + // Request count + expect(text).toContain("42"); + // Total tokens = 1500 + 500 + 200 + 300 = 2500 -> "2.5K" + expect(text).toContain("2.5K"); + }); + + it("passes correct props to ModelBreakdownRow", () => { + const item = makeItem({ model: "test-model", cost: 5.0, requests: 99 }); + + const text = renderText( + + ); + + // Model name + expect(text).toContain("test-model"); + // Request count + expect(text).toContain("99"); + // Cost formatted + expect(text).toContain("$5.00"); + // Cost percentage = (5/10)*100 = 50.0 + expect(text).toContain("50.0%"); + }); + + it("uses custom labels when provided", () => { + const items = [makeItem({ model: null })]; + + const text = renderText( + + ); + + // Custom unknown model label instead of "t:unknownModel" + expect(text).toContain("Custom Unknown"); + expect(text).not.toContain("t:unknownModel"); + // Custom modal labels appear in the dialog content + expect(text).toContain("Custom Requests"); + expect(text).toContain("Custom Cost"); + expect(text).toContain("Custom Total Tokens"); + expect(text).toContain("Custom Cache Tokens"); + expect(text).toContain("Custom Cache Hit"); + }); +}); + +describe("ModelBreakdownRow", () => { + it("renders model name and metrics in the row", () => { + const text = renderText( + + ); + + expect(text).toContain("claude-opus-4"); + expect(text).toContain("150"); + expect(text).toContain("$3.50"); + }); + + it("computes cache hit rate correctly", () => { + // totalInputTokens = 10000 + 2000 + 8000 = 20000 + // cacheHitRate = (8000 / 20000) * 100 = 40.0 + const text = renderText( + + ); + + expect(text).toContain("40.0%"); + }); + + it("shows zero cache hit rate when no input tokens", () => { + const text = renderText( + + ); + + expect(text).toContain("0.0%"); + }); + + it("uses translation fallback when no labels provided", () => { + const text = renderText( + + ); + + // unknownModel via translation mock + expect(text).toContain("t:unknownModel"); + // modal labels via translation mock + expect(text).toContain("t:modal.requests"); + expect(text).toContain("t:modal.cacheWrite"); + expect(text).toContain("t:modal.cacheRead"); + }); + + it("uses custom labels when provided", () => { + const text = renderText( + + ); + + expect(text).toContain("Custom Unknown"); + expect(text).toContain("Custom Requests"); + expect(text).toContain("Custom Cache Write"); + expect(text).toContain("Custom Cache Read"); + expect(text).toContain("Custom Cache Tokens"); + expect(text).not.toContain("t:unknownModel"); + }); +}); diff --git a/tests/unit/dashboard/user-insights-page.test.tsx b/tests/unit/dashboard/user-insights-page.test.tsx new file mode 100644 index 000000000..86edb3bbe --- /dev/null +++ b/tests/unit/dashboard/user-insights-page.test.tsx @@ -0,0 +1,407 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import dashboardMessages from "@messages/en/dashboard.json"; +import myUsageMessages from "@messages/en/myUsage.json"; +import commonMessages from "@messages/en/common.json"; + +// --- Hoisted mocks --- + +const mockGetUserInsightsOverview = vi.hoisted(() => vi.fn()); +const mockGetUserInsightsKeyTrend = vi.hoisted(() => vi.fn()); +const mockGetUserInsightsModelBreakdown = vi.hoisted(() => vi.fn()); + +vi.mock("@/actions/admin-user-insights", () => ({ + getUserInsightsOverview: mockGetUserInsightsOverview, + getUserInsightsKeyTrend: mockGetUserInsightsKeyTrend, + getUserInsightsModelBreakdown: mockGetUserInsightsModelBreakdown, +})); + +const routerPushMock = vi.fn(); +vi.mock("@/i18n/routing", () => ({ + useRouter: () => ({ + push: routerPushMock, + replace: vi.fn(), + back: vi.fn(), + }), + usePathname: () => "/dashboard/leaderboard/user/10", +})); + +// Mock recharts to avoid rendering issues in happy-dom +vi.mock("recharts", () => ({ + Area: () => null, + AreaChart: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/chart", () => ({ + ChartContainer: ({ + children, + className, + }: { + children: ReactNode; + className?: string; + config: unknown; + }) => ( +
+ {children} +
+ ), + ChartTooltip: () => null, +})); + +// --- Test helpers --- + +const messages = { + dashboard: dashboardMessages, + myUsage: myUsageMessages, + common: commonMessages, +} as const; + +let queryClient: QueryClient; + +function renderWithProviders(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + {node} + + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushMicrotasks() { + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + } +} + +// --- Tests --- + +describe("UserInsightsView", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + + // Default mocks that resolve + mockGetUserInsightsOverview.mockResolvedValue({ + ok: true, + data: { + user: { id: 10, name: "TestUser" }, + overview: { + todayRequests: 42, + todayCost: 1.23, + avgResponseTime: 850, + todayErrorRate: 2.5, + yesterdaySamePeriodRequests: 30, + yesterdaySamePeriodCost: 1.0, + yesterdaySamePeriodAvgResponseTime: 900, + recentMinuteRequests: 3, + }, + currencyCode: "USD", + }, + }); + + mockGetUserInsightsKeyTrend.mockResolvedValue({ + ok: true, + data: [ + { key_id: 1, key_name: "key-a", date: "2026-03-08", api_calls: 10, total_cost: "0.5" }, + { key_id: 1, key_name: "key-a", date: "2026-03-09", api_calls: 15, total_cost: "0.8" }, + ], + }); + + mockGetUserInsightsModelBreakdown.mockResolvedValue({ + ok: true, + data: { + breakdown: [ + { + model: "claude-sonnet-4-5-20250514", + requests: 100, + cost: 1.5, + inputTokens: 5000, + outputTokens: 3000, + cacheCreationTokens: 1000, + cacheReadTokens: 500, + }, + { + model: "gpt-4o", + requests: 50, + cost: 0.8, + inputTokens: 2000, + outputTokens: 1500, + cacheCreationTokens: 200, + cacheReadTokens: 100, + }, + ], + currencyCode: "USD", + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders page title with userName", async () => { + const { UserInsightsView } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view" + ); + + const { container, unmount } = renderWithProviders( + + ); + + await flushMicrotasks(); + + const page = container.querySelector("[data-testid='user-insights-page']"); + expect(page).not.toBeNull(); + + const heading = container.querySelector("h1"); + expect(heading).not.toBeNull(); + expect(heading!.textContent).toContain("User Insights"); + expect(heading!.textContent).toContain("TestUser"); + + unmount(); + }); + + it("renders back button", async () => { + const { UserInsightsView } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view" + ); + + const { container, unmount } = renderWithProviders( + + ); + + await flushMicrotasks(); + + const backButton = Array.from(container.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("Back to Leaderboard") + ); + expect(backButton).not.toBeUndefined(); + + act(() => { + backButton!.click(); + }); + expect(routerPushMock).toHaveBeenCalledWith("/dashboard/leaderboard?scope=user"); + + unmount(); + }); +}); + +describe("UserOverviewCards", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders 4 metric cards", async () => { + mockGetUserInsightsOverview.mockResolvedValue({ + ok: true, + data: { + user: { id: 10, name: "TestUser" }, + overview: { + todayRequests: 42, + todayCost: 1.23, + avgResponseTime: 850, + todayErrorRate: 2.5, + yesterdaySamePeriodRequests: 30, + yesterdaySamePeriodCost: 1.0, + yesterdaySamePeriodAvgResponseTime: 900, + recentMinuteRequests: 3, + }, + currencyCode: "USD", + }, + }); + + const { UserOverviewCards } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards" + ); + + const { container, unmount } = renderWithProviders(); + + await flushMicrotasks(); + + const cards = container.querySelectorAll("[data-testid^='user-insights-metric-']"); + expect(cards.length).toBe(4); + + const todayRequests = container.querySelector( + "[data-testid='user-insights-metric-todayRequests']" + ); + expect(todayRequests).not.toBeNull(); + expect(todayRequests!.textContent).toContain("42"); + + const avgResponseTime = container.querySelector( + "[data-testid='user-insights-metric-avgResponseTime']" + ); + expect(avgResponseTime).not.toBeNull(); + expect(avgResponseTime!.textContent).toContain("850ms"); + + const errorRate = container.querySelector("[data-testid='user-insights-metric-errorRate']"); + expect(errorRate).not.toBeNull(); + expect(errorRate!.textContent).toContain("2.5%"); + + unmount(); + }); + + it("shows loading skeletons while fetching", async () => { + // Never resolves to keep loading state + mockGetUserInsightsOverview.mockReturnValue(new Promise(() => {})); + + const { UserOverviewCards } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards" + ); + + const { container, unmount } = renderWithProviders(); + + await flushMicrotasks(); + + const skeletons = container.querySelectorAll("[data-slot='skeleton']"); + expect(skeletons.length).toBeGreaterThan(0); + + unmount(); + }); +}); + +describe("UserKeyTrendChart", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders chart with timeRange prop", async () => { + mockGetUserInsightsKeyTrend.mockResolvedValue({ + ok: true, + data: [], + }); + + const { UserKeyTrendChart } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart" + ); + + const { container, unmount } = renderWithProviders( + + ); + + await flushMicrotasks(); + + // Time range buttons are now in the parent filter bar, not in this component + // Chart should render without internal time range controls + expect(container.querySelector("[data-testid='user-insights-time-range-today']")).toBeNull(); + + unmount(); + }); +}); + +describe("UserModelBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders model breakdown items", async () => { + mockGetUserInsightsModelBreakdown.mockResolvedValue({ + ok: true, + data: { + breakdown: [ + { + model: "claude-sonnet-4-5-20250514", + requests: 100, + cost: 1.5, + inputTokens: 5000, + outputTokens: 3000, + cacheCreationTokens: 1000, + cacheReadTokens: 500, + }, + { + model: "gpt-4o", + requests: 50, + cost: 0.8, + inputTokens: 2000, + outputTokens: 1500, + cacheCreationTokens: 200, + cacheReadTokens: 100, + }, + ], + currencyCode: "USD", + }, + }); + + const { UserModelBreakdown } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown" + ); + + const { container, unmount } = renderWithProviders(); + + await flushMicrotasks(); + + const breakdownList = container.querySelector( + "[data-testid='user-insights-model-breakdown-list']" + ); + expect(breakdownList).not.toBeNull(); + + // Check model names appear + expect(breakdownList!.textContent).toContain("claude-sonnet-4-5-20250514"); + expect(breakdownList!.textContent).toContain("gpt-4o"); + + unmount(); + }); +}); diff --git a/tests/unit/lib/cost-calculation-breakdown.test.ts b/tests/unit/lib/cost-calculation-breakdown.test.ts index b8589ffb9..074efc9c3 100644 --- a/tests/unit/lib/cost-calculation-breakdown.test.ts +++ b/tests/unit/lib/cost-calculation-breakdown.test.ts @@ -80,10 +80,10 @@ describe("calculateRequestCostBreakdown", () => { true // context1mApplied ); - // input: 200000 * 0.000003 + 100000 * 0.000003 * 2.0 = 0.6 + 0.6 = 1.2 - expect(result.input).toBeCloseTo(1.2, 4); - // output: 100 tokens, below 200k threshold - expect(result.output).toBeCloseTo(0.0015, 6); + // input: 300000 * 0.000003 * 2.0 = 1.8 (all tokens at premium when context > 200K) + expect(result.input).toBeCloseTo(1.8, 4); + // output: 100 * 0.000015 * 1.5 = 0.00225 (output also at premium when context > 200K) + expect(result.output).toBeCloseTo(0.00225, 6); }); test("200k tier pricing (Gemini style)", () => { @@ -97,8 +97,68 @@ describe("calculateRequestCostBreakdown", () => { }) ); - // input: 200000 * 0.000003 + 100000 * 0.000006 = 0.6 + 0.6 = 1.2 - expect(result.input).toBeCloseTo(1.2, 4); + // input: 300000 * 0.000006 = 1.8 (all tokens at above-200k rate when context > 200K) + expect(result.input).toBeCloseTo(1.8, 4); + }); + + test("uses priority long-context pricing fields in breakdown when available", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + cache_read_input_token_cost_priority: 0.2, + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + cache_read_input_token_cost_above_272k_tokens_priority: 0.7, + }), + false, + true + ); + + expect(result.input).toBe(1904007); + expect(result.output).toBe(140); + expect(result.cache_read).toBe(7); + expect(result.total).toBe(1904154); + }); + + test("falls back to regular long-context pricing in breakdown when priority long-context fields are absent", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + cache_read_input_token_cost_priority: 0.2, + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: undefined, + output_cost_per_token_above_272k_tokens_priority: undefined, + cache_read_input_token_cost_above_272k_tokens_priority: undefined, + }), + false, + true + ); + + expect(result.input).toBe(1360005); + expect(result.output).toBe(100); + expect(result.cache_read).toBe(5); + expect(result.total).toBe(1360110); }); test("categories sum to total", () => { diff --git a/tests/unit/lib/cost-calculation-priority.test.ts b/tests/unit/lib/cost-calculation-priority.test.ts index 17f020ee9..0342b94be 100644 --- a/tests/unit/lib/cost-calculation-priority.test.ts +++ b/tests/unit/lib/cost-calculation-priority.test.ts @@ -43,4 +43,76 @@ describe("calculateRequestCost priority service tier", () => { expect(Number(cost.toString())).toBe(32.5); }); + + test("uses priority long-context pricing fields when available", () => { + const cost = calculateRequestCost( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + cache_read_input_token_cost_above_272k_tokens_priority: 0.7, + }), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(1904154); + }); + + test("falls back to regular long-context pricing when priority long-context fields are absent", () => { + const cost = calculateRequestCost( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: undefined, + output_cost_per_token_above_272k_tokens_priority: undefined, + cache_read_input_token_cost_above_272k_tokens_priority: undefined, + }), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(1360110); + }); + + test("uses priority long-context fields by schema, not by model name", () => { + const cost = calculateRequestCost( + { + input_tokens: 272001, + output_tokens: 2, + }, + makePriceData({ + mode: "responses", + model_family: undefined, + input_cost_per_token_above_272k_tokens: undefined, + output_cost_per_token_above_272k_tokens: undefined, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + }), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(1904147); + }); }); diff --git a/tests/unit/lib/log-cleanup/service-count.test.ts b/tests/unit/lib/log-cleanup/service-count.test.ts index 235b75618..7625fe04c 100644 --- a/tests/unit/lib/log-cleanup/service-count.test.ts +++ b/tests/unit/lib/log-cleanup/service-count.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { type MockInstance, beforeEach, describe, expect, it, vi } from "vitest"; type ExecuteCountResult = unknown[] & { @@ -34,28 +36,50 @@ function makeExecuteResult(input: { return result; } +function makeReturningResult(count: number): unknown[] { + return Array.from({ length: count }, () => ({ "?column?": 1 })); +} + describe("log cleanup delete count", () => { beforeEach(async () => { const { db } = await import("@/drizzle/db"); (db.execute as MockInstance).mockReset(); }); + it("prefers RETURNING array length for row counting", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce(makeReturningResult(5)) // main delete: 5 rows + .mockResolvedValueOnce([]) // main delete: 0 (exit loop) + .mockResolvedValueOnce([]) // soft-delete purge: 0 (exit) + .mockResolvedValueOnce({}); // VACUUM + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(5); + expect(result.batchCount).toBe(1); + expect(result.vacuumPerformed).toBe(true); + }); + it("reads affected rows from postgres.js count field", async () => { const { db } = await import("@/drizzle/db"); (db.execute as MockInstance) - .mockResolvedValueOnce(makeExecuteResult({ count: 3 })) - .mockResolvedValueOnce(makeExecuteResult({ count: 0 })); + .mockResolvedValueOnce(makeExecuteResult({ count: 3 })) // main delete + .mockResolvedValueOnce(makeExecuteResult({ count: 0 })) // main delete exit + .mockResolvedValueOnce([]) // soft-delete purge + .mockResolvedValueOnce({}); // VACUUM const { cleanupLogs } = await import("@/lib/log-cleanup/service"); const result = await cleanupLogs( - { - beforeDate: new Date(), - }, + { beforeDate: new Date() }, {}, - { - type: "manual", - user: "test", - } + { type: "manual", user: "test" } ); expect(result.error).toBeUndefined(); @@ -65,10 +89,11 @@ describe("log cleanup delete count", () => { it("reads affected rows from postgres.js BigInt count field", async () => { const { db } = await import("@/drizzle/db"); - // postgres.js returns count as BigInt in some versions (db.execute as MockInstance) .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(7) })) - .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(0) })); + .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(0) })) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({}); const { cleanupLogs } = await import("@/lib/log-cleanup/service"); const result = await cleanupLogs( @@ -86,22 +111,159 @@ describe("log cleanup delete count", () => { const { db } = await import("@/drizzle/db"); (db.execute as MockInstance) .mockResolvedValueOnce(makeExecuteResult({ rowCount: 2 })) - .mockResolvedValueOnce(makeExecuteResult({ rowCount: 0 })); + .mockResolvedValueOnce(makeExecuteResult({ rowCount: 0 })) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({}); const { cleanupLogs } = await import("@/lib/log-cleanup/service"); const result = await cleanupLogs( - { - beforeDate: new Date(), - }, + { beforeDate: new Date() }, {}, - { - type: "manual", - user: "test", - } + { type: "manual", user: "test" } ); expect(result.error).toBeUndefined(); expect(result.totalDeleted).toBe(2); expect(result.batchCount).toBe(1); }); + + it("purgeSoftDeleted runs after main cleanup and count returned in result", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce(makeReturningResult(2)) // main delete: 2 + .mockResolvedValueOnce([]) // main delete exit + .mockResolvedValueOnce(makeReturningResult(4)) // soft-delete purge: 4 + .mockResolvedValueOnce([]) // soft-delete purge exit + .mockResolvedValueOnce({}); // VACUUM + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(2); + expect(result.softDeletedPurged).toBe(4); + expect(result.vacuumPerformed).toBe(true); + }); + + it("VACUUM runs after deletion, failure doesn't fail cleanup", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce(makeReturningResult(1)) // main delete: 1 + .mockResolvedValueOnce([]) // main delete exit + .mockResolvedValueOnce([]) // soft-delete purge: 0 + .mockRejectedValueOnce(new Error("VACUUM failed")); // VACUUM fails + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(1); + expect(result.vacuumPerformed).toBe(false); + }); + + it("VACUUM skipped when 0 records deleted", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce([]) // main delete: 0 (exit immediately) + .mockResolvedValueOnce([]); // soft-delete purge: 0 + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(0); + expect(result.softDeletedPurged).toBe(0); + expect(result.vacuumPerformed).toBe(false); + // VACUUM should not have been called (only 2 execute calls total) + expect(db.execute).toHaveBeenCalledTimes(2); + }); +}); + +describe("getAffectedRows", () => { + it("returns array length for RETURNING rows", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeReturningResult(10))).toBe(10); + }); + + it("falls through to count for empty array with count property", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeExecuteResult({ count: 5 }))).toBe(5); + }); + + it("handles BigInt count", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeExecuteResult({ count: BigInt(99) }))).toBe(99); + }); + + it("handles rowCount fallback", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeExecuteResult({ rowCount: 42 }))).toBe(42); + }); + + it("returns 0 for null/undefined", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(null)).toBe(0); + expect(getAffectedRows(undefined)).toBe(0); + }); + + it("returns 0 for empty result", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows([])).toBe(0); + expect(getAffectedRows({})).toBe(0); + }); +}); + +describe("buildWhereConditions", () => { + it("does not filter on deletedAt", async () => { + const { buildWhereConditions } = await import("@/lib/log-cleanup/service"); + const conditions = buildWhereConditions({}); + expect(conditions).toHaveLength(0); + }); + + it("returns conditions only for provided filters", async () => { + const { buildWhereConditions } = await import("@/lib/log-cleanup/service"); + const conditions = buildWhereConditions({ + beforeDate: new Date(), + userIds: [1, 2], + }); + // beforeDate + userIds = 2 conditions (no deletedAt) + expect(conditions).toHaveLength(2); + }); +}); + +describe("log cleanup SQL patterns", () => { + const serviceSource = readFileSync( + resolve(process.cwd(), "src/lib/log-cleanup/service.ts"), + "utf-8" + ); + + it("uses SKIP LOCKED in delete SQL", () => { + expect(serviceSource).toContain("FOR UPDATE SKIP LOCKED"); + }); + + it("uses RETURNING 1 in delete SQL", () => { + expect(serviceSource).toContain("RETURNING 1"); + }); + + it("does not contain deletedAt IS NULL in buildWhereConditions", () => { + const buildFnMatch = serviceSource.match(/function buildWhereConditions[\s\S]*?^}/m); + expect(buildFnMatch).not.toBeNull(); + expect(buildFnMatch![0]).not.toContain("deletedAt"); + }); + + it("includes VACUUM ANALYZE", () => { + expect(serviceSource).toContain("VACUUM ANALYZE message_request"); + }); }); diff --git a/tests/unit/lib/redis/cost-cache-cleanup.test.ts b/tests/unit/lib/redis/cost-cache-cleanup.test.ts new file mode 100644 index 000000000..3fef0f4c7 --- /dev/null +++ b/tests/unit/lib/redis/cost-cache-cleanup.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock logger +const loggerMock = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +// Mock Redis +const redisPipelineMock = { + del: vi.fn().mockReturnThis(), + exec: vi.fn(), +}; +const redisMock = { + status: "ready" as string, + pipeline: vi.fn(() => redisPipelineMock), +}; +const getRedisClientMock = vi.fn(() => redisMock); +vi.mock("@/lib/redis", () => ({ + getRedisClient: getRedisClientMock, +})); + +// Mock scanPattern +const scanPatternMock = vi.fn(); +vi.mock("@/lib/redis/scan-helper", () => ({ + scanPattern: scanPatternMock, +})); + +// Mock active-session-keys +vi.mock("@/lib/redis/active-session-keys", () => ({ + getKeyActiveSessionsKey: (keyId: number) => `{active_sessions}:key:${keyId}:active_sessions`, + getUserActiveSessionsKey: (userId: number) => `{active_sessions}:user:${userId}:active_sessions`, +})); + +describe("clearUserCostCache", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Re-establish default implementations after resetAllMocks + getRedisClientMock.mockReturnValue(redisMock); + redisMock.status = "ready"; + redisMock.pipeline.mockReturnValue(redisPipelineMock); + redisPipelineMock.del.mockReturnThis(); + redisPipelineMock.exec.mockResolvedValue([]); + scanPatternMock.mockResolvedValue([]); + }); + + test("scans correct Redis patterns for keyIds, userId, keyHashes", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + await clearUserCostCache({ + userId: 10, + keyIds: [1, 2], + keyHashes: ["hash-a", "hash-b"], + }); + + const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern); + // Per-key cost counters + expect(calls).toContain("key:1:cost_*"); + expect(calls).toContain("key:2:cost_*"); + // User cost counters + expect(calls).toContain("user:10:cost_*"); + // Total cost cache (user) + expect(calls).toContain("total_cost:user:10"); + expect(calls).toContain("total_cost:user:10:*"); + // Total cost cache (key hashes) + expect(calls).toContain("total_cost:key:hash-a"); + expect(calls).toContain("total_cost:key:hash-a:*"); + expect(calls).toContain("total_cost:key:hash-b"); + expect(calls).toContain("total_cost:key:hash-b:*"); + // Lease cache + expect(calls).toContain("lease:key:1:*"); + expect(calls).toContain("lease:key:2:*"); + expect(calls).toContain("lease:user:10:*"); + }); + + test("pipeline deletes all found keys", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"]; + if (pattern === "user:10:cost_*") return ["user:10:cost_monthly"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [null, 1], + [null, 1], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(3); + expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_daily"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_5h"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("user:10:cost_monthly"); + expect(redisPipelineMock.exec).toHaveBeenCalled(); + }); + + test("returns metrics (costKeysDeleted, activeSessionsDeleted, durationMs)", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [null, 1], + [null, 1], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: true, + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(1); + // 1 key session + 1 user session = 2 + expect(result!.activeSessionsDeleted).toBe(2); + expect(typeof result!.durationMs).toBe("number"); + expect(result!.durationMs).toBeGreaterThanOrEqual(0); + }); + + test("returns null when Redis not ready", async () => { + redisMock.status = "connecting"; + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).toBeNull(); + expect(scanPatternMock).not.toHaveBeenCalled(); + }); + + test("returns null when Redis client is null", async () => { + getRedisClientMock.mockReturnValue(null); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).toBeNull(); + }); + + test("includeActiveSessions=true adds session key DELs", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1, 2], + keyHashes: [], + includeActiveSessions: true, + }); + + expect(result).not.toBeNull(); + // 2 key sessions + 1 user session + expect(result!.activeSessionsDeleted).toBe(3); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:1:active_sessions"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:2:active_sessions"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:user:10:active_sessions"); + }); + + test("includeActiveSessions=false skips session keys", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; + return []; + }); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: false, + }); + + expect(result).not.toBeNull(); + expect(result!.activeSessionsDeleted).toBe(0); + // Only cost key deleted, no session keys + const delCalls = redisPipelineMock.del.mock.calls.map(([k]: [string]) => k); + expect(delCalls).not.toContain("{active_sessions}:key:1:active_sessions"); + expect(delCalls).not.toContain("{active_sessions}:user:10:active_sessions"); + }); + + test("empty scan results -- no pipeline created, returns zeros", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: false, + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(0); + expect(result!.activeSessionsDeleted).toBe(0); + // No pipeline created when nothing to delete + expect(redisMock.pipeline).not.toHaveBeenCalled(); + }); + + test("pipeline partial failures -- logged, does not throw", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [new Error("Connection reset"), null], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(2); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Some Redis deletes failed during cost cache cleanup", + expect.objectContaining({ errorCount: 1, userId: 10 }) + ); + }); + + test("no keys (empty keyIds/keyHashes) -- only user patterns scanned", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + await clearUserCostCache({ + userId: 10, + keyIds: [], + keyHashes: [], + }); + + const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern); + // Only user-level patterns (no key:* or total_cost:key:* patterns) + expect(calls).toContain("user:10:cost_*"); + expect(calls).toContain("total_cost:user:10"); + expect(calls).toContain("total_cost:user:10:*"); + expect(calls).toContain("lease:user:10:*"); + // No key-specific patterns + expect(calls.filter((p: string) => p.startsWith("key:"))).toHaveLength(0); + expect(calls.filter((p: string) => p.startsWith("total_cost:key:"))).toHaveLength(0); + expect(calls.filter((p: string) => p.startsWith("lease:key:"))).toHaveLength(0); + }); +}); diff --git a/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts new file mode 100644 index 000000000..e86465225 --- /dev/null +++ b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// Mock Redis client +const redisPipelineMock = { + setex: vi.fn().mockReturnThis(), + del: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([]), +}; +const redisMock = { + get: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + pipeline: vi.fn(() => redisPipelineMock), +}; + +// Mock the redis client loader +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: () => redisMock, +})); + +// Enable cache feature via env +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + ENABLE_API_KEY_REDIS_CACHE: "true", + REDIS_URL: "redis://localhost:6379", + ENABLE_RATE_LIMIT: "true", + CI: "", + NEXT_PHASE: "", + }; +}); + +// Mock crypto.subtle for SHA-256 +const mockDigest = vi.fn(); +Object.defineProperty(globalThis, "crypto", { + value: { + subtle: { + digest: mockDigest, + }, + }, + writable: true, + configurable: true, +}); + +// Helper: produce a predictable hex hash from SHA-256 mock +function setupSha256Mock(hexResult = "abc123def456") { + const buffer = new ArrayBuffer(hexResult.length / 2); + const view = new Uint8Array(buffer); + for (let i = 0; i < hexResult.length; i += 2) { + view[i / 2] = parseInt(hexResult.slice(i, i + 2), 16); + } + mockDigest.mockResolvedValue(buffer); +} + +// Base user fixture +function makeUser(overrides: Record = {}) { + return { + id: 10, + name: "test-user", + role: "user", + isEnabled: true, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitConcurrentSessions: 0, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-02-01T00:00:00Z"), + expiresAt: null, + deletedAt: null, + costResetAt: null, + ...overrides, + }; +} + +describe("api-key-auth-cache costResetAt handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + redisMock.get.mockResolvedValue(null); + redisMock.setex.mockResolvedValue("OK"); + redisMock.del.mockResolvedValue(1); + setupSha256Mock(); + }); + + describe("hydrateUserFromCache (via getCachedUser)", () => { + test("preserves costResetAt as Date when valid ISO string in cache", async () => { + const costResetAt = "2026-02-15T00:00:00.000Z"; + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + expect(user!.costResetAt).toBeInstanceOf(Date); + expect(user!.costResetAt!.toISOString()).toBe(costResetAt); + }); + + test("costResetAt null in cache -- returns null correctly", async () => { + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt: null }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + expect(user!.costResetAt).toBeNull(); + }); + + test("costResetAt undefined in cache -- returns undefined correctly", async () => { + // When costResetAt is not present in JSON, it deserializes as undefined + const userWithoutField = makeUser(); + delete (userWithoutField as Record).costResetAt; + const cachedPayload = { v: 1, user: userWithoutField }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + // undefined because JSON.parse drops undefined fields + expect(user!.costResetAt).toBeUndefined(); + }); + + test("invalid costResetAt string -- cache entry deleted, returns null", async () => { + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt: "not-a-date" }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + // hydrateUserFromCache returns null because costResetAt != null but parseOptionalDate returns null + // BUT: the code path is: costResetAt is not null, parseOptionalDate returns null for invalid string + // Line 173-174: if (user.costResetAt != null && !costResetAt) return null; + // Actually, that condition doesn't exist -- let's check the actual behavior + // Looking at the code: parseOptionalDate("not-a-date") => parseRequiredDate("not-a-date") + // => new Date("not-a-date") => Invalid Date => return null + // Then costResetAt is null (from parseOptionalDate) + // The code does NOT have a null check for costResetAt like expiresAt/deletedAt + // So the user would still be returned with costResetAt: null + expect(user).not.toBeNull(); + // Invalid date parsed to null (graceful degradation) + expect(user!.costResetAt).toBeNull(); + }); + }); + + describe("cacheUser", () => { + test("includes costResetAt in cached payload", async () => { + const user = makeUser({ + costResetAt: new Date("2026-02-15T00:00:00Z"), + }); + + const { cacheUser } = await import("@/lib/security/api-key-auth-cache"); + await cacheUser(user as never); + + expect(redisMock.setex).toHaveBeenCalledWith( + expect.stringContaining("api_key_auth:v1:user:10"), + expect.any(Number), + expect.stringContaining("2026-02-15") + ); + }); + + test("caches user with null costResetAt", async () => { + const user = makeUser({ costResetAt: null }); + + const { cacheUser } = await import("@/lib/security/api-key-auth-cache"); + await cacheUser(user as never); + + expect(redisMock.setex).toHaveBeenCalled(); + const payload = JSON.parse(redisMock.setex.mock.calls[0][2]); + expect(payload.v).toBe(1); + expect(payload.user.costResetAt).toBeNull(); + }); + }); + + describe("invalidateCachedUser", () => { + test("deletes correct Redis key", async () => { + const { invalidateCachedUser } = await import("@/lib/security/api-key-auth-cache"); + await invalidateCachedUser(10); + + expect(redisMock.del).toHaveBeenCalledWith("api_key_auth:v1:user:10"); + }); + }); +}); diff --git a/tests/unit/lib/utils/provider-display.test.ts b/tests/unit/lib/utils/provider-display.test.ts new file mode 100644 index 000000000..6639de723 --- /dev/null +++ b/tests/unit/lib/utils/provider-display.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { isProviderFinalized } from "@/lib/utils/provider-display"; + +describe("isProviderFinalized", () => { + it.each([ + { + name: "null providerChain + null statusCode = not finalized", + entry: { providerChain: null, statusCode: null, blockedBy: null }, + expected: false, + }, + { + name: "empty providerChain + null statusCode = not finalized", + entry: { providerChain: [], statusCode: null, blockedBy: null }, + expected: false, + }, + { + name: "undefined fields = not finalized", + entry: {}, + expected: false, + }, + { + name: "providerChain with items = finalized", + entry: { providerChain: [{ id: 1, name: "provider-a" }], statusCode: 200 }, + expected: true, + }, + { + name: "null providerChain + statusCode present = finalized", + entry: { providerChain: null, statusCode: 200 }, + expected: true, + }, + { + name: "statusCode 0 counts as finalized", + entry: { providerChain: null, statusCode: 0 }, + expected: true, + }, + { + name: "error statusCode = finalized", + entry: { providerChain: null, statusCode: 500 }, + expected: true, + }, + { + name: "blockedBy = finalized (regardless of other fields)", + entry: { providerChain: null, statusCode: null, blockedBy: "sensitive_word" }, + expected: true, + }, + { + name: "blockedBy takes priority over missing chain/status", + entry: { blockedBy: "rate_limit" }, + expected: true, + }, + ])("$name", ({ entry, expected }) => { + expect(isProviderFinalized(entry)).toBe(expected); + }); +}); diff --git a/tests/unit/proxy/client-detector.test.ts b/tests/unit/proxy/client-detector.test.ts index 131f65f74..b5327a85e 100644 --- a/tests/unit/proxy/client-detector.test.ts +++ b/tests/unit/proxy/client-detector.test.ts @@ -297,6 +297,82 @@ describe("client-detector", () => { }); }); + describe("matchClientPattern glob wildcard path", () => { + test("should match codex-* against codex-cli/2.0", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + expect(matchClientPattern(session, "codex-*")).toBe(true); + }); + + test("should not match codex-* against GeminiCLI/1.0", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + expect(matchClientPattern(session, "codex-*")).toBe(false); + }); + + test("should match *-cli* against codex-cli/2.0", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + expect(matchClientPattern(session, "*-cli*")).toBe(true); + }); + + test("should match bare * against any non-empty UA", () => { + const session = createMockSession({ userAgent: "AnyClient/1.0" }); + expect(matchClientPattern(session, "*")).toBe(true); + }); + + test("should match My*App against MyCustomApp/1.0", () => { + const session = createMockSession({ userAgent: "MyCustomApp/1.0" }); + expect(matchClientPattern(session, "My*App*")).toBe(true); + }); + + test("should be case-insensitive for glob", () => { + const session = createMockSession({ userAgent: "codex-cli/1.0" }); + expect(matchClientPattern(session, "CODEX-*")).toBe(true); + }); + + test("should return false for glob when UA is empty", () => { + const session = createMockSession({ userAgent: " " }); + expect(matchClientPattern(session, "codex-*")).toBe(false); + }); + + test("should NOT normalize hyphens/underscores in glob mode", () => { + const session = createMockSession({ userAgent: "codex_cli/2.0" }); + expect(matchClientPattern(session, "codex-*")).toBe(false); + }); + + test("should match glob with underscores literally", () => { + const session = createMockSession({ userAgent: "codex_cli/2.0" }); + expect(matchClientPattern(session, "codex_*")).toBe(true); + }); + + test("consecutive wildcards ** should behave like single *", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + expect(matchClientPattern(session, "codex-**")).toBe(true); + expect(matchClientPattern(session, "**codex**")).toBe(true); + }); + + test("glob should handle regex metacharacters literally", () => { + const session = createMockSession({ userAgent: "foo.bar/1.0" }); + expect(matchClientPattern(session, "foo.bar*")).toBe(true); + expect(matchClientPattern(session, "foo*bar*")).toBe(true); + const session2 = createMockSession({ userAgent: "fooXbar/1.0" }); + expect(matchClientPattern(session2, "foo.bar*")).toBe(false); + }); + + test("glob should handle brackets and parens literally", () => { + const session = createMockSession({ userAgent: "tool[v2]/1.0" }); + expect(matchClientPattern(session, "tool[v2]*")).toBe(true); + }); + + test("pathological glob pattern completes quickly without ReDoS", () => { + const session = createMockSession({ userAgent: `${"a".repeat(32)}b` }); + const pattern = "*a*a*a*a*a*a*a*a*c"; + const start = performance.now(); + const result = matchClientPattern(session, pattern); + const elapsed = performance.now() - start; + expect(result).toBe(false); + expect(elapsed).toBeLessThan(50); + }); + }); + describe("isClientAllowed", () => { test("should reject when blocked matches even if allowed also matches", () => { const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)"); @@ -451,6 +527,30 @@ describe("client-detector", () => { expect(result.signals).toBeUndefined(); expect(result.hubConfirmed).toBeUndefined(); }); + + test("should allow when glob pattern in allowlist matches", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + const result = isClientAllowedDetailed(session, ["codex-*"], []); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("allowed"); + expect(result.matchedPattern).toBe("codex-*"); + }); + + test("should reject when glob pattern in blocklist matches", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + const result = isClientAllowedDetailed(session, [], ["codex-*"]); + expect(result.allowed).toBe(false); + expect(result.matchType).toBe("blocklist_hit"); + expect(result.matchedPattern).toBe("codex-*"); + }); + + test("should work with mix of glob and substring patterns", () => { + const session = createMockSession({ userAgent: "my-custom-tool/3.0" }); + const result = isClientAllowedDetailed(session, ["codex-*", "custom"], []); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("allowed"); + expect(result.matchedPattern).toBe("custom"); + }); }); describe("detectClientFull", () => { diff --git a/tests/unit/proxy/extract-usage-metrics.test.ts b/tests/unit/proxy/extract-usage-metrics.test.ts index 4bbc43d56..281f2d44e 100644 --- a/tests/unit/proxy/extract-usage-metrics.test.ts +++ b/tests/unit/proxy/extract-usage-metrics.test.ts @@ -540,6 +540,38 @@ describe("extractUsageMetrics", () => { // 顶层优先 expect(result.usageMetrics?.cache_read_input_tokens).toBe(300); }); + + it("应从 Chat Completions 的 prompt_tokens_details.cached_tokens 提取缓存读取", () => { + const response = JSON.stringify({ + usage: { + prompt_tokens: 1000, + completion_tokens: 500, + prompt_tokens_details: { + cached_tokens: 200, + }, + }, + }); + + const result = parseUsageFromResponseText(response, "openai"); + + expect(result.usageMetrics?.cache_read_input_tokens).toBe(200); + }); + + it("顶层 cache_read_input_tokens 应优先于 Chat Completions 嵌套格式", () => { + const response = JSON.stringify({ + usage: { + prompt_tokens: 1000, + cache_read_input_tokens: 300, + prompt_tokens_details: { + cached_tokens: 200, + }, + }, + }); + + const result = parseUsageFromResponseText(response, "openai"); + + expect(result.usageMetrics?.cache_read_input_tokens).toBe(300); + }); }); describe("SSE 流式响应解析", () => { diff --git a/tests/unit/proxy/hedge-winner-dedup.test.ts b/tests/unit/proxy/hedge-winner-dedup.test.ts new file mode 100644 index 000000000..ca78eebb7 --- /dev/null +++ b/tests/unit/proxy/hedge-winner-dedup.test.ts @@ -0,0 +1,237 @@ +/** + * Tests for hedge winner duplicate provider chain entry fix. + * + * Bug: When a streaming hedge request wins, commitWinner() logs the provider with + * reason "hedge_winner", then finalizeDeferredStreamingFinalizationIfNeeded() logs + * the same provider again with reason "retry_success". The dedup logic in + * addProviderToChain() doesn't catch this because "hedge_winner" !== "retry_success". + * + * Fix: Add isHedgeWinner flag to DeferredStreamingFinalization so finalization + * can skip duplicate session binding, provider update, and chain logging. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Provider } from "@/types/provider"; + +// ── stream-finalization round-trip ────────────────────────────────── + +describe("DeferredStreamingFinalization isHedgeWinner flag", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("should preserve isHedgeWinner=true through set/consume cycle", async () => { + const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import( + "@/app/v1/_lib/proxy/stream-finalization" + ); + + const fakeSession = {} as Parameters[0]; + + setDeferredStreamingFinalization(fakeSession, { + providerId: 1, + providerName: "test", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: false, + endpointId: null, + endpointUrl: "https://api.example.com", + upstreamStatusCode: 200, + isHedgeWinner: true, + }); + + const meta = consumeDeferredStreamingFinalization(fakeSession); + expect(meta).not.toBeNull(); + expect(meta!.isHedgeWinner).toBe(true); + }); + + it("should preserve isHedgeWinner=false (non-hedge) through set/consume cycle", async () => { + const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import( + "@/app/v1/_lib/proxy/stream-finalization" + ); + + const fakeSession = {} as Parameters[0]; + + setDeferredStreamingFinalization(fakeSession, { + providerId: 1, + providerName: "test", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: null, + endpointUrl: "https://api.example.com", + upstreamStatusCode: 200, + isHedgeWinner: false, + }); + + const meta = consumeDeferredStreamingFinalization(fakeSession); + expect(meta).not.toBeNull(); + expect(meta!.isHedgeWinner).toBe(false); + }); + + it("should default isHedgeWinner to undefined when not set", async () => { + const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import( + "@/app/v1/_lib/proxy/stream-finalization" + ); + + const fakeSession = {} as Parameters[0]; + + setDeferredStreamingFinalization(fakeSession, { + providerId: 1, + providerName: "test", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: null, + endpointUrl: "https://api.example.com", + upstreamStatusCode: 200, + }); + + const meta = consumeDeferredStreamingFinalization(fakeSession); + expect(meta).not.toBeNull(); + expect(meta!.isHedgeWinner).toBeUndefined(); + }); +}); + +// ── addProviderToChain dedup gap (documents the bug) ──────────────── + +// These mocks must be declared before importing ProxySession +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +vi.mock("@/repository/provider", () => ({ + findAllProviders: vi.fn(async () => []), +})); + +vi.mock("@/lib/redis/live-chain-store", () => ({ + writeLiveChain: vi.fn(), +})); + +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +const makeProvider = (id: number, name: string): Provider => + ({ + id, + name, + providerVendorId: 100, + providerType: "claude", + priority: 10, + weight: 1, + costMultiplier: 1, + groupTag: null, + isEnabled: true, + }) as unknown as Provider; + +function createSession(): ProxySession { + return new ( + ProxySession as unknown as { + new (init: { + startTime: number; + method: string; + requestUrl: URL; + headers: Headers; + headerLog: string; + request: { message: Record; log: string; model: string | null }; + userAgent: string | null; + context: unknown; + clientAbortSignal: AbortSignal | null; + }): ProxySession; + } + )({ + startTime: Date.now(), + method: "POST", + requestUrl: new URL("http://localhost/v1/messages"), + headers: new Headers(), + headerLog: "", + request: { message: {}, log: "(test)", model: "test-model" }, + userAgent: null, + context: {}, + clientAbortSignal: null, + }); +} + +describe("addProviderToChain dedup behavior with hedge reasons", () => { + it("same provider with hedge_winner then retry_success produces duplicate (documents bug)", () => { + const session = createSession(); + const provider = makeProvider(1, "Provider A"); + + // commitWinner logs with hedge_winner + session.addProviderToChain(provider, { + reason: "hedge_winner", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + // finalization would log with retry_success (the bug) + session.addProviderToChain(provider, { + reason: "retry_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + const chain = session.getProviderChain(); + // Documents the current (broken) behavior: 2 entries for the same provider. + // After the fix, finalization won't call addProviderToChain for hedge winners, + // so this scenario won't arise in practice. + expect(chain).toHaveLength(2); + expect(chain[0].reason).toBe("hedge_winner"); + expect(chain[1].reason).toBe("retry_success"); + }); + + it("same provider with identical reason and attemptNumber deduplicates correctly", () => { + const session = createSession(); + const provider = makeProvider(1, "Provider A"); + + session.addProviderToChain(provider, { + reason: "request_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + // Same reason + same attemptNumber -> should dedup + session.addProviderToChain(provider, { + reason: "request_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0].reason).toBe("request_success"); + }); + + it("non-hedge finalization should add entry to chain normally", () => { + const session = createSession(); + const provider = makeProvider(1, "Provider A"); + + session.addProviderToChain(provider, { + reason: "request_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0].reason).toBe("request_success"); + }); +}); diff --git a/tests/unit/proxy/provider-selector-cross-type-model.test.ts b/tests/unit/proxy/provider-selector-cross-type-model.test.ts new file mode 100644 index 000000000..60ff7b9bf --- /dev/null +++ b/tests/unit/proxy/provider-selector-cross-type-model.test.ts @@ -0,0 +1,535 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Provider } from "@/types/provider"; + +// ── Mocks (shared by findReusable and pickRandomProvider tests) ── + +const circuitBreakerMocks = vi.hoisted(() => ({ + isCircuitOpen: vi.fn(async () => false), + getCircuitState: vi.fn(() => "closed"), +})); + +vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks); + +const vendorTypeCircuitMocks = vi.hoisted(() => ({ + isVendorTypeCircuitOpen: vi.fn(async () => false), +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => vendorTypeCircuitMocks); + +const sessionManagerMocks = vi.hoisted(() => ({ + SessionManager: { + getSessionProvider: vi.fn(async () => null as number | null), + clearSessionProvider: vi.fn(async () => undefined), + }, +})); + +vi.mock("@/lib/session-manager", () => sessionManagerMocks); + +const providerRepositoryMocks = vi.hoisted(() => ({ + findProviderById: vi.fn(async () => null as Provider | null), + findAllProviders: vi.fn(async () => [] as Provider[]), +})); + +vi.mock("@/repository/provider", () => providerRepositoryMocks); + +const rateLimitMocks = vi.hoisted(() => ({ + RateLimitService: { + checkCostLimitsWithLease: vi.fn(async () => ({ allowed: true })), + checkTotalCostLimit: vi.fn(async () => ({ allowed: true, current: 0 })), + }, +})); + +vi.mock("@/lib/rate-limit", () => rateLimitMocks); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +// ── Helpers ── + +function createProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "test-provider", + isEnabled: true, + providerType: "openai-compatible", + groupTag: null, + weight: 1, + priority: 0, + costMultiplier: 1, + allowedModels: null, + providerVendorId: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + ...overrides, + } as unknown as Provider; +} + +// ══════════════════════════════════════════════════════════════════ +// Part 1: Direct unit tests for providerSupportsModel (table-driven) +// ══════════════════════════════════════════════════════════════════ + +describe("providerSupportsModel - direct unit tests (#832)", () => { + const cases: Array<{ + name: string; + providerType: string; + allowedModels: string[] | null; + modelRedirects?: Record; + requestedModel: string; + expected: boolean; + }> = [ + // Core fix: openai-compatible + claude model + explicit allowedModels + { + name: "openai-compatible + allowedModels contains claude model -> true", + providerType: "openai-compatible", + allowedModels: ["claude-opus-4-6"], + requestedModel: "claude-opus-4-6", + expected: true, + }, + { + name: "openai-compatible + null allowedModels + claude model -> true (wildcard)", + providerType: "openai-compatible", + allowedModels: null, + requestedModel: "claude-sonnet-4-5-20250929", + expected: true, + }, + { + name: "openai-compatible + empty allowedModels + claude model -> true (wildcard)", + providerType: "openai-compatible", + allowedModels: [], + requestedModel: "claude-opus-4-6", + expected: true, + }, + { + name: "openai-compatible + allowedModels NOT containing model -> false", + providerType: "openai-compatible", + allowedModels: ["gpt-4o", "gpt-4o-mini"], + requestedModel: "claude-opus-4-6", + expected: false, + }, + + // Claude provider behavior + { + name: "claude + null allowedModels + claude model -> true (wildcard)", + providerType: "claude", + allowedModels: null, + requestedModel: "claude-opus-4-6", + expected: true, + }, + { + name: "claude + null allowedModels + non-claude model -> true (wildcard)", + providerType: "claude", + allowedModels: null, + requestedModel: "gpt-4o", + expected: true, + }, + { + name: "claude + allowedModels contains non-claude model -> true (explicit)", + providerType: "claude", + allowedModels: ["gemini-2.5-pro"], + requestedModel: "gemini-2.5-pro", + expected: true, + }, + { + name: "claude + allowedModels NOT containing model -> false", + providerType: "claude", + allowedModels: ["claude-haiku-4-5"], + requestedModel: "claude-opus-4-6", + expected: false, + }, + { + name: "claude-auth + null allowedModels -> true (wildcard)", + providerType: "claude-auth", + allowedModels: null, + requestedModel: "claude-opus-4-6", + expected: true, + }, + + // modelRedirects + { + name: "modelRedirects + null allowedModels -> true (wildcard)", + providerType: "openai-compatible", + allowedModels: null, + modelRedirects: { "claude-opus-4-6": "custom-opus" }, + requestedModel: "claude-opus-4-6", + expected: true, + }, + { + name: "modelRedirects does not bypass explicit allowedModels mismatch -> false", + providerType: "claude", + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, + requestedModel: "claude-opus-4-5-20251001", + expected: false, + }, + { + name: "neither allowedModels nor modelRedirects contains model -> false", + providerType: "openai-compatible", + allowedModels: ["gpt-4o"], + modelRedirects: { "gpt-4": "gpt-4o" }, + requestedModel: "claude-opus-4-6", + expected: false, + }, + + // Other provider types + { + name: "codex + null allowedModels -> true (wildcard)", + providerType: "codex", + allowedModels: null, + requestedModel: "codex-mini-latest", + expected: true, + }, + { + name: "gemini + allowedModels match -> true", + providerType: "gemini", + allowedModels: ["gemini-2.0-flash"], + requestedModel: "gemini-2.0-flash", + expected: true, + }, + ]; + + test.each(cases)("$name", async ({ + providerType, + allowedModels, + modelRedirects, + requestedModel, + expected, + }) => { + const { providerSupportsModel } = await import("@/app/v1/_lib/proxy/provider-selector"); + const provider = createProvider({ + providerType, + allowedModels, + ...(modelRedirects && { modelRedirects }), + }); + expect(providerSupportsModel(provider, requestedModel)).toBe(expected); + }); +}); + +// ══════════════════════════════════════════════════════════════════ +// Part 2: Integration tests via findReusable (session reuse path) +// ══════════════════════════════════════════════════════════════════ + +describe("findReusable - cross-type model routing (#832)", () => { + test("openai-compatible + allowedModels with claude model -> reuse succeeds", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + const provider = createProvider({ + id: 10, + providerType: "openai-compatible", + allowedModels: ["claude-opus-4-6"], + }); + + sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(10); + providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider); + rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({ + allowed: true, + }); + rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({ + allowed: true, + current: 0, + }); + + const session = { + sessionId: "cross-type-1", + shouldReuseProvider: () => true, + getOriginalModel: () => "claude-opus-4-6", + authState: null, + getCurrentModel: () => null, + } as any; + + const result = await (ProxyProviderResolver as any).findReusable(session); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(10); + }); + + test("openai-compatible + null allowedModels + claude model -> reuse succeeds (wildcard)", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + const provider = createProvider({ id: 11, allowedModels: null }); + + sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(11); + providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider); + rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({ + allowed: true, + }); + rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({ + allowed: true, + current: 0, + }); + + const session = { + sessionId: "cross-type-2", + shouldReuseProvider: () => true, + getOriginalModel: () => "claude-sonnet-4-5-20250929", + authState: null, + getCurrentModel: () => null, + } as any; + + const result = await (ProxyProviderResolver as any).findReusable(session); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(11); + }); + + test("openai-compatible + allowedModels mismatch -> clears stale binding", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + const provider = createProvider({ + id: 12, + allowedModels: ["gpt-4o", "gpt-4o-mini"], + }); + + sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(12); + providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider); + + const session = { + sessionId: "cross-type-3", + shouldReuseProvider: () => true, + getOriginalModel: () => "claude-opus-4-6", + authState: null, + getCurrentModel: () => null, + } as any; + + const result = await (ProxyProviderResolver as any).findReusable(session); + + expect(result).toBeNull(); + expect(sessionManagerMocks.SessionManager.clearSessionProvider).toHaveBeenCalledWith( + "cross-type-3" + ); + }); + + test("modelRedirects do not bypass explicit allowedModels during reuse", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + const provider = createProvider({ + id: 15, + providerType: "claude", + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, + }); + + sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(15); + providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider); + + const session = { + sessionId: "cross-type-6", + shouldReuseProvider: () => true, + getOriginalModel: () => "claude-opus-4-5-20251001", + authState: null, + getCurrentModel: () => null, + } as any; + + const result = await (ProxyProviderResolver as any).findReusable(session); + + expect(result).toBeNull(); + expect(sessionManagerMocks.SessionManager.clearSessionProvider).toHaveBeenCalledWith( + "cross-type-6" + ); + }); +}); + +// ══════════════════════════════════════════════════════════════════ +// Part 3: Integration tests via pickRandomProvider (fresh selection path) +// ══════════════════════════════════════════════════════════════════ + +describe("pickRandomProvider - cross-type model routing (#832)", () => { + function createPickSession(originalFormat: string, providers: Provider[], originalModel: string) { + return { + originalFormat, + authState: null, + getProvidersSnapshot: async () => providers, + getOriginalModel: () => originalModel, + getCurrentModel: () => originalModel, + clientRequestsContext1m: () => false, + } as any; + } + + async function setupResolverMocks() { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation( + async (...args: unknown[]) => args[0] as Provider[] + ); + vi.spyOn(ProxyProviderResolver as any, "selectTopPriority").mockImplementation( + (...args: unknown[]) => args[0] as Provider[] + ); + vi.spyOn(ProxyProviderResolver as any, "selectOptimal").mockImplementation( + (...args: unknown[]) => (args[0] as Provider[])[0] ?? null + ); + + return ProxyProviderResolver; + } + + test("openai format + openai-compatible with allowedModels=[claude-opus-4-6] -> selected", async () => { + const Resolver = await setupResolverMocks(); + + const provider = createProvider({ + id: 20, + providerType: "openai-compatible", + allowedModels: ["claude-opus-4-6"], + }); + const session = createPickSession("openai", [provider], "claude-opus-4-6"); + + const { provider: picked } = await (Resolver as any).pickRandomProvider(session, []); + + expect(picked).not.toBeNull(); + expect(picked?.id).toBe(20); + }); + + test("openai format + claude provider with null allowedModels -> rejected by format check", async () => { + const Resolver = await setupResolverMocks(); + + const claudeProvider = createProvider({ + id: 21, + providerType: "claude", + allowedModels: null, + }); + const session = createPickSession("openai", [claudeProvider], "gpt-4o"); + + const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []); + + expect(picked).toBeNull(); + const mismatch = context.filteredProviders.find( + (fp: any) => fp.id === 21 && fp.reason === "format_type_mismatch" + ); + expect(mismatch).toBeDefined(); + }); + + test("openai format + openai-compatible with non-matching allowedModels -> rejected by model check", async () => { + const Resolver = await setupResolverMocks(); + + const provider = createProvider({ + id: 22, + providerType: "openai-compatible", + allowedModels: ["gpt-4o"], + }); + const session = createPickSession("openai", [provider], "claude-opus-4-6"); + + const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []); + + expect(picked).toBeNull(); + const mismatch = context.filteredProviders.find( + (fp: any) => fp.id === 22 && fp.reason === "model_not_allowed" + ); + expect(mismatch).toBeDefined(); + }); + + test("format check + model check combined: only format-and-model compatible provider selected", async () => { + const Resolver = await setupResolverMocks(); + + // claude provider (format-incompatible with openai request) + const p1 = createProvider({ + id: 30, + providerType: "claude", + allowedModels: ["claude-opus-4-6"], + }); + // openai-compatible but wrong model + const p2 = createProvider({ + id: 31, + providerType: "openai-compatible", + allowedModels: ["gpt-4o"], + }); + // openai-compatible with correct model + const p3 = createProvider({ + id: 32, + providerType: "openai-compatible", + allowedModels: ["claude-opus-4-6"], + }); + + const session = createPickSession("openai", [p1, p2, p3], "claude-opus-4-6"); + + const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []); + + expect(picked?.id).toBe(32); + + const formatMismatch = context.filteredProviders.find( + (fp: any) => fp.id === 30 && fp.reason === "format_type_mismatch" + ); + expect(formatMismatch).toBeDefined(); + + const modelMismatch = context.filteredProviders.find( + (fp: any) => fp.id === 31 && fp.reason === "model_not_allowed" + ); + expect(modelMismatch).toBeDefined(); + }); + + test("claude format + explicit allowlist rejects opus request even when redirect points to allowed glm", async () => { + const Resolver = await setupResolverMocks(); + + const provider = createProvider({ + id: 33, + providerType: "claude", + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, + }); + const session = createPickSession("claude", [provider], "claude-opus-4-5-20251001"); + + const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []); + + expect(picked).toBeNull(); + const mismatch = context.filteredProviders.find( + (fp: any) => fp.id === 33 && fp.reason === "model_not_allowed" + ); + expect(mismatch).toBeDefined(); + }); + + test("claude format skips priority-0 redirect-only provider and selects lower-priority allowed provider", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation( + async (...args: unknown[]) => args[0] as Provider[] + ); + + const priorityZeroProvider = createProvider({ + id: 40, + providerType: "claude", + priority: 0, + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, + }); + const fallbackProvider = createProvider({ + id: 41, + providerType: "claude", + priority: 1, + allowedModels: ["claude-opus-4-5-20251001"], + }); + + const session = createPickSession( + "claude", + [priorityZeroProvider, fallbackProvider], + "claude-opus-4-5-20251001" + ); + + const { provider: picked, context } = await (ProxyProviderResolver as any).pickRandomProvider( + session, + [] + ); + + expect(picked?.id).toBe(41); + expect(context.selectedPriority).toBe(1); + const mismatch = context.filteredProviders.find( + (fp: any) => fp.id === 40 && fp.reason === "model_not_allowed" + ); + expect(mismatch).toBeDefined(); + }); +}); diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts new file mode 100644 index 000000000..d57a5baac --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -0,0 +1,666 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; + +const mocks = vi.hoisted(() => ({ + pickRandomProviderWithExclusion: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn(async () => {}), + getCircuitState: vi.fn(() => "closed"), + getProviderHealthInfo: vi.fn(async () => ({ + health: { failureCount: 0 }, + config: { failureThreshold: 3 }, + })), + updateSessionBindingSmart: vi.fn(async () => ({ updated: true, reason: "test" })), + updateSessionProvider: vi.fn(async () => {}), + clearSessionProvider: vi.fn(async () => {}), + isHttp2Enabled: vi.fn(async () => false), + getPreferredProviderEndpoints: vi.fn(async () => []), + getEndpointFilterStats: vi.fn(async () => null), + recordEndpointSuccess: vi.fn(async () => {}), + recordEndpointFailure: vi.fn(async () => {}), + isVendorTypeCircuitOpen: vi.fn(async () => false), + recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}), + categorizeErrorAsync: vi.fn(async () => 0), + storeSessionSpecialSettings: vi.fn(async () => {}), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isHttp2Enabled: mocks.isHttp2Enabled, + }; +}); + +vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({ + getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints, + getEndpointFilterStats: mocks.getEndpointFilterStats, +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + recordEndpointSuccess: mocks.recordEndpointSuccess, + recordEndpointFailure: mocks.recordEndpointFailure, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + getCircuitState: mocks.getCircuitState, + getProviderHealthInfo: mocks.getProviderHealthInfo, + recordFailure: mocks.recordFailure, + recordSuccess: mocks.recordSuccess, +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ + isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen, + recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout, +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + updateSessionBindingSmart: mocks.updateSessionBindingSmart, + updateSessionProvider: mocks.updateSessionProvider, + clearSessionProvider: mocks.clearSessionProvider, + storeSessionSpecialSettings: mocks.storeSessionSpecialSettings, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ + ProxyProviderResolver: { + pickRandomProviderWithExclusion: mocks.pickRandomProviderWithExclusion, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + categorizeErrorAsync: mocks.categorizeErrorAsync, + }; +}); + +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +type AttemptRuntime = { + clearResponseTimeout?: () => void; + responseController?: AbortController; +}; + +function createProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "p1", + url: "https://provider.example.com", + key: "k", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: 1, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 100, + streamingIdleTimeoutMs: 0, + requestTimeoutNonStreamingMs: 0, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + codexServiceTierPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function createSession(clientAbortSignal: AbortSignal | null = null): ProxySession { + const headers = new Headers(); + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl: new URL("https://example.com/v1/messages"), + headers, + originalHeaders: new Headers(headers), + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { + model: "claude-test", + log: "(test)", + message: { + model: "claude-test", + stream: true, + messages: [{ role: "user", content: "hi" }], + }, + }, + userAgent: null, + context: null, + clientAbortSignal, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: null, + sessionId: "sess-hedge", + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/messages"), + isHeaderModified: () => false, + }); + + return session as ProxySession; +} + +function createStreamingResponse(params: { + label: string; + firstChunkDelayMs: number; + controller: AbortController; +}): Response { + const encoder = new TextEncoder(); + let timeoutId: ReturnType | null = null; + + const stream = new ReadableStream({ + start(controller) { + const onAbort = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + controller.close(); + }; + + if (params.controller.signal.aborted) { + onAbort(); + return; + } + + params.controller.signal.addEventListener("abort", onAbort, { once: true }); + timeoutId = setTimeout(() => { + if (params.controller.signal.aborted) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(`data: {"provider":"${params.label}"}\n\n`)); + controller.close(); + }, params.firstChunkDelayMs); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +describe("ProxyForwarder - first-byte hedge scheduling", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("first provider exceeds first-byte threshold, second provider starts and wins by first chunk", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 220, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 40, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(50); + const response = await responsePromise; + expect(await response.text()).toContain('"provider":"p2"'); + expect(controller1.signal.aborted).toBe(true); + expect(controller2.signal.aborted).toBe(false); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(2); + expect(mocks.updateSessionBindingSmart).toHaveBeenCalledWith("sess-hedge", 2, 0, false, true); + } finally { + vi.useRealTimers(); + } + }); + + test("first provider can still win after hedge started if it emits first chunk earlier than fallback", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 140, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 120, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(45); + const response = await responsePromise; + expect(await response.text()).toContain('"provider":"p1"'); + expect(controller1.signal.aborted).toBe(false); + expect(controller2.signal.aborted).toBe(true); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + test("when multiple providers all exceed threshold, hedge scheduler keeps expanding until a later provider wins", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const provider3 = createProvider({ id: 3, name: "p3", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion + .mockResolvedValueOnce(provider2) + .mockResolvedValueOnce(provider3); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const controller3 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 400, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 400, + controller: controller2, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller3; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p3", + firstChunkDelayMs: 20, + controller: controller3, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(200); + expect(doForward).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(25); + const response = await responsePromise; + expect(await response.text()).toContain('"provider":"p3"'); + expect(controller1.signal.aborted).toBe(true); + expect(controller2.signal.aborted).toBe(true); + expect(controller3.signal.aborted).toBe(false); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + test("client abort before any winner should abort all in-flight attempts, return 499, and clear sticky provider binding", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const clientAbortController = new AbortController(); + const session = createSession(clientAbortController.signal); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 500, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 500, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + const rejection = expect(responsePromise).rejects.toMatchObject({ + statusCode: 499, + }); + + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); + + clientAbortController.abort(new Error("client_cancelled")); + await vi.runAllTimersAsync(); + + await rejection; + expect(controller1.signal.aborted).toBe(true); + expect(controller2.signal.aborted).toBe(true); + expect(mocks.clearSessionProvider).toHaveBeenCalledWith("sess-hedge"); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("hedge launcher rejection should settle request instead of hanging", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockRejectedValueOnce(new Error("selector down")); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 500, + controller: controller1, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + const rejection = expect(responsePromise).rejects.toMatchObject({ + statusCode: 503, + }); + + await vi.advanceTimersByTimeAsync(100); + await vi.runAllTimersAsync(); + + await rejection; + expect(controller1.signal.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + test("strict endpoint pool errors should reject with sanitized ProxyError instead of raw selector error", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ + id: 1, + name: "p1", + providerType: "claude", + providerVendorId: 123, + firstByteTimeoutStreamingMs: 100, + }); + const session = createSession(); + session.requestUrl = new URL("https://example.com/v1/messages"); + session.setProvider(provider1); + + mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("Redis connection lost")); + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(null); + + const responsePromise = ProxyForwarder.send(session); + const rejection = expect(responsePromise).rejects.toMatchObject({ + statusCode: 503, + message: "No available provider endpoints", + }); + + await vi.runAllTimersAsync(); + await rejection; + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("endpoint resolution failure should not inflate launchedProviderCount, winner gets request_success not hedge_winner", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ + id: 1, + name: "p1", + providerVendorId: 123, + firstByteTimeoutStreamingMs: 100, + }); + const provider2 = createProvider({ + id: 2, + name: "p2", + providerVendorId: null, + firstByteTimeoutStreamingMs: 100, + }); + const session = createSession(); + session.requestUrl = new URL("https://example.com/v1/messages"); + session.setProvider(provider1); + + // Provider 1's strict endpoint resolution will fail + mocks.getPreferredProviderEndpoints.mockRejectedValueOnce( + new Error("Endpoint resolution failed") + ); + + // After provider 1 fails, pick provider 2 as alternative + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller2 = new AbortController(); + + // Only provider 2 reaches doForward (provider 1 fails at endpoint resolution) + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 10, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(200); + const response = await responsePromise; + + expect(await response.text()).toContain('"provider":"p2"'); + expect(session.provider?.id).toBe(2); + + // Key assertion: since only provider 2 actually launched (provider 1 failed at + // endpoint resolution before incrementing launchedProviderCount), the winner + // should be classified as "request_success" not "hedge_winner". + const chain = session.getProviderChain(); + const winnerEntry = chain.find( + (entry) => entry.reason === "request_success" || entry.reason === "hedge_winner" + ); + expect(winnerEntry).toBeDefined(); + expect(winnerEntry!.reason).toBe("request_success"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts new file mode 100644 index 000000000..2f1ba391e --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isHttp2Enabled: vi.fn(async () => false), + getCachedSystemSettings: vi.fn(async () => ({ + enableClaudeMetadataUserIdInjection: false, + enableBillingHeaderRectifier: false, + })), + getProxyAgentForProvider: vi.fn(async () => null), + getGlobalAgentPool: vi.fn(() => ({ + getAgent: vi.fn(), + markOriginUnhealthy: vi.fn(), + })), +})); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isHttp2Enabled: mocks.isHttp2Enabled, + getCachedSystemSettings: mocks.getCachedSystemSettings, + }; +}); + +vi.mock("@/lib/proxy-agent", () => ({ + getProxyAgentForProvider: mocks.getProxyAgentForProvider, + getGlobalAgentPool: mocks.getGlobalAgentPool, +})); + +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +function createProvider(): Provider { + return { + id: 1, + name: "codex-upstream", + providerType: "codex", + url: "https://upstream.example.com/v1/responses", + key: "upstream-key", + preserveClientIp: false, + priority: 0, + maxRetryAttempts: 1, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + } as unknown as Provider; +} + +function createRawPassthroughSession(bodyText: string, extraHeaders?: HeadersInit): ProxySession { + const headers = new Headers({ + "content-type": "application/json", + "content-length": String(new TextEncoder().encode(bodyText).byteLength), + ...Object.fromEntries(new Headers(extraHeaders).entries()), + }); + const originalHeaders = new Headers(headers); + const specialSettings: unknown[] = []; + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl: new URL("https://proxy.example.com/v1/responses/compact?stream=false"), + headers, + originalHeaders, + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { + model: "gpt-5", + log: bodyText, + message: JSON.parse(bodyText) as Record, + buffer: new TextEncoder().encode(bodyText).buffer, + }, + userAgent: "CodexTest/1.0", + context: null, + clientAbortSignal: null, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: null, + sessionId: null, + requestSequence: 1, + originalFormat: "openai", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + forwardedRequestBody: null, + endpointPolicy: resolveEndpointPolicy("/v1/responses/compact"), + setCacheTtlResolved: vi.fn(), + getCacheTtlResolved: vi.fn(() => null), + getCurrentModel: vi.fn(() => "gpt-5"), + clientRequestsContext1m: vi.fn(() => false), + setContext1mApplied: vi.fn(), + getContext1mApplied: vi.fn(() => false), + getEndpointPolicy: vi.fn(() => resolveEndpointPolicy("/v1/responses/compact")), + addSpecialSetting: vi.fn((setting: unknown) => { + specialSettings.push(setting); + }), + getSpecialSettings: vi.fn(() => specialSettings), + isHeaderModified: vi.fn((key: string) => originalHeaders.get(key) !== headers.get(key)), + }); + + return session as ProxySession; +} + +function readBodyText(body: BodyInit | undefined): string | null { + if (body == null) return null; + if (typeof body === "string") return body; + if (body instanceof ArrayBuffer) { + return new TextDecoder().decode(body); + } + if (ArrayBuffer.isView(body)) { + return new TextDecoder().decode(body); + } + throw new Error(`Unsupported body type: ${Object.prototype.toString.call(body)}`); +} + +describe("ProxyForwarder raw passthrough regression", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("raw passthrough 应优先保留原始请求体字节,而不是重新 JSON.stringify", async () => { + const originalBody = '{\n "model": "gpt-5",\n "input": [1, 2, 3]\n}\n'; + const session = createRawPassthroughSession(originalBody); + const provider = createProvider(); + + let capturedInit: { body?: BodyInit; headers?: HeadersInit } | null = null; + const fetchWithoutAutoDecode = vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode"); + fetchWithoutAutoDecode.mockImplementationOnce(async (_url: string, init: RequestInit) => { + capturedInit = { body: init.body ?? undefined, headers: init.headers ?? undefined }; + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json", "content-length": "2" }, + }); + }); + + const { doForward } = ProxyForwarder as unknown as { + doForward: (session: ProxySession, provider: Provider, baseUrl: string) => Promise; + }; + + await doForward(session, provider, provider.url); + + expect(readBodyText(capturedInit?.body)).toBe(originalBody); + }); + + it("raw passthrough 出站请求不得继续携带 transfer-encoding 这类 hop-by-hop 头", async () => { + const body = '{"model":"gpt-5","input":[]}'; + const session = createRawPassthroughSession(body, { + connection: "keep-alive", + "transfer-encoding": "chunked", + }); + const provider = createProvider(); + + let capturedHeaders: Headers | null = null; + const fetchWithoutAutoDecode = vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode"); + fetchWithoutAutoDecode.mockImplementationOnce(async (_url: string, init: RequestInit) => { + capturedHeaders = new Headers(init.headers); + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json", "content-length": "2" }, + }); + }); + + const { doForward } = ProxyForwarder as unknown as { + doForward: (session: ProxySession, provider: Provider, baseUrl: string) => Promise; + }; + + await doForward(session, provider, provider.url); + + expect(capturedHeaders?.get("connection")).toBeNull(); + expect(capturedHeaders?.get("transfer-encoding")).toBeNull(); + }); +}); diff --git a/tests/unit/proxy/proxy-forwarder.test.ts b/tests/unit/proxy/proxy-forwarder.test.ts index 8eef4ffbf..00ad3ff94 100644 --- a/tests/unit/proxy/proxy-forwarder.test.ts +++ b/tests/unit/proxy/proxy-forwarder.test.ts @@ -153,6 +153,28 @@ describe("ProxyForwarder - buildHeaders User-Agent resolution", () => { // 空字符串应该被保留(使用 ?? 而非 ||) expect(resultHeaders.get("user-agent")).toBe(""); }); + + it("应该剥离 transfer-encoding 这类传输层 header,避免向上游继续透传", () => { + const session = createSession({ + userAgent: "Original-UA/1.0", + headers: new Headers([ + ["user-agent", "Original-UA/1.0"], + ["connection", "keep-alive"], + ["transfer-encoding", "chunked"], + ["content-length", "123"], + ]), + }); + + const provider = createCodexProvider(); + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const resultHeaders = buildHeaders(session, provider); + + expect(resultHeaders.get("connection")).toBeNull(); + expect(resultHeaders.get("transfer-encoding")).toBeNull(); + expect(resultHeaders.get("content-length")).toBeNull(); + }); }); describe("ProxyForwarder - buildGeminiHeaders headers passthrough", () => { @@ -307,4 +329,38 @@ describe("ProxyForwarder - buildGeminiHeaders headers passthrough", () => { expect(resultHeaders.get("x-goog-api-client")).toBe("GeminiCLI/1.0"); }); + + it("Gemini 路径也应该剥离 transfer-encoding,避免请求体透传回归污染上游", () => { + const session = createSession({ + userAgent: "Original-UA/1.0", + headers: new Headers([ + ["user-agent", "Original-UA/1.0"], + ["connection", "keep-alive"], + ["transfer-encoding", "chunked"], + ["content-length", "123"], + ]), + }); + + const provider = createGeminiProvider("gemini"); + const { buildGeminiHeaders } = ProxyForwarder as unknown as { + buildGeminiHeaders: ( + session: ProxySession, + provider: Provider, + baseUrl: string, + accessToken: string, + isApiKey: boolean + ) => Headers; + }; + const resultHeaders = buildGeminiHeaders( + session, + provider, + "https://generativelanguage.googleapis.com/v1beta", + "upstream-api-key", + true + ); + + expect(resultHeaders.get("connection")).toBeNull(); + expect(resultHeaders.get("transfer-encoding")).toBeNull(); + expect(resultHeaders.get("content-length")).toBeNull(); + }); }); diff --git a/tests/unit/proxy/rate-limit-guard.test.ts b/tests/unit/proxy/rate-limit-guard.test.ts index 176c4cc98..aba3d2f9b 100644 --- a/tests/unit/proxy/rate-limit-guard.test.ts +++ b/tests/unit/proxy/rate-limit-guard.test.ts @@ -163,6 +163,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_time: "00:00", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); }); @@ -223,6 +224,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_mode: "fixed", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); }); @@ -566,6 +568,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_mode: "rolling", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); // checkUserDailyCost should NOT be called (migrated to lease) diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index e3e83fbd7..fc0783f5f 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -58,6 +58,7 @@ vi.mock("@/lib/session-manager", () => ({ SessionManager: { updateSessionUsage: vi.fn(), storeSessionResponse: vi.fn(), + clearSessionProvider: vi.fn(), extractCodexPromptCacheKey: vi.fn(), updateSessionWithCodexCacheKey: vi.fn(), }, @@ -162,7 +163,7 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { key, apiKey: "sk-test", }, - sessionId: opts?.sessionId ?? null, + sessionId: opts?.sessionId ?? "fake-session", requestSequence: 1, originalFormat: "claude", providerType: null, @@ -325,6 +326,7 @@ function setupCommonMocks() { vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(SessionManager.clearSessionProvider).mockResolvedValue(undefined); vi.mocked(RateLimitService.trackCost).mockResolvedValue(undefined); vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); vi.mocked(RateLimitService.decrementLeaseBudget).mockResolvedValue({ @@ -360,6 +362,7 @@ describe("Endpoint circuit breaker isolation", () => { expect.objectContaining({ message: expect.stringContaining("FAKE_200") }) ); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + expect(SessionManager.clearSessionProvider).toHaveBeenCalledWith("fake-session"); const chain = session.getProviderChain(); expect( @@ -383,6 +386,7 @@ describe("Endpoint circuit breaker isolation", () => { expect(mockRecordFailure).not.toHaveBeenCalled(); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + expect(SessionManager.clearSessionProvider).toHaveBeenCalledWith("fake-session"); const chain = session.getProviderChain(); expect( diff --git a/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts b/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts index afc42b326..567d34827 100644 --- a/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts +++ b/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts @@ -5,6 +5,7 @@ import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { SessionManager } from "@/lib/session-manager"; import type { Provider } from "@/types/provider"; const asyncTasks: Promise[] = []; @@ -75,6 +76,12 @@ vi.mock("@/lib/session-manager", () => ({ SessionManager: { storeSessionResponse: vi.fn(), updateSessionUsage: vi.fn(), + clearSessionProvider: vi.fn(), + storeSessionUpstreamRequestMeta: vi.fn(async () => undefined), + storeSessionSpecialSettings: vi.fn(async () => undefined), + storeSessionRequestHeaders: vi.fn(async () => undefined), + storeSessionResponseHeaders: vi.fn(async () => undefined), + storeSessionUpstreamResponseMeta: vi.fn(async () => undefined), }, })); @@ -457,4 +464,74 @@ describe("ProxyResponseHandler - Gemini stream passthrough timeouts", () => { await Promise.allSettled(asyncTasks); } }); + + test("客户端中断流式透传后应清理 session provider 绑定,避免下次继续复用旧供应商", async () => { + asyncTasks.length = 0; + const { baseUrl, close } = await startSseServer((_req, res) => { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.flushHeaders(); + res.write('data: {"x":1}\n\n'); + setTimeout(() => { + try { + res.write('data: {"x":2}\n\n'); + } catch { + // ignore + } + }, 1000); + }); + + const clientAbortController = new AbortController(); + vi.mocked(SessionManager.clearSessionProvider).mockResolvedValue(undefined); + + try { + const provider = createProvider({ + url: baseUrl, + firstByteTimeoutStreamingMs: 1000, + streamingIdleTimeoutMs: 0, + }); + const session = createSession({ + clientAbortSignal: clientAbortController.signal, + messageId: 4, + userId: 1, + }); + session.setProvider(provider); + session.setSessionId("gemini-abort-session"); + + const doForward = ( + ProxyForwarder as unknown as { + doForward: (this: typeof ProxyForwarder, ...args: unknown[]) => unknown; + } + ).doForward; + + const upstreamResponse = (await doForward.call( + ProxyForwarder, + session, + provider, + baseUrl + )) as Response; + + const clientResponse = await ProxyResponseHandler.dispatch(session, upstreamResponse); + const reader = clientResponse.body?.getReader(); + expect(reader).toBeTruthy(); + if (!reader) throw new Error("Missing body reader"); + + const first = await reader.read(); + expect(first.done).toBe(false); + + clientAbortController.abort(new Error("client_cancelled")); + await Promise.allSettled(asyncTasks); + + expect(vi.mocked(SessionManager.clearSessionProvider)).toHaveBeenCalledWith( + "gemini-abort-session" + ); + } finally { + clientAbortController.abort(new Error("test_cleanup")); + await close(); + await Promise.allSettled(asyncTasks); + } + }); }); diff --git a/tests/unit/proxy/response-handler-lease-decrement.test.ts b/tests/unit/proxy/response-handler-lease-decrement.test.ts index 1100256ef..a3a6a3f98 100644 --- a/tests/unit/proxy/response-handler-lease-decrement.test.ts +++ b/tests/unit/proxy/response-handler-lease-decrement.test.ts @@ -158,13 +158,19 @@ function createSession(opts: { specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + resolvedPricingCache: new Map(), endpointPolicy: resolveEndpointPolicy("/v1/messages"), isHeaderModified: () => false, getContext1mApplied: () => false, getOriginalModel: () => originalModel, getCurrentModel: () => redirectedModel, getProviderChain: () => [], - getCachedPriceDataByBillingSource: async () => testPriceData, + getResolvedPricingByBillingSource: async () => ({ + resolvedModelName: redirectedModel, + resolvedPricingProviderKey: "test-provider", + source: "cloud_exact" as const, + priceData: testPriceData, + }), recordTtfb: () => 100, ttfbMs: null, getRequestSequence: () => 1, @@ -374,10 +380,22 @@ describe("Lease Budget Decrement after trackCostToRedis", () => { messageId: 5003, }); - // Override getCachedPriceDataByBillingSource to return zero prices + // Override getResolvedPricingByBillingSource to return zero prices ( - session as { getCachedPriceDataByBillingSource: () => Promise } - ).getCachedPriceDataByBillingSource = async () => zeroPriceData; + session as { + getResolvedPricingByBillingSource: () => Promise<{ + resolvedModelName: string; + resolvedPricingProviderKey: string; + source: string; + priceData: ModelPriceData; + }>; + } + ).getResolvedPricingByBillingSource = async () => ({ + resolvedModelName: originalModel, + resolvedPricingProviderKey: "test-provider", + source: "cloud_exact" as const, + priceData: zeroPriceData, + }); const response = createNonStreamResponse(usage); await ProxyResponseHandler.dispatch(session, response); diff --git a/tests/unit/proxy/response-input-rectifier.test.ts b/tests/unit/proxy/response-input-rectifier.test.ts new file mode 100644 index 000000000..a77922d96 --- /dev/null +++ b/tests/unit/proxy/response-input-rectifier.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from "vitest"; +import { + normalizeResponseInput, + rectifyResponseInput, +} from "@/app/v1/_lib/proxy/response-input-rectifier"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { SpecialSetting } from "@/types/special-settings"; + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: vi.fn(), +})); +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); +const getCachedMock = vi.mocked(getCachedSystemSettings); + +function createMockSession(input: unknown): { + session: ProxySession; + specialSettings: SpecialSetting[]; +} { + const specialSettings: SpecialSetting[] = []; + const session = { + request: { message: { model: "gpt-4o", input } }, + sessionId: "sess_test", + addSpecialSetting: (s: SpecialSetting) => specialSettings.push(s), + } as unknown as ProxySession; + return { session, specialSettings }; +} + +describe("rectifyResponseInput", () => { + // --- Passthrough cases --- + + it("passes through array input unchanged", () => { + const message: Record = { + model: "gpt-4o", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + }; + const original = message.input; + + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "array" }); + expect(message.input).toBe(original); + }); + + it("passes through empty array input unchanged", () => { + const message: Record = { input: [] }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "array" }); + expect(message.input).toEqual([]); + }); + + it("passes through undefined input", () => { + const message: Record = { model: "gpt-4o" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + expect(message.input).toBeUndefined(); + }); + + it("passes through null input", () => { + const message: Record = { input: null }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + expect(message.input).toBeNull(); + }); + + // --- String normalization --- + + it("normalizes non-empty string to user message array", () => { + const message: Record = { model: "gpt-4o", input: "hello world" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "string_to_array", originalType: "string" }); + expect(message.input).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: "hello world" }], + }, + ]); + }); + + it("normalizes empty string to empty array", () => { + const message: Record = { input: "" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ + applied: true, + action: "empty_string_to_empty_array", + originalType: "string", + }); + expect(message.input).toEqual([]); + }); + + it("normalizes whitespace-only string to user message (not empty)", () => { + const message: Record = { input: " " }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "string_to_array", originalType: "string" }); + expect(message.input).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: " " }], + }, + ]); + }); + + it("normalizes multiline string", () => { + const message: Record = { input: "line1\nline2\nline3" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "string_to_array", originalType: "string" }); + expect(message.input).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: "line1\nline2\nline3" }], + }, + ]); + }); + + // --- Object normalization --- + + it("wraps single MessageInput (has role) into array", () => { + const inputObj = { role: "user", content: [{ type: "input_text", text: "hi" }] }; + const message: Record = { input: inputObj }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "object_to_array", originalType: "object" }); + expect(message.input).toEqual([inputObj]); + }); + + it("wraps single ToolOutputsInput (has type) into array", () => { + const inputObj = { type: "function_call_output", call_id: "call_123", output: "result" }; + const message: Record = { input: inputObj }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "object_to_array", originalType: "object" }); + expect(message.input).toEqual([inputObj]); + }); + + it("passes through object without role or type", () => { + const message: Record = { input: { foo: "bar", baz: 42 } }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + }); + + // --- Edge cases --- + + it("does not modify other message fields", () => { + const message: Record = { + model: "gpt-4o", + input: "hello", + temperature: 0.7, + stream: true, + }; + rectifyResponseInput(message); + + expect(message.model).toBe("gpt-4o"); + expect(message.temperature).toBe(0.7); + expect(message.stream).toBe(true); + }); + + it("passes through numeric input as other", () => { + const message: Record = { input: 42 }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + }); + + it("passes through boolean input as other", () => { + const message: Record = { input: true }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + }); +}); + +describe("normalizeResponseInput", () => { + it("normalizes string input and records audit when enabled", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: true } as any); + + const { session, specialSettings } = createMockSession("hello"); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "hello" }] }, + ]); + expect(specialSettings).toHaveLength(1); + expect(specialSettings[0]).toMatchObject({ + type: "response_input_rectifier", + hit: true, + action: "string_to_array", + originalType: "string", + }); + }); + + it("skips normalization when feature is disabled", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: false } as any); + + const { session, specialSettings } = createMockSession("hello"); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toBe("hello"); + expect(specialSettings).toHaveLength(0); + }); + + it("does not record audit for passthrough (array input)", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: true } as any); + + const arrayInput = [{ role: "user", content: [{ type: "input_text", text: "hi" }] }]; + const { session, specialSettings } = createMockSession(arrayInput); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toBe(arrayInput); + expect(specialSettings).toHaveLength(0); + }); + + it("wraps single object input and records audit when enabled", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: true } as any); + + const inputObj = { role: "user", content: [{ type: "input_text", text: "hi" }] }; + const { session, specialSettings } = createMockSession(inputObj); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toEqual([inputObj]); + expect(specialSettings).toHaveLength(1); + expect(specialSettings[0]).toMatchObject({ + type: "response_input_rectifier", + action: "object_to_array", + originalType: "object", + }); + }); +}); diff --git a/tests/unit/repository/leaderboard-user-model-stats.test.ts b/tests/unit/repository/leaderboard-user-model-stats.test.ts new file mode 100644 index 000000000..37da7ba37 --- /dev/null +++ b/tests/unit/repository/leaderboard-user-model-stats.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for user leaderboard modelStats (per-user model breakdown). + * + * Key difference from provider scope: null model rows are PRESERVED + * (provider scope at line 570 has `if (!row.model) continue;`). + */ + +const createChainMock = (resolvedData: unknown[]) => ({ + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + groupBy: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue(resolvedData), +}); + +let selectCallIndex = 0; +let chainMocks: ReturnType[] = []; + +const mockSelect = vi.fn(() => { + const chain = chainMocks[selectCallIndex] ?? createChainMock([]); + selectCallIndex++; + return chain; +}); + +const mocks = vi.hoisted(() => ({ + resolveSystemTimezone: vi.fn(), + getSystemSettings: vi.fn(), +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: (...args: unknown[]) => mockSelect(...args), + }, +})); + +vi.mock("@/drizzle/schema", () => ({ + usageLedger: { + providerId: "providerId", + finalProviderId: "finalProviderId", + userId: "userId", + costUsd: "costUsd", + inputTokens: "inputTokens", + outputTokens: "outputTokens", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + isSuccess: "isSuccess", + blockedBy: "blockedBy", + createdAt: "createdAt", + ttfbMs: "ttfbMs", + durationMs: "durationMs", + model: "model", + originalModel: "originalModel", + }, + messageRequest: { + deletedAt: "deletedAt", + providerId: "providerId", + userId: "userId", + costUsd: "costUsd", + inputTokens: "inputTokens", + outputTokens: "outputTokens", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + errorMessage: "errorMessage", + blockedBy: "blockedBy", + createdAt: "createdAt", + ttfbMs: "ttfbMs", + durationMs: "durationMs", + model: "model", + originalModel: "originalModel", + }, + providers: { + id: "id", + name: "name", + deletedAt: "deletedAt", + providerType: "providerType", + }, + users: { + id: "id", + name: "name", + deletedAt: "deletedAt", + tags: "tags", + providerGroup: "providerGroup", + }, +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: mocks.resolveSystemTimezone, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, +})); + +describe("User Leaderboard Model Stats", () => { + beforeEach(() => { + vi.resetModules(); + selectCallIndex = 0; + chainMocks = []; + mockSelect.mockClear(); + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + }); + + it("includes modelStats when includeModelStats=true", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 100, + totalCost: "10.0", + totalTokens: 50000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "claude-sonnet-4", + totalRequests: 60, + totalCost: "6.0", + totalTokens: 30000, + }, + { + userId: 1, + model: "claude-opus-4", + totalRequests: 40, + totalCost: "4.0", + totalTokens: 20000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].modelStats).toBeDefined(); + expect(result[0].modelStats).toHaveLength(2); + expect(result[0].modelStats![0].model).toBe("claude-sonnet-4"); + expect(result[0].modelStats![0].totalCost).toBe(6.0); + expect(result[0].modelStats![1].model).toBe("claude-opus-4"); + expect(result[0].modelStats![1].totalCost).toBe(4.0); + }); + + it("preserves null model rows (unlike provider scope)", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "bob", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "claude-sonnet-4", + totalRequests: 30, + totalCost: "3.0", + totalTokens: 15000, + }, + { + userId: 1, + model: null, + totalRequests: 20, + totalCost: "2.0", + totalTokens: 10000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].modelStats).toHaveLength(2); + + const nullModelStat = result[0].modelStats!.find((s) => s.model === null); + expect(nullModelStat).toBeDefined(); + expect(nullModelStat!.totalRequests).toBe(20); + expect(nullModelStat!.totalCost).toBe(2.0); + }); + + it("does not include modelStats when includeModelStats is false/undefined", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "carol", + totalRequests: 10, + totalCost: "1.0", + totalTokens: 5000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + + const resultDefault = await findDailyLeaderboard(); + expect(resultDefault[0].modelStats).toBeUndefined(); + expect(mockSelect).toHaveBeenCalledTimes(1); + + selectCallIndex = 0; + mockSelect.mockClear(); + + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "carol", + totalRequests: 10, + totalCost: "1.0", + totalTokens: 5000, + }, + ]), + ]; + + const resultFalse = await findDailyLeaderboard(undefined, false); + expect(resultFalse[0].modelStats).toBeUndefined(); + expect(mockSelect).toHaveBeenCalledTimes(1); + }); + + it("groups model stats correctly by userId", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 80, + totalCost: "8.0", + totalTokens: 40000, + }, + { + userId: 2, + userName: "bob", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "claude-sonnet-4", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + { + userId: 1, + model: "claude-opus-4", + totalRequests: 30, + totalCost: "3.0", + totalTokens: 15000, + }, + { + userId: 2, + model: "claude-haiku-3.5", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(2); + + const alice = result.find((r) => r.userId === 1); + expect(alice).toBeDefined(); + expect(alice!.modelStats).toHaveLength(2); + const aliceModels = alice!.modelStats!.map((m) => m.model).sort(); + expect(aliceModels).toEqual(["claude-opus-4", "claude-sonnet-4"]); + + const bob = result.find((r) => r.userId === 2); + expect(bob).toBeDefined(); + expect(bob!.modelStats).toHaveLength(1); + expect(bob!.modelStats![0].model).toBe("claude-haiku-3.5"); + }); + + it("orders model stats by totalCost descending", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 100, + totalCost: "15.0", + totalTokens: 75000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "expensive-model", + totalRequests: 30, + totalCost: "10.0", + totalTokens: 30000, + }, + { + userId: 1, + model: "cheap-model", + totalRequests: 70, + totalCost: "5.0", + totalTokens: 45000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(1); + const stats = result[0].modelStats!; + expect(stats).toHaveLength(2); + expect(stats[0].totalCost).toBeGreaterThanOrEqual(stats[1].totalCost); + expect(stats[0].model).toBe("expensive-model"); + expect(stats[1].model).toBe("cheap-model"); + }); +}); diff --git a/tests/unit/repository/statistics-reset-at.test.ts b/tests/unit/repository/statistics-reset-at.test.ts new file mode 100644 index 000000000..88817749c --- /dev/null +++ b/tests/unit/repository/statistics-reset-at.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// dbResultMock controls what every DB chain resolves to when awaited +const dbResultMock = vi.fn<[], unknown>().mockReturnValue([{ total: 0 }]); + +// Build a chainable mock that resolves to dbResultMock() on await +function chain(): Record { + const obj: Record = {}; + for (const method of ["select", "from", "where", "groupBy", "limit"]) { + obj[method] = vi.fn(() => chain()); + } + // Make it thenable so `await db.select().from().where()` works + // biome-ignore lint/suspicious/noThenProperty: thenable mock for drizzle query chain + obj.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) => { + try { + resolve(dbResultMock()); + } catch (e) { + reject(e); + } + }; + return obj; +} + +vi.mock("@/drizzle/db", () => ({ + db: chain(), +})); + +// Mock drizzle schema -- preserve all exports so module-level sql`` calls work +vi.mock("@/drizzle/schema", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +describe("statistics resetAt parameter", () => { + beforeEach(() => { + vi.clearAllMocks(); + dbResultMock.mockReturnValue([{ total: 0 }]); + }); + + describe("sumUserTotalCost", () => { + test("with valid resetAt -- queries DB and returns cost", async () => { + const resetAt = new Date("2026-02-15T00:00:00Z"); + dbResultMock.mockReturnValue([{ total: 42.5 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, resetAt); + + expect(result).toBe(42.5); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("without resetAt -- uses maxAgeDays cutoff instead", async () => { + dbResultMock.mockReturnValue([{ total: 100.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365); + + expect(result).toBe(100.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("with null resetAt -- treated same as undefined", async () => { + dbResultMock.mockReturnValue([{ total: 50.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, null); + + expect(result).toBe(50.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("with invalid Date (NaN) -- skips resetAt, falls through to maxAgeDays", async () => { + const invalidDate = new Date("invalid"); + dbResultMock.mockReturnValue([{ total: 75.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, invalidDate); + + expect(result).toBe(75.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + }); + + describe("sumKeyTotalCost", () => { + test("with valid resetAt -- uses resetAt instead of maxAgeDays cutoff", async () => { + const resetAt = new Date("2026-02-20T00:00:00Z"); + dbResultMock.mockReturnValue([{ total: 15.0 }]); + + const { sumKeyTotalCost } = await import("@/repository/statistics"); + const result = await sumKeyTotalCost("sk-hash", 365, resetAt); + + expect(result).toBe(15.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("without resetAt -- falls back to maxAgeDays", async () => { + dbResultMock.mockReturnValue([{ total: 30.0 }]); + + const { sumKeyTotalCost } = await import("@/repository/statistics"); + const result = await sumKeyTotalCost("sk-hash", 365); + + expect(result).toBe(30.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + }); + + describe("sumUserTotalCostBatch", () => { + test("with resetAtMap -- splits users: individual queries for reset users", async () => { + const resetAtMap = new Map([[10, new Date("2026-02-15T00:00:00Z")]]); + // Calls: 1) individual sumUserTotalCost(10) => where => [{ total: 25 }] + // 2) batch for user 20 => groupBy => [{ userId: 20, total: 50 }] + dbResultMock + .mockReturnValueOnce([{ total: 25.0 }]) + .mockReturnValueOnce([{ userId: 20, total: 50.0 }]); + + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([10, 20], 365, resetAtMap); + + expect(result.get(10)).toBe(25.0); + expect(result.get(20)).toBe(50.0); + }); + + test("with empty resetAtMap -- single batch query for all users", async () => { + dbResultMock.mockReturnValue([ + { userId: 10, total: 25.0 }, + { userId: 20, total: 50.0 }, + ]); + + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([10, 20], 365, new Map()); + + expect(result.get(10)).toBe(25.0); + expect(result.get(20)).toBe(50.0); + }); + + test("empty userIds -- returns empty map immediately", async () => { + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([], 365); + + expect(result.size).toBe(0); + }); + }); + + describe("sumKeyTotalCostBatchByIds", () => { + test("with resetAtMap -- splits keys into individual vs batch", async () => { + const resetAtMap = new Map([[1, new Date("2026-02-15T00:00:00Z")]]); + dbResultMock + // 1) PK lookup: key strings + .mockReturnValueOnce([ + { id: 1, key: "sk-a" }, + { id: 2, key: "sk-b" }, + ]) + // 2) individual sumKeyTotalCost for key 1 + .mockReturnValueOnce([{ total: 10.0 }]) + // 3) batch for key 2 + .mockReturnValueOnce([{ key: "sk-b", total: 20.0 }]); + + const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics"); + const result = await sumKeyTotalCostBatchByIds([1, 2], 365, resetAtMap); + + expect(result.get(1)).toBe(10.0); + expect(result.get(2)).toBe(20.0); + }); + + test("empty keyIds -- returns empty map immediately", async () => { + const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics"); + const result = await sumKeyTotalCostBatchByIds([], 365); + + expect(result.size).toBe(0); + }); + }); + + describe("sumUserQuotaCosts", () => { + const ranges = { + range5h: { + startTime: new Date("2026-03-01T07:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeDaily: { + startTime: new Date("2026-03-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeWeekly: { + startTime: new Date("2026-02-23T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeMonthly: { + startTime: new Date("2026-02-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + }; + + test("with resetAt -- returns correct cost summary", async () => { + const resetAt = new Date("2026-02-25T00:00:00Z"); + dbResultMock.mockReturnValue([ + { + cost5h: "1.0", + costDaily: "2.0", + costWeekly: "3.0", + costMonthly: "4.0", + costTotal: "5.0", + }, + ]); + + const { sumUserQuotaCosts } = await import("@/repository/statistics"); + const result = await sumUserQuotaCosts(10, ranges, 365, resetAt); + + expect(result.cost5h).toBe(1.0); + expect(result.costDaily).toBe(2.0); + expect(result.costWeekly).toBe(3.0); + expect(result.costMonthly).toBe(4.0); + expect(result.costTotal).toBe(5.0); + }); + + test("without resetAt -- uses only maxAgeDays cutoff", async () => { + dbResultMock.mockReturnValue([ + { cost5h: "0", costDaily: "0", costWeekly: "0", costMonthly: "0", costTotal: "0" }, + ]); + + const { sumUserQuotaCosts } = await import("@/repository/statistics"); + const result = await sumUserQuotaCosts(10, ranges, 365); + + expect(result.cost5h).toBe(0); + expect(result.costTotal).toBe(0); + }); + }); + + describe("sumKeyQuotaCostsById", () => { + test("with resetAt -- same cutoff logic as sumUserQuotaCosts", async () => { + const resetAt = new Date("2026-02-25T00:00:00Z"); + const ranges = { + range5h: { + startTime: new Date("2026-03-01T07:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeDaily: { + startTime: new Date("2026-03-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeWeekly: { + startTime: new Date("2026-02-23T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeMonthly: { + startTime: new Date("2026-02-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + }; + // First: getKeyStringByIdCached lookup, then main query + dbResultMock.mockReturnValueOnce([{ key: "sk-test-hash" }]).mockReturnValueOnce([ + { + cost5h: "2.0", + costDaily: "4.0", + costWeekly: "6.0", + costMonthly: "8.0", + costTotal: "10.0", + }, + ]); + + const { sumKeyQuotaCostsById } = await import("@/repository/statistics"); + const result = await sumKeyQuotaCostsById(42, ranges, 365, resetAt); + + expect(result.cost5h).toBe(2.0); + expect(result.costTotal).toBe(10.0); + }); + }); +}); diff --git a/tests/unit/settings/providers/form-tab-nav.test.tsx b/tests/unit/settings/providers/form-tab-nav.test.tsx index 8dfeec4af..387bcaa30 100644 --- a/tests/unit/settings/providers/form-tab-nav.test.tsx +++ b/tests/unit/settings/providers/form-tab-nav.test.tsx @@ -26,15 +26,25 @@ vi.mock("framer-motion", () => ({ vi.mock("lucide-react", () => { const stub = ({ className }: any) => ; return { + Clock: stub, FileText: stub, Route: stub, Gauge: stub, Network: stub, FlaskConical: stub, + Scale: stub, + Settings: stub, + Shield: stub, + Timer: stub, }; }); -import { FormTabNav } from "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav"; +import { + FormTabNav, + NAV_ORDER, + PARENT_MAP, + TAB_ORDER, +} from "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav"; // --------------------------------------------------------------------------- // Render helper (matches project convention) @@ -71,12 +81,12 @@ describe("FormTabNav", () => { // -- Default (vertical) layout ------------------------------------------- describe("default vertical layout", () => { - it("renders all 5 tabs across 3 responsive breakpoints (15 total)", () => { + it("renders all tabs across 3 responsive breakpoints (22 total when no children on active tab)", () => { const { container, unmount } = render(); - // Desktop (5) + Tablet (5) + Mobile (5) = 15 + // Desktop (10) + Tablet (6) + Mobile (6) = 22 const buttons = container.querySelectorAll("button"); - expect(buttons.length).toBe(15); + expect(buttons.length).toBe(22); unmount(); }); @@ -91,6 +101,129 @@ describe("FormTabNav", () => { unmount(); }); + + it("renders sub-items only in the desktop sidebar", () => { + const { container, unmount } = render(); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + expect(desktopButtons.length).toBe(10); + unmount(); + }); + + it("calls onSubTabChange when a sub-item is clicked", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + act(() => { + desktopButtons[2].click(); + }); + expect(onSubTabChange).toHaveBeenCalledWith("scheduling"); + unmount(); + }); + + it("calls onSubTabChange when the activeTime sub-item is clicked", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const activeTimeButton = Array.from(desktopNav!.querySelectorAll("button")).find((button) => + button.textContent?.includes("tabs.activeTime") + ); + + expect(activeTimeButton).toBeTruthy(); + + act(() => { + activeTimeButton!.click(); + }); + + expect(onSubTabChange).toHaveBeenCalledWith("activeTime"); + unmount(); + }); + + it("highlights active sub-item with text-primary", () => { + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + expect(desktopButtons[2].className).toContain("text-primary"); + unmount(); + }); + + it("renders sub-items in tablet nav when active tab has children", () => { + const { container, unmount } = render(); + const navs = container.querySelectorAll("nav"); + // Second nav is tablet (hidden md:flex md:flex-col lg:hidden) + const tabletNav = navs[1]; + const schedulingBtn = Array.from(tabletNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + expect(schedulingBtn).toBeTruthy(); + unmount(); + }); + + it("renders sub-items in mobile nav when active tab has children", () => { + const { container, unmount } = render(); + const navs = container.querySelectorAll("nav"); + // Third nav is mobile (flex md:hidden) + const mobileNav = navs[2]; + const schedulingBtn = Array.from(mobileNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + expect(schedulingBtn).toBeTruthy(); + unmount(); + }); + + it("does not render sub-items in tablet/mobile when active tab has no children", () => { + const { container, unmount } = render(); + const navs = container.querySelectorAll("nav"); + const tabletNav = navs[1]; + const mobileNav = navs[2]; + // basic has no children, so no sub-item buttons beyond the main 6 + const tabletButtons = tabletNav!.querySelectorAll("button"); + expect(tabletButtons.length).toBe(6); + const mobileButtons = mobileNav!.querySelectorAll("button"); + expect(mobileButtons.length).toBe(6); + unmount(); + }); + + it("calls onSubTabChange from tablet sub-item click", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const navs = container.querySelectorAll("nav"); + const tabletNav = navs[1]; + const schedulingBtn = Array.from(tabletNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + act(() => { + schedulingBtn!.click(); + }); + expect(onSubTabChange).toHaveBeenCalledWith("scheduling"); + unmount(); + }); + + it("calls onSubTabChange from mobile sub-item click", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const navs = container.querySelectorAll("nav"); + const mobileNav = navs[2]; + const schedulingBtn = Array.from(mobileNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + act(() => { + schedulingBtn!.click(); + }); + expect(onSubTabChange).toHaveBeenCalledWith("scheduling"); + unmount(); + }); }); // -- Horizontal layout --------------------------------------------------- @@ -118,6 +251,13 @@ describe("FormTabNav", () => { unmount(); }); + it("does not render sub-items in horizontal layout", () => { + const { container, unmount } = render(); + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBe(6); + unmount(); + }); + it("highlights the active tab with text-primary", () => { const { container, unmount } = render( @@ -153,9 +293,9 @@ describe("FormTabNav", () => { ); const buttons = container.querySelectorAll("button"); - // Click the "network" tab (index 3) + // Click the "network" tab (index 4) act(() => { - buttons[3].click(); + buttons[4].click(); }); expect(onTabChange).toHaveBeenCalledWith("network"); @@ -199,8 +339,8 @@ describe("FormTabNav", () => { const routingDot = buttons[1].querySelector(".bg-yellow-500"); expect(routingDot).toBeTruthy(); - // limits (index 2) should have a primary dot - const limitsDot = buttons[2].querySelector(".bg-primary"); + // limits (index 3) should have a primary dot + const limitsDot = buttons[3].querySelector(".bg-primary"); expect(limitsDot).toBeTruthy(); // basic (index 0) should have no status dot @@ -210,4 +350,59 @@ describe("FormTabNav", () => { unmount(); }); }); + + describe("derived constants", () => { + it("TAB_ORDER has correct length matching NAV_CONFIG", () => { + expect(TAB_ORDER.length).toBe(6); + expect(TAB_ORDER).toEqual(["basic", "routing", "options", "limits", "network", "testing"]); + }); + + it("NAV_ORDER includes all tabs and sub-tabs", () => { + expect(NAV_ORDER).toEqual([ + "basic", + "routing", + "scheduling", + "options", + "activeTime", + "limits", + "circuitBreaker", + "network", + "timeout", + "testing", + ]); + }); + + it("PARENT_MAP maps each sub-tab to its parent", () => { + expect(PARENT_MAP).toEqual({ + scheduling: "routing", + activeTime: "options", + circuitBreaker: "limits", + timeout: "network", + }); + }); + }); + + describe("excludeTabs", () => { + it("hides excluded tabs in horizontal layout", () => { + const { container, unmount } = render( + + ); + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBe(5); + const labels = Array.from(buttons).map((btn) => btn.textContent); + expect(labels).not.toContain("tabs.options"); + unmount(); + }); + + it("hides excluded tabs in desktop sidebar", () => { + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + const labels = Array.from(desktopButtons).map((btn) => btn.textContent); + expect(labels).not.toContain("tabs.options"); + unmount(); + }); + }); }); diff --git a/tests/unit/settings/providers/options-section.test.tsx b/tests/unit/settings/providers/options-section.test.tsx new file mode 100644 index 000000000..f34141a7a --- /dev/null +++ b/tests/unit/settings/providers/options-section.test.tsx @@ -0,0 +1,534 @@ +/** @vitest-environment happy-dom */ + +const mockDispatch = vi.fn(); +const mockUseProviderForm = vi.fn(); + +vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })); +vi.mock("framer-motion", () => ({ + motion: { div: ({ children, ...rest }: any) =>
{children}
}, +})); +vi.mock("lucide-react", () => { + const stub = ({ className }: any) => ; + return { Clock: stub, Info: stub, Settings: stub, Timer: stub }; +}); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context", + () => ({ + useProviderForm: (...args: any[]) => mockUseProviderForm(...args), + }) +); +vi.mock("@/app/[locale]/settings/providers/_components/adaptive-thinking-editor", () => ({ + AdaptiveThinkingEditor: (props: any) =>
, +})); +vi.mock("@/app/[locale]/settings/providers/_components/thinking-budget-editor", () => ({ + ThinkingBudgetEditor: (props: any) =>
, +})); +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children, className }: any) => {children}, +})); +vi.mock("@/components/ui/input", () => ({ + Input: (props: any) => , +})); +vi.mock("@/components/ui/select", () => ({ + Select: ({ children }: any) =>
{children}
, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) =>
{children}
, + SelectTrigger: ({ children, className }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) => {placeholder}, +})); +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ id, checked, onCheckedChange, disabled }: any) => ( +