diff --git a/.env.example b/.env.example index 177141c29..b6c6d72b8 100644 --- a/.env.example +++ b/.env.example @@ -97,7 +97,6 @@ ENABLE_API_KEY_REDIS_CACHE="true" # 是否启用 API Key Redis 缓存( # 降低该值会按签发时间收紧已签发 ADMIN_TOKEN 签名 cookie 的剩余寿命,且不会延长其原始 exp。 AUTH_SESSION_TTL_SECONDS=604800 # Web UI 登录态过期时间(秒,默认 604800 = 7 天,范围 60-31536000) SESSION_TTL=300 # 代理请求上下文缓存时间(秒,默认 300 = 5 分钟;不控制 Web UI 登录态) -DISCOVERY_ROLLOUT_PERCENT=100 # Discovery 运维灰度比例(0-100,按 API Key + Session 稳定分桶) STORE_SESSION_MESSAGES=false # 会话消息存储模式(默认:false) # - false:存储请求/响应体但对 message 内容脱敏 [REDACTED] # - true:原样存储 message 内容(注意隐私和存储空间影响) diff --git a/drizzle/0112_complex_sabra.sql b/drizzle/0112_complex_sabra.sql new file mode 100644 index 000000000..85c9524aa --- /dev/null +++ b/drizzle/0112_complex_sabra.sql @@ -0,0 +1,63 @@ +CREATE TABLE IF NOT EXISTS "provider_batch_apply_operations" ( + "claim_key" varchar(256) PRIMARY KEY NOT NULL, + "preview_token" varchar(256) NOT NULL, + "payload_fingerprint" varchar(128) NOT NULL, + "operation_id" varchar(256) NOT NULL, + "undo_token" varchar(256) NOT NULL, + "undo_expires_at" timestamp with time zone, + "undo_consumed_at" timestamp with time zone, + "status" varchar(32) NOT NULL, + "result" jsonb, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "provider_cache_effectiveness" ( + "id" serial PRIMARY KEY NOT NULL, + "provider_id" integer NOT NULL, + "model" varchar(128) NOT NULL, + "cache_ttl_bucket" varchar(10) NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "window_end" timestamp with time zone NOT NULL, + "sample_count" integer DEFAULT 0 NOT NULL, + "eligible_count" integer DEFAULT 0 NOT NULL, + "theoretical_cache_tokens" bigint DEFAULT 0 NOT NULL, + "observed_cache_read_tokens" bigint DEFAULT 0 NOT NULL, + "raw_effectiveness_bp" integer DEFAULT 0 NOT NULL, + "confidence_bp" integer DEFAULT 0 NOT NULL, + "effectiveness_bp" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "replay_payloads" ( + "replay_id" varchar(64) PRIMARY KEY NOT NULL, + "verifier" varchar(64) NOT NULL, + "scope_tag" varchar(16) NOT NULL, + "key_id" integer NOT NULL, + "user_id" integer NOT NULL, + "format" varchar(16) NOT NULL, + "model" varchar(128), + "status_code" integer NOT NULL, + "headers_json" jsonb, + "payload" text NOT NULL, + "byte_size" integer NOT NULL, + "source_message_request_id" integer, + "created_at" timestamp with time zone DEFAULT now(), + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN "cache_compatibility_key" varchar(64);--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN "cache_score_eligible" boolean;--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN "cache_score_excluded_reason" varchar(32);--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN "theoretical_cache_tokens" bigint;--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN "cache_ttl_bucket" varchar(10);--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "stream_gate_mode" varchar(10) DEFAULT 'enforce' NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "affinity_ignore_client_session_id" boolean DEFAULT true NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_provider_batch_apply_operations_preview_token" ON "provider_batch_apply_operations" USING btree ("preview_token");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_provider_batch_apply_operations_operation_id" ON "provider_batch_apply_operations" USING btree ("operation_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_provider_batch_apply_operations_undo_token" ON "provider_batch_apply_operations" USING btree ("undo_token");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_batch_apply_operations_expires_at" ON "provider_batch_apply_operations" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_cache_effectiveness_window" ON "provider_cache_effectiveness" USING btree ("provider_id","model","window_start" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_replay_payloads_key_id" ON "replay_payloads" USING btree ("key_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_replay_payloads_expires_at" ON "replay_payloads" USING btree ("expires_at"); \ No newline at end of file diff --git a/drizzle/meta/0110_snapshot.json b/drizzle/meta/0110_snapshot.json index 66d670d57..b8563e975 100644 --- a/drizzle/meta/0110_snapshot.json +++ b/drizzle/meta/0110_snapshot.json @@ -1,6 +1,6 @@ { "id": "b6b7996d-5b70-4a31-a1c7-3a318b3398a0", - "prevId": "c054c34a-98a4-4ae1-b0e5-0b663380f123", + "prevId": "60b89563-2169-4393-b823-ded5c410b455", "version": "7", "dialect": "postgresql", "tables": { diff --git a/drizzle/meta/0112_snapshot.json b/drizzle/meta/0112_snapshot.json new file mode 100644 index 000000000..f560b0431 --- /dev/null +++ b/drizzle/meta/0112_snapshot.json @@ -0,0 +1,5151 @@ +{ + "id": "5fd1703b-6bd3-4594-9c92-9f9c889dcd24", + "prevId": "b59029b9-eae7-4d23-97b8-323ee78c8e99", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_pricing_catalog": { + "name": "cloud_pricing_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "providers": { + "name": "providers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vendors": { + "name": "vendors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "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 + }, + "routing_trace": { + "name": "routing_trace", + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "hedge_losers": { + "name": "hedge_losers", + "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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_compatibility_key": { + "name": "cache_compatibility_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "cache_score_eligible": { + "name": "cache_score_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_score_excluded_reason": { + "name": "cache_score_excluded_reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "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_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "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\".\"client_ip\" IS NOT 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": "'cloud'" + }, + "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": {} + }, + "idx_model_prices_vendor": { + "name": "idx_model_prices_vendor", + "columns": [ + { + "expression": "((\"price_data\" ->> 'vendor'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_aliases": { + "name": "idx_model_prices_aliases", + "columns": [ + { + "expression": "((\"price_data\" -> 'aliases'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "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_batch_apply_operations": { + "name": "provider_batch_apply_operations", + "schema": "", + "columns": { + "claim_key": { + "name": "claim_key", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "preview_token": { + "name": "preview_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "payload_fingerprint": { + "name": "payload_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_token": { + "name": "undo_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_expires_at": { + "name": "undo_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "undo_consumed_at": { + "name": "undo_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_batch_apply_operations_preview_token": { + "name": "uniq_provider_batch_apply_operations_preview_token", + "columns": [ + { + "expression": "preview_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_operation_id": { + "name": "uniq_provider_batch_apply_operations_operation_id", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_undo_token": { + "name": "uniq_provider_batch_apply_operations_undo_token", + "columns": [ + { + "expression": "undo_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_batch_apply_operations_expires_at": { + "name": "idx_provider_batch_apply_operations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_cache_effectiveness": { + "name": "provider_cache_effectiveness", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sample_count": { + "name": "sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eligible_count": { + "name": "eligible_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observed_cache_read_tokens": { + "name": "observed_cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_effectiveness_bp": { + "name": "raw_effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confidence_bp": { + "name": "confidence_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "effectiveness_bp": { + "name": "effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_cache_effectiveness_window": { + "name": "idx_provider_cache_effectiveness_window", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": 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_image_generation_preference": { + "name": "codex_image_generation_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.replay_payloads": { + "name": "replay_payloads", + "schema": "", + "columns": { + "replay_id": { + "name": "replay_id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "verifier": { + "name": "verifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scope_tag": { + "name": "scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "headers_json": { + "name": "headers_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_message_request_id": { + "name": "source_message_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_replay_payloads_key_id": { + "name": "idx_replay_payloads_key_id", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_replay_payloads_expires_at": { + "name": "idx_replay_payloads_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discovery_concurrency": { + "name": "discovery_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "max_discovery_rounds": { + "name": "max_discovery_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "discovery_sla_ms": { + "name": "discovery_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "sticky_sla_ms": { + "name": "sticky_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20000 + }, + "racing_total_timeout_ms": { + "name": "racing_total_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60000 + }, + "sticky_timeout_cooldown_ms": { + "name": "sticky_timeout_cooldown_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300000 + }, + "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 + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_gemini_function_id_rectifier": { + "name": "enable_gemini_function_id_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 + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "stream_gate_mode": { + "name": "stream_gate_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'enforce'" + }, + "affinity_ignore_client_session_id": { + "name": "affinity_ignore_client_session_id", + "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": {}, + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": 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 + }, + "group_cost_multiplier": { + "name": "group_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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "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" + }, + { + "expression": "endpoint", + "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" + }, + { + "expression": "endpoint", + "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" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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_5h_cost_reset_at": { + "name": "limit_5h_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 d74fe7801..91847a65c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -785,6 +785,13 @@ "when": 1784622924311, "tag": "0111_happy_mauler", "breakpoints": true + }, + { + "idx": 112, + "version": "7", + "when": 1784800591867, + "tag": "0112_complex_sabra", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 0c2eb03c2..6256fb9cf 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -665,6 +665,7 @@ "cost": "Cost", "cacheHitRequests": "Cache-eligible Requests", "cacheHitRate": "Cache Hit Rate", + "cacheCoefficient": "Cache Coefficient", "cacheReadTokens": "Cache Read Tokens", "totalTokens": "Total Tokens", "cacheCreationConsumedAmount": "Cache Creation Spend", diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index e5654556d..9c50f60bc 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -160,6 +160,15 @@ "leasePercentZero": "Percentage is 0. This may cause the lease budget to always be 0.", "leaseCapZero": "Lease cap is 0. This may cause the per-lease budget to be 0." } + }, + "affinityIgnoreClientSessionId": "Ignore Client Session ID", + "affinityIgnoreClientSessionIdDesc": "When enabled, fingerprintable requests are forced to use longest-prefix affinity for provider stickiness (skipping client Session ID binding); non-fingerprintable requests still use session reuse. Default on.", + "streamGateMode": "Stream Content Gate", + "streamGateModeDesc": "Buffers upstream output until the first valid content frame arrives, and automatically fails over to another provider on error frames or empty streams; shadow mode only records divergence statistics without affecting forwarding. Default enabled.", + "streamGateModeOptions": { + "off": "Off", + "shadow": "Shadow mode", + "enforce": "Enabled" } }, "ipLogging": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index b07d5d7a3..01121a9fc 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -665,6 +665,7 @@ "cost": "コスト", "cacheHitRequests": "キャッシュ対象リクエスト数(命中率計算対象)", "cacheHitRate": "キャッシュ命中率", + "cacheCoefficient": "キャッシュ係数", "cacheReadTokens": "キャッシュ読取トークン数", "totalTokens": "総トークン数", "cacheCreationConsumedAmount": "キャッシュ作成消費額", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index f453b8519..7cdea6512 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -106,8 +106,6 @@ "saveFailed": "保存に失敗しました", "saveSettings": "設定を保存", "saveSuccess": "正常に保存されました", - "publicStatusProjectionWarning": "システム設定は保存されましたが、public status の Redis 投影は更新されませんでした。", - "publicStatusBackgroundRefreshPending": "システム設定は保存されましたが、バックグラウンド更新が成功するまで公開ステータスページに古いデータが表示される場合があります。", "siteTitle": "サイトタイトル", "siteTitleDesc": "ブラウザタブのタイトルとシステムのデフォルト表示名を設定するために使用されます。", "siteTitlePlaceholder": "例:Claude Code Hub", @@ -162,6 +160,15 @@ "leasePercentZero": "比率が 0 です。リース予算が常に 0 になる可能性があります。", "leaseCapZero": "上限が 0 です。リースごとの予算が 0 になる可能性があります。" } + }, + "affinityIgnoreClientSessionId": "クライアント Session ID を無視", + "affinityIgnoreClientSessionIdDesc": "有効にすると、フィンガープリント可能なリクエストは最長プレフィックス親和性によるプロバイダー固定を強制します(クライアント Session ID バインディングをスキップ)。フィンガープリント不可能なリクエストは従来どおりセッション再利用を使用します。デフォルトで有効。", + "streamGateMode": "ストリーム内容ゲート", + "streamGateModeDesc": "最初の有効なコンテンツフレームが到着するまでバッファリングし、エラーフレームや空ストリームの場合はプロバイダーを自動的に切り替えて再試行します。シャドウモードは転送に影響せず、判定乖離の統計のみを記録します。デフォルトで有効。", + "streamGateModeOptions": { + "off": "オフ", + "shadow": "シャドウモード", + "enforce": "有効" } }, "ipLogging": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 2fc0fff47..9010fe27d 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -665,6 +665,7 @@ "cost": "Стоимость", "cacheHitRequests": "Запросы (учтены в hit rate)", "cacheHitRate": "Попадания в кэш", + "cacheCoefficient": "Коэффициент кэша", "cacheReadTokens": "Токены чтения из кэша", "totalTokens": "Всего токенов", "cacheCreationConsumedAmount": "Расход на создание кэша", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 490614560..9fd5abca0 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -106,8 +106,6 @@ "saveFailed": "Ошибка сохранения", "saveSettings": "Сохранить настройки", "saveSuccess": "Сохранено успешно", - "publicStatusProjectionWarning": "Системные настройки сохранены, но Redis-проекция public status не была обновлена.", - "publicStatusBackgroundRefreshPending": "Системные настройки сохранены, но публичная статус-страница может временно показывать устаревшие данные, пока фоновое обновление не завершится.", "siteTitle": "Название сайта", "siteTitleDesc": "Используется для установки заголовка вкладки браузера и имени системы по умолчанию.", "siteTitlePlaceholder": "например: Claude Code Hub", @@ -162,6 +160,15 @@ "leasePercentZero": "Процент равен 0. Бюджет аренды может всегда быть 0.", "leaseCapZero": "Предел аренды равен 0. Бюджет на срез может быть 0." } + }, + "affinityIgnoreClientSessionId": "Игнорировать клиентский Session ID", + "affinityIgnoreClientSessionIdDesc": "Если включено, запросы с отпечатком принудительно используют аффинность по самому длинному префиксу для закрепления за провайдером (пропуская привязку по клиентскому Session ID); запросы без отпечатка по-прежнему используют переиспользование сессии. По умолчанию включено.", + "streamGateMode": "Шлюз потокового контента", + "streamGateModeDesc": "Буферизует вывод до появления первого валидного кадра контента и автоматически переключает провайдера при кадрах ошибок или пустых потоках; теневой режим лишь записывает статистику расхождений, не влияя на пересылку. По умолчанию включено.", + "streamGateModeOptions": { + "off": "Выключено", + "shadow": "Теневой режим", + "enforce": "Включено" } }, "ipLogging": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index fdb559c64..ad2ce3cfe 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -665,6 +665,7 @@ "cost": "成本", "cacheHitRequests": "缓存触发请求数", "cacheHitRate": "缓存命中率", + "cacheCoefficient": "缓存系数", "cacheReadTokens": "缓存读取 Token 数", "totalTokens": "总 Token 数", "cacheCreationConsumedAmount": "缓存创建消耗金额", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index f6ef4054a..f3497f20c 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -87,6 +87,15 @@ "enableResponseInputRectifierDesc": "自动将 /v1/responses 请求中的非数组 input(字符串简写或带 role/type 的单消息对象)规范化为标准数组格式后再处理(默认开启)。", "allowNonConversationEndpointProviderFallback": "允许非对话端点跨供应商 fallback", "allowNonConversationEndpointProviderFallbackDesc": "控制 /v1/messages/count_tokens 与 /v1/responses/compact 在当前供应商失败时,是否沿用现有决策链切换到兼容供应商重试。默认开启,并继续保持 raw passthrough 与非计费语义。", + "streamGateMode": "流式内容门控", + "streamGateModeDesc": "在首个有效内容帧到达前先行缓冲,遇到错误帧或空流时自动切换供应商重试;影子模式仅旁路统计判定分歧,不影响转发。默认启用。", + "streamGateModeOptions": { + "off": "关闭", + "shadow": "影子模式", + "enforce": "启用" + }, + "affinityIgnoreClientSessionId": "忽略客户端 Session ID", + "affinityIgnoreClientSessionIdDesc": "开启后,可指纹化的请求强制使用最长前缀亲和做供应商粘性(跳过客户端 Session ID 绑定);不可指纹化的请求仍走会话复用。默认开启。", "fakeStreaming": { "title": "Fake 流式输出白名单", "description": "针对图像 / 视频等长耗时同步生成场景(容易超过 Cloudflare 120 秒无响应体超时),CCH 会先返回 SSE 心跳保活,并在内部串行调用上游供应商,仅在最终拿到非空结果时回写最终响应。命中白名单的模型会启用 fake streaming;列表为空表示完全禁用。", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 7c0466715..bef309e78 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -665,6 +665,7 @@ "cost": "費用", "cacheHitRequests": "快取命中請求數(納入快取命中率計算的請求總數)", "cacheHitRate": "快取命中率", + "cacheCoefficient": "快取係數", "cacheReadTokens": "快取讀取 Token 數", "totalTokens": "總 Token 數", "cacheCreationConsumedAmount": "快取建立消耗金額", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 308df4e58..3b6411888 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -106,8 +106,6 @@ "saveFailed": "儲存失敗", "saveSettings": "儲存設定", "saveSuccess": "儲存成功", - "publicStatusProjectionWarning": "系統設定已儲存,但 public status Redis 投影尚未刷新。", - "publicStatusBackgroundRefreshPending": "系統設定已儲存,但公開狀態頁可能會在背景刷新成功前暫時維持舊資料。", "siteTitle": "站台標題", "siteTitleDesc": "用於設定瀏覽器分頁標題以及系統預設顯示名稱。", "siteTitlePlaceholder": "例:Claude Code Hub", @@ -162,6 +160,15 @@ "leasePercentZero": "目前比例為 0,可能導致租約預算始終為 0。", "leaseCapZero": "租約上限為 0 可能導致單次租約預算為 0。" } + }, + "affinityIgnoreClientSessionId": "忽略用戶端 Session ID", + "affinityIgnoreClientSessionIdDesc": "開啟後,可指紋化的請求強制使用最長前綴親和做供應商黏性(跳過用戶端 Session ID 綁定);不可指紋化的請求仍走會話複用。預設開啟。", + "streamGateMode": "串流內容閘控", + "streamGateModeDesc": "在首個有效內容影格到達前先行緩衝,遇到錯誤影格或空串流時自動切換供應商重試;影子模式僅旁路統計判定分歧,不影響轉發。預設啟用。", + "streamGateModeOptions": { + "off": "關閉", + "shadow": "影子模式", + "enforce": "啟用" } }, "ipLogging": { diff --git a/src/actions/admin-user-insights.ts b/src/actions/admin-user-insights.ts index 2aa33b493..296879f8a 100644 --- a/src/actions/admin-user-insights.ts +++ b/src/actions/admin-user-insights.ts @@ -40,7 +40,7 @@ export async function getUserInsightsOverview( }> > { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "Unauthorized" }; } @@ -75,7 +75,7 @@ export async function getUserInsightsKeyTrend( timeRange: string ): Promise> { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "Unauthorized" }; } @@ -130,7 +130,7 @@ export async function getUserInsightsModelBreakdown( }> > { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "Unauthorized" }; } @@ -166,7 +166,7 @@ export async function getUserInsightsProviderBreakdown( }> > { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "Unauthorized" }; } diff --git a/src/actions/audit-logs.ts b/src/actions/audit-logs.ts index 36ac6fd3c..59efcd952 100644 --- a/src/actions/audit-logs.ts +++ b/src/actions/audit-logs.ts @@ -61,7 +61,7 @@ export async function getAuditLogsBatch( const tErrors = await getTranslations("errors"); try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tErrors("PERMISSION_DENIED"), @@ -116,7 +116,7 @@ export async function getAuditLogDetail(id: number): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限访问客户端版本统计" }; } diff --git a/src/actions/dispatch-simulator.ts b/src/actions/dispatch-simulator.ts index 564cb1989..9ed7a174a 100644 --- a/src/actions/dispatch-simulator.ts +++ b/src/actions/dispatch-simulator.ts @@ -444,7 +444,7 @@ export async function simulateDispatchAction( rawInput: DispatchSimulatorInput ): Promise> { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: DISPATCH_SIMULATOR_ERROR_CODES.PERMISSION_DENIED, diff --git a/src/actions/error-rules.ts b/src/actions/error-rules.ts index 9c77a2b5b..5357f0061 100644 --- a/src/actions/error-rules.ts +++ b/src/actions/error-rules.ts @@ -43,7 +43,7 @@ function validateOverrideStatusCodeRange(statusCode: number | null | undefined): export async function listErrorRules(): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn("[ErrorRulesAction] Unauthorized access attempt"); return []; } @@ -77,7 +77,7 @@ export async function createErrorRuleAction(data: { }): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -228,7 +228,7 @@ export async function updateErrorRuleAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -361,7 +361,7 @@ export async function updateErrorRuleAction( export async function deleteErrorRuleAction(id: number): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -423,7 +423,7 @@ export async function refreshCacheAction(): Promise< > { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -495,7 +495,7 @@ export async function testErrorRuleAction(input: { message: string }): Promise< > { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -615,7 +615,7 @@ export async function testErrorRuleAction(input: { message: string }): Promise< export async function getCacheStats() { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return null; } diff --git a/src/actions/keys.ts b/src/actions/keys.ts index 7568db63e..88a221c56 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -1130,7 +1130,7 @@ export async function resetKeyLimitsOnly(keyId: number): Promise { const tError = await getTranslations("errors"); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index 608990567..9676c6169 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -252,7 +252,7 @@ export async function uploadPriceTable( ): Promise> { // 权限检查:只有管理员可以上传价格表 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -329,7 +329,7 @@ export async function getModelPrices(): Promise { try { // 权限检查:只有管理员可以查看价格表 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return []; } @@ -359,7 +359,7 @@ export async function getAvailableModelCatalog(options?: { }): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return []; } @@ -399,7 +399,7 @@ export async function getModelPricesPaginated( try { // 权限检查:只有管理员可以查看价格表 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作", @@ -465,7 +465,7 @@ export async function checkLiteLLMSyncConflicts(): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } diff --git a/src/actions/notification-bindings.ts b/src/actions/notification-bindings.ts index 2ffed7e5b..ab3be2243 100644 --- a/src/actions/notification-bindings.ts +++ b/src/actions/notification-bindings.ts @@ -33,7 +33,7 @@ export async function getBindingsForTypeAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限访问通知绑定" }; } @@ -53,7 +53,7 @@ export async function updateBindingsAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index 4cf314d10..a51f8f6a3 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -19,7 +19,7 @@ import type { ActionResult } from "./types"; */ export async function getNotificationSettingsAction(): Promise { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { throw new Error("无权限执行此操作"); } return getNotificationSettings(); @@ -33,7 +33,7 @@ export async function updateNotificationSettingsAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -78,7 +78,7 @@ export async function testWebhookAction( type: NotificationJobType ): Promise<{ success: boolean; error?: string }> { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { success: false, error: "无权限执行此操作" }; } diff --git a/src/actions/provider-cache-effectiveness.ts b/src/actions/provider-cache-effectiveness.ts new file mode 100644 index 000000000..c2853fd2b --- /dev/null +++ b/src/actions/provider-cache-effectiveness.ts @@ -0,0 +1,46 @@ +"use server"; + +import { getTranslations } from "next-intl/server"; +import type { ActionResult } from "@/actions/types"; +import { getSession } from "@/lib/auth"; +import { logger } from "@/lib/logger"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; +import { listProviderCacheEffectivenessWindows } from "@/repository/provider-cache-effectiveness"; +import type { ProviderCacheEffectivenessWindow } from "@/types/provider-cache-effectiveness"; + +export interface GetProviderCacheEffectivenessInput { + providerId?: number; + limit?: number; +} + +/** + * 获取缓存效果窗口列表(管理员,只读指标) + */ +export async function getProviderCacheEffectivenessWindows( + input: GetProviderCacheEffectivenessInput = {} +): Promise> { + const tErrors = await getTranslations("errors"); + try { + const session = await getSession(); + if (session?.user.role !== "admin") { + return { + ok: false, + error: tErrors("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const rows = await listProviderCacheEffectivenessWindows({ + providerId: input.providerId, + limit: input.limit, + }); + return { ok: true, data: rows }; + } catch (error) { + logger.error("[ProviderCacheEffectivenessAction] Failed to list windows:", error); + return { + ok: false, + error: tErrors("OPERATION_FAILED"), + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } +} diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index 6e3d2e889..ff89b8458 100644 --- a/src/actions/provider-endpoints.ts +++ b/src/actions/provider-endpoints.ts @@ -155,7 +155,7 @@ const BatchGetProviderEndpointProbeLogsBatchSchema = z.object({ async function getAdminSession() { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return null; } return session; diff --git a/src/actions/provider-groups.ts b/src/actions/provider-groups.ts index bccbad967..6ff1b8412 100644 --- a/src/actions/provider-groups.ts +++ b/src/actions/provider-groups.ts @@ -43,7 +43,7 @@ export async function getProviderGroups(): Promise { role: session?.user.role, }); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.trace("getProviders:unauthorized", { hasSession: !!session, role: session?.user.role, @@ -420,7 +420,7 @@ export async function getProviders(): Promise { export async function getProviderStatisticsAsync(): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") return {}; + if (session?.user.role !== "admin") return {}; const statistics = await getProviderStatistics(); @@ -591,7 +591,7 @@ export async function addProvider(data: { }): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -809,7 +809,7 @@ export async function editProvider( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -1007,7 +1007,7 @@ export async function removeProvider( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -1094,7 +1094,7 @@ export async function autoSortProviderPriority(args: { }): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -1222,7 +1222,7 @@ export async function autoSortProviderPriority(args: { export async function getProvidersHealthStatus() { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return {}; } @@ -1270,7 +1270,7 @@ export async function getProvidersHealthStatus() { export async function resetProviderCircuit(providerId: number): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -1293,7 +1293,7 @@ export async function resetProviderCircuit(providerId: number): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -2174,7 +2174,7 @@ export async function previewProviderBatchPatch( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -2254,7 +2254,7 @@ export async function applyProviderBatchPatch( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -2464,7 +2464,7 @@ export async function undoProviderPatch( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -2723,7 +2723,7 @@ export async function batchUpdateProviders( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -2892,7 +2892,7 @@ export async function batchDeleteProviders( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -2957,7 +2957,7 @@ export async function undoProviderDelete( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -3021,7 +3021,7 @@ export async function batchResetProviderCircuits( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -3070,7 +3070,7 @@ export async function getProviderLimitUsage(providerId: number): Promise< > { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -3224,7 +3224,7 @@ export async function getProviderLimitUsageBatch( try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn("getProviderLimitUsageBatch: 无权限执行此操作"); return result; } @@ -3366,7 +3366,7 @@ export async function testProviderProxy(data: { > { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -3492,7 +3492,7 @@ export async function getUnmaskedProviderKey(id: number): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -5001,7 +5001,7 @@ async function isUrlSafeForApiTest( */ export async function testProviderUnified(data: UnifiedTestArgs): Promise { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "未授权", @@ -5125,7 +5125,7 @@ export async function testProviderById( args?: TestProviderByIdArgs ): Promise { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "未授权", @@ -5217,7 +5217,7 @@ export async function getProviderTestPresets( providerType: ProviderType ): Promise> { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "未授权", @@ -5394,7 +5394,7 @@ export async function fetchUpstreamModels( ): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -5689,7 +5689,7 @@ export async function reclusterProviderVendors(args: { }): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "NO_PERMISSION" }; } diff --git a/src/actions/public-status.ts b/src/actions/public-status.ts index 2f4f2bf85..9a641ff1c 100644 --- a/src/actions/public-status.ts +++ b/src/actions/public-status.ts @@ -70,7 +70,7 @@ export async function savePublicStatusSettings(input: SavePublicStatusSettingsIn const t = await getTranslations("settings"); const tError = await getTranslations("errors"); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("UNAUTHORIZED") }; } if (!PUBLIC_STATUS_INTERVAL_SET.has(input.publicStatusAggregationIntervalMinutes)) { diff --git a/src/actions/rate-limit-stats.ts b/src/actions/rate-limit-stats.ts index 9bfcdcadf..3f972be01 100644 --- a/src/actions/rate-limit-stats.ts +++ b/src/actions/rate-limit-stats.ts @@ -19,7 +19,7 @@ export async function getRateLimitStats( const session = await getSession(); // 仅管理员可访问 - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "Unauthorized - Admin access required", diff --git a/src/actions/sensitive-words.ts b/src/actions/sensitive-words.ts index 1a39f5ea2..1d6bd06e8 100644 --- a/src/actions/sensitive-words.ts +++ b/src/actions/sensitive-words.ts @@ -14,7 +14,7 @@ import type { ActionResult } from "./types"; export async function listSensitiveWords(): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn("[SensitiveWordsAction] Unauthorized access attempt"); return []; } @@ -36,7 +36,7 @@ export async function createSensitiveWordAction(data: { }): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -132,7 +132,7 @@ export async function updateSensitiveWordAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -211,7 +211,7 @@ export async function updateSensitiveWordAction( export async function deleteSensitiveWordAction(id: number): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -270,7 +270,7 @@ export async function refreshCacheAction(): Promise< > { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -305,7 +305,7 @@ export async function refreshCacheAction(): Promise< export async function getCacheStats() { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return null; } diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 4ffbdf87c..3dac0a71d 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -27,6 +27,7 @@ import type { CodexPriorityBillingSource, FakeStreamingWhitelistEntry, ResponseFixerConfig, + StreamGateSettingMode, SystemSettings, } from "@/types/system-config"; import type { ActionResult } from "./types"; @@ -39,7 +40,7 @@ function discoveryValidationErrorCode(error: unknown): string | null { export async function fetchSystemSettings(): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限访问系统设置" }; } @@ -102,6 +103,8 @@ export async function saveSystemSettings(formData: { enableResponseInputRectifier?: boolean; allowNonConversationEndpointProviderFallback?: boolean; fakeStreamingWhitelist?: FakeStreamingWhitelistEntry[]; + streamGateMode?: StreamGateSettingMode; + affinityIgnoreClientSessionId?: boolean; enableCodexSessionIdCompletion?: boolean; enableClaudeMetadataUserIdInjection?: boolean; enableResponseFixer?: boolean; @@ -122,7 +125,7 @@ export async function saveSystemSettings(formData: { let before: SystemSettings | null = null; try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -188,6 +191,8 @@ export async function saveSystemSettings(formData: { allowNonConversationEndpointProviderFallback: validated.allowNonConversationEndpointProviderFallback, fakeStreamingWhitelist: validated.fakeStreamingWhitelist, + streamGateMode: validated.streamGateMode, + affinityIgnoreClientSessionId: validated.affinityIgnoreClientSessionId, enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: validated.enableClaudeMetadataUserIdInjection, enableResponseFixer: validated.enableResponseFixer, diff --git a/src/actions/users.ts b/src/actions/users.ts index 1c543c155..3bf948f4c 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1344,7 +1344,7 @@ export async function addUser(data: { // 权限检查:只有管理员可以添加用户 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), @@ -1546,7 +1546,7 @@ export async function createUserOnly(data: { // Permission check: only admin can add users const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), @@ -1883,7 +1883,7 @@ export async function removeUser(userId: number): Promise { const tError = await getTranslations("errors"); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), @@ -2018,7 +2018,7 @@ export async function renewUser( const tError = await getTranslations("errors"); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), @@ -2094,7 +2094,7 @@ export async function toggleUserEnabled(userId: number, enabled: boolean): Promi const tError = await getTranslations("errors"); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), @@ -2234,7 +2234,7 @@ export async function resetUserLimitsOnly(userId: number): Promise const tError = await getTranslations("errors"); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), @@ -2259,7 +2259,7 @@ export async function resetUserLimitsOnly(userId: number): Promise if (requiresRedisForFixed5h) { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return { ok: false, error: tError("USER_5H_FIXED_RESET_REQUIRES_REDIS"), @@ -2348,7 +2348,7 @@ export async function resetUserAllStatistics(userId: number): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限访问推送目标" }; } @@ -352,7 +352,7 @@ export async function createWebhookTargetAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -381,7 +381,7 @@ export async function updateWebhookTargetAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -405,7 +405,7 @@ export async function updateWebhookTargetAction( export async function deleteWebhookTargetAction(id: number): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } @@ -426,7 +426,7 @@ export async function testWebhookTargetAction( try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "无权限执行此操作" }; } diff --git a/src/app/[locale]/dashboard/_components/user/actions/reset-user-5h-limit.ts b/src/app/[locale]/dashboard/_components/user/actions/reset-user-5h-limit.ts index bc0cce5d6..6424b436d 100644 --- a/src/app/[locale]/dashboard/_components/user/actions/reset-user-5h-limit.ts +++ b/src/app/[locale]/dashboard/_components/user/actions/reset-user-5h-limit.ts @@ -18,7 +18,7 @@ export async function resetUser5hLimitOnly( const tError = await getTranslations("errors"); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: tError("PERMISSION_DENIED"), diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 0062129ae..7183ff0f9 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -239,9 +239,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { : scope === "userCacheHitRate" ? 6 : scope === "provider" - ? 10 + ? 11 : scope === "providerCacheHitRate" - ? 8 + ? 7 : scope === "model" ? 6 : 5; @@ -379,6 +379,16 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { sortKey: "avgCostPerMillionTokens", getValue: (row) => row.avgCostPerMillionTokens ?? 0, }, + { + header: t("columns.cacheCoefficient"), + className: "text-right", + cell: (row) => { + const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; + return bp == null ? "–" : (bp / 10000).toFixed(2); + }, + sortKey: "cacheCoefficientBp", + getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), + }, ]; const providerCacheHitRateColumns: ColumnDef[] = [ @@ -414,6 +424,16 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { sortKey: "cacheHitRate", getValue: (row) => row.cacheHitRate, }, + { + header: t("columns.cacheCoefficient"), + className: "text-right", + cell: (row) => { + const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; + return bp == null ? "–" : (bp / 10000).toFixed(2); + }, + sortKey: "cacheCoefficientBp", + getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), + }, { header: t("columns.cacheReadTokens"), className: "text-right", diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx index 9f71cc066..5078ecc2a 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx @@ -13,7 +13,7 @@ export default async function UserInsightsPage({ const { locale, userId: userIdStr } = await params; const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return redirect({ href: "/dashboard/leaderboard", locale }); } diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 4d45b6260..64b0bc03b 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -1521,8 +1521,7 @@ describe("error-details-dialog routing trace", () => { }); test("expands exact Discovery attempts with sanitized provider details and cancellation reasons", () => { - const longSecondError = - " second-attempt-429 " + "x".repeat(8_200) + "TAIL_NOT_RENDERED"; + const longSecondError = ` second-attempt-429 ${"x".repeat(8_200)}TAIL_NOT_RENDERED`; const detailedTrace: RoutingTraceV1 = { version: 1, mode: "discovery", diff --git a/src/app/[locale]/dashboard/providers/page.tsx b/src/app/[locale]/dashboard/providers/page.tsx index f287b8b12..a63e557ed 100644 --- a/src/app/[locale]/dashboard/providers/page.tsx +++ b/src/app/[locale]/dashboard/providers/page.tsx @@ -23,7 +23,7 @@ export default async function DashboardProvidersPage({ // 权限检查:仅 admin 用户可访问 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { redirect({ href: session ? "/dashboard" : "/login", locale }); } diff --git a/src/app/[locale]/dashboard/quotas/keys/page.tsx b/src/app/[locale]/dashboard/quotas/keys/page.tsx index 9147e67af..be1231610 100644 --- a/src/app/[locale]/dashboard/quotas/keys/page.tsx +++ b/src/app/[locale]/dashboard/quotas/keys/page.tsx @@ -10,7 +10,7 @@ export default async function KeysQuotaPage({ params }: { params: Promise<{ loca const session = await getSession(); // 权限检查:仅 admin 用户可访问 - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { redirect({ href: session ? "/dashboard/my-quota" : "/login", locale }); } diff --git a/src/app/[locale]/dashboard/quotas/providers/page.tsx b/src/app/[locale]/dashboard/quotas/providers/page.tsx index 51ce7b35d..f97bedbe7 100644 --- a/src/app/[locale]/dashboard/quotas/providers/page.tsx +++ b/src/app/[locale]/dashboard/quotas/providers/page.tsx @@ -51,7 +51,7 @@ export default async function ProvidersQuotaPage({ const session = await getSession(); // 权限检查:仅 admin 用户可访问 - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { redirect({ href: session ? "/dashboard/my-quota" : "/login", locale }); } diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx index be1ac1f26..0b0a41399 100644 --- a/src/app/[locale]/dashboard/quotas/users/page.tsx +++ b/src/app/[locale]/dashboard/quotas/users/page.tsx @@ -125,7 +125,7 @@ export default async function UsersQuotaPage({ params }: { params: Promise<{ loc const { locale } = await params; const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return redirect({ href: session ? "/dashboard/my-quota" : "/login", locale }); } diff --git a/src/app/[locale]/dashboard/rate-limits/page.tsx b/src/app/[locale]/dashboard/rate-limits/page.tsx index 18ef80815..0a8c20eac 100644 --- a/src/app/[locale]/dashboard/rate-limits/page.tsx +++ b/src/app/[locale]/dashboard/rate-limits/page.tsx @@ -13,7 +13,7 @@ export default async function RateLimitsPage({ params }: { params: Promise<{ loc const session = await getSession(); // 仅管理员可访问 - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return redirect({ href: "/dashboard", locale }); } diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/page.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/page.tsx index e3f3bfbf4..2daab3633 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/page.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/page.tsx @@ -13,7 +13,7 @@ export default async function SessionMessagesPage({ const session = await getSession(); // 权限检查:仅 admin 用户可访问 - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return redirect({ href: session ? "/dashboard" : "/login", locale }); } diff --git a/src/app/[locale]/dashboard/sessions/page.tsx b/src/app/[locale]/dashboard/sessions/page.tsx index ea5df6ee3..4069750ce 100644 --- a/src/app/[locale]/dashboard/sessions/page.tsx +++ b/src/app/[locale]/dashboard/sessions/page.tsx @@ -13,7 +13,7 @@ export default async function ActiveSessionsPage({ const session = await getSession(); // 权限检查:仅 admin 用户可访问 - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return redirect({ href: session ? "/dashboard" : "/login", locale }); } diff --git a/src/app/[locale]/internal/data-gen/page.tsx b/src/app/[locale]/internal/data-gen/page.tsx index 0c8ae3a6f..b24a8a86c 100644 --- a/src/app/[locale]/internal/data-gen/page.tsx +++ b/src/app/[locale]/internal/data-gen/page.tsx @@ -10,7 +10,7 @@ export default async function Page({ params }: { params: Promise<{ locale: strin const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return redirect({ href: "/login", locale }); } diff --git a/src/app/[locale]/settings/client-versions/page.tsx b/src/app/[locale]/settings/client-versions/page.tsx index 10e9c6e34..1d71a5345 100644 --- a/src/app/[locale]/settings/client-versions/page.tsx +++ b/src/app/[locale]/settings/client-versions/page.tsx @@ -24,7 +24,7 @@ export default async function ClientVersionsPage({ const t = await getTranslations({ locale, namespace: "settings" }); const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return redirect({ href: "/login", locale }); } 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 1a0d80ec5..56d47ae15 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -8,10 +8,12 @@ import { Coins, Eye, FileCode, + Filter, Globe, MapPin, Network, Pencil, + Route, Terminal, Thermometer, Trophy, @@ -53,6 +55,7 @@ import type { BillingModelSource, CodexPriorityBillingSource, FakeStreamingWhitelistEntry, + StreamGateSettingMode, SystemSettings, } from "@/types/system-config"; @@ -90,6 +93,8 @@ interface SystemSettingsFormProps { | "enableGeminiFunctionIdRectifier" | "allowNonConversationEndpointProviderFallback" | "fakeStreamingWhitelist" + | "streamGateMode" + | "affinityIgnoreClientSessionId" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" @@ -199,6 +204,12 @@ export function SystemSettingsForm({ groupTags: [...entry.groupTags], })) ); + const [streamGateMode, setStreamGateMode] = useState( + initialSettings.streamGateMode + ); + const [affinityIgnoreClientSessionId, setAffinityIgnoreClientSessionId] = useState( + initialSettings.affinityIgnoreClientSessionId + ); const [enableThinkingBudgetRectifier, setEnableThinkingBudgetRectifier] = useState( initialSettings.enableThinkingBudgetRectifier ); @@ -382,6 +393,8 @@ export function SystemSettingsForm({ enableResponseInputRectifier, allowNonConversationEndpointProviderFallback, fakeStreamingWhitelist: sanitizedFakeStreamingWhitelist, + streamGateMode, + affinityIgnoreClientSessionId, enableThinkingBudgetRectifier, enableThinkingEffortConflictRectifier, enableGeminiFunctionIdRectifier, @@ -444,6 +457,8 @@ export function SystemSettingsForm({ groupTags: [...entry.groupTags], })) ); + setStreamGateMode(result.data.streamGateMode); + setAffinityIgnoreClientSessionId(result.data.affinityIgnoreClientSessionId); setEnableThinkingBudgetRectifier(result.data.enableThinkingBudgetRectifier); setEnableThinkingEffortConflictRectifier(result.data.enableThinkingEffortConflictRectifier); setEnableGeminiFunctionIdRectifier(result.data.enableGeminiFunctionIdRectifier); @@ -1091,6 +1106,59 @@ export function SystemSettingsForm({ /> + {/* Stream Content Gate Mode */} +
+
+
+ +
+
+

{t("streamGateMode")}

+

{t("streamGateModeDesc")}

+
+
+
+ +
+
+ + {/* Affinity: Ignore Client Session ID */} +
+
+
+ +
+
+

+ {t("affinityIgnoreClientSessionId")} +

+

+ {t("affinityIgnoreClientSessionIdDesc")} +

+
+
+ setAffinityIgnoreClientSessionId(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 a918cb98c..192369a1e 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -78,6 +78,8 @@ async function SettingsConfigContent({ locale }: { locale: string }) { allowNonConversationEndpointProviderFallback: settings.allowNonConversationEndpointProviderFallback, fakeStreamingWhitelist: settings.fakeStreamingWhitelist, + streamGateMode: settings.streamGateMode, + affinityIgnoreClientSessionId: settings.affinityIgnoreClientSessionId, enableCodexSessionIdCompletion: settings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: settings.enableClaudeMetadataUserIdInjection, enableResponseFixer: settings.enableResponseFixer, diff --git a/src/app/[locale]/settings/providers/_components/model-multi-select.tsx b/src/app/[locale]/settings/providers/_components/model-multi-select.tsx index 7276c621f..8919b058a 100644 --- a/src/app/[locale]/settings/providers/_components/model-multi-select.tsx +++ b/src/app/[locale]/settings/providers/_components/model-multi-select.tsx @@ -119,7 +119,7 @@ export function ModelMultiSelect({ catalogScope = "chat", }: ModelMultiSelectProps) { const t = useTranslations("settings.providers.form.modelSelect"); - const tPrices = useTranslations("settings.prices"); + const _tPrices = useTranslations("settings.prices"); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(true); const [modelSource, setModelSource] = useState("loading"); diff --git a/src/app/api/admin/database/export/route.ts b/src/app/api/admin/database/export/route.ts index 2106fd0ff..a5b24c4b5 100644 --- a/src/app/api/admin/database/export/route.ts +++ b/src/app/api/admin/database/export/route.ts @@ -90,7 +90,7 @@ export async function GET(request: Request) { try { // 1. 验证管理员权限 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn({ action: "database_export_unauthorized" }); return new Response("Unauthorized", { status: 401 }); } diff --git a/src/app/api/admin/database/import/route.ts b/src/app/api/admin/database/import/route.ts index c6062a0f8..13aa1d214 100644 --- a/src/app/api/admin/database/import/route.ts +++ b/src/app/api/admin/database/import/route.ts @@ -34,7 +34,7 @@ export async function POST(request: Request) { try { // 1. 验证管理员权限 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn({ action: "database_import_unauthorized" }); return new Response("Unauthorized", { status: 401 }); } diff --git a/src/app/api/admin/database/status/route.ts b/src/app/api/admin/database/status/route.ts index 410d83e7b..4d24cbae7 100644 --- a/src/app/api/admin/database/status/route.ts +++ b/src/app/api/admin/database/status/route.ts @@ -18,7 +18,7 @@ export async function GET() { try { // 1. 验证管理员权限 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn({ action: "database_status_unauthorized" }); return new Response("Unauthorized", { status: 401 }); } diff --git a/src/app/api/admin/log-cleanup/manual/route.ts b/src/app/api/admin/log-cleanup/manual/route.ts index 0a5d9f9fe..6fc2d1c38 100644 --- a/src/app/api/admin/log-cleanup/manual/route.ts +++ b/src/app/api/admin/log-cleanup/manual/route.ts @@ -54,7 +54,7 @@ export async function POST(request: NextRequest) { try { // 1. 验证管理员权限 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn({ action: "log_cleanup_unauthorized" }); return Response.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/admin/log-level/route.ts b/src/app/api/admin/log-level/route.ts index 196707fd9..d97e70823 100644 --- a/src/app/api/admin/log-level/route.ts +++ b/src/app/api/admin/log-level/route.ts @@ -11,7 +11,7 @@ export const runtime = "nodejs"; export async function GET() { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return new Response("Unauthorized", { status: 401 }); } @@ -29,7 +29,7 @@ export async function GET() { export async function POST(req: Request) { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return new Response("Unauthorized", { status: 401 }); } diff --git a/src/app/api/admin/system-config/route.ts b/src/app/api/admin/system-config/route.ts index 1d964bc5c..bdf9cc76b 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -25,7 +25,7 @@ export const runtime = "nodejs"; export async function GET() { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return new Response("Unauthorized", { status: 401 }); } @@ -55,7 +55,7 @@ export async function GET() { export async function POST(req: Request) { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return new Response("Unauthorized", { status: 401 }); } @@ -107,6 +107,8 @@ export async function POST(req: Request) { enableGeminiFunctionIdRectifier: validated.enableGeminiFunctionIdRectifier, enableBillingHeaderRectifier: validated.enableBillingHeaderRectifier, enableResponseInputRectifier: validated.enableResponseInputRectifier, + streamGateMode: validated.streamGateMode, + affinityIgnoreClientSessionId: validated.affinityIgnoreClientSessionId, enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: validated.enableClaudeMetadataUserIdInjection, enableResponseFixer: validated.enableResponseFixer, diff --git a/src/app/api/availability/current/route.ts b/src/app/api/availability/current/route.ts index 57b17d4a7..33a2153a2 100644 --- a/src/app/api/availability/current/route.ts +++ b/src/app/api/availability/current/route.ts @@ -15,7 +15,7 @@ import { getCurrentProviderStatus } from "@/lib/availability"; export async function GET(_request: NextRequest) { // Verify admin authentication using session cookies (consistent with /api/availability) const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/availability/endpoints/probe-logs/route.ts b/src/app/api/availability/endpoints/probe-logs/route.ts index e97cb91bd..6bc4da30d 100644 --- a/src/app/api/availability/endpoints/probe-logs/route.ts +++ b/src/app/api/availability/endpoints/probe-logs/route.ts @@ -4,7 +4,7 @@ import { findProviderEndpointById, findProviderEndpointProbeLogs } from "@/repos export async function GET(request: NextRequest) { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/availability/endpoints/route.ts b/src/app/api/availability/endpoints/route.ts index b0c831ec2..8accfbf4f 100644 --- a/src/app/api/availability/endpoints/route.ts +++ b/src/app/api/availability/endpoints/route.ts @@ -21,7 +21,7 @@ function isProviderType(value: string | null): value is ProviderType { export async function GET(request: NextRequest) { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts index ecedb5284..ae79b2d72 100644 --- a/src/app/api/availability/route.ts +++ b/src/app/api/availability/route.ts @@ -100,7 +100,7 @@ function parseProviderIdsQueryParam(value: string): number[] { export async function GET(request: NextRequest) { // Verify admin authentication using session cookies const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/internal/data-gen/route.ts b/src/app/api/internal/data-gen/route.ts index a89946588..a00b58fcc 100644 --- a/src/app/api/internal/data-gen/route.ts +++ b/src/app/api/internal/data-gen/route.ts @@ -9,7 +9,7 @@ export const runtime = "nodejs"; export async function POST(request: NextRequest) { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); } diff --git a/src/app/api/prices/cloud-model-count/route.ts b/src/app/api/prices/cloud-model-count/route.ts index 52460e859..393255ea5 100644 --- a/src/app/api/prices/cloud-model-count/route.ts +++ b/src/app/api/prices/cloud-model-count/route.ts @@ -5,7 +5,7 @@ import { fetchAndParseCloudPriceTable } from "@/lib/price-sync/cloud-price-table export async function GET() { // 权限检查:只有管理员可以访问 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ ok: false, error: "无权限访问此资源" }, { status: 403 }); } diff --git a/src/app/api/prices/route.ts b/src/app/api/prices/route.ts index 50fa3476c..926b1af36 100644 --- a/src/app/api/prices/route.ts +++ b/src/app/api/prices/route.ts @@ -18,7 +18,7 @@ export async function GET(request: NextRequest) { try { // 权限检查:只有管理员可以访问价格数据 const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ ok: false, error: "无权限访问此资源" }, { status: 403 }); } diff --git a/src/app/api/prices/vendors/route.ts b/src/app/api/prices/vendors/route.ts index 170d2a437..8954d98c9 100644 --- a/src/app/api/prices/vendors/route.ts +++ b/src/app/api/prices/vendors/route.ts @@ -18,7 +18,7 @@ export const dynamic = "force-dynamic"; */ export async function GET() { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return NextResponse.json({ ok: false, error: "无权限访问此资源" }, { status: 403 }); } diff --git a/src/app/api/v1/resources/providers/handlers.ts b/src/app/api/v1/resources/providers/handlers.ts index e6da2e7fa..93398344c 100644 --- a/src/app/api/v1/resources/providers/handlers.ts +++ b/src/app/api/v1/resources/providers/handlers.ts @@ -22,6 +22,7 @@ import { jsonResponse, noContentResponse, } from "@/lib/api/v1/_shared/response-helpers"; +import { ProviderCacheEffectivenessListQuerySchema } from "@/lib/api/v1/schemas/provider-cache-effectiveness"; import { HIDDEN_PROVIDER_TYPES, ProviderApiTestSchema, @@ -339,6 +340,24 @@ export async function listProviderGroups(c: Context): Promise { return result.ok ? jsonResponse({ items: result.data }) : actionError(c, result); } +export async function listProviderCacheEffectiveness(c: Context): Promise { + const query = ProviderCacheEffectivenessListQuerySchema.safeParse({ + providerId: c.req.query("providerId"), + limit: c.req.query("limit"), + }); + if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); + + const actions = await import("@/actions/provider-cache-effectiveness"); + const result = await callAction( + c, + actions.getProviderCacheEffectivenessWindows, + [query.data] as never[], + c.get("auth") + ); + if (!result.ok) return actionError(c, result); + return jsonResponse({ items: result.data }); +} + export async function autoSortProviders(c: Context): Promise { const body = await parseJson(c, ProviderConfirmBodySchema); if (body instanceof Response) return body; diff --git a/src/app/api/v1/resources/providers/router.ts b/src/app/api/v1/resources/providers/router.ts index b3db03f7e..86c3a07da 100644 --- a/src/app/api/v1/resources/providers/router.ts +++ b/src/app/api/v1/resources/providers/router.ts @@ -3,6 +3,10 @@ import { requireAuth } from "@/lib/api/v1/_shared/auth-middleware"; import { PUBLIC_PROVIDER_TYPE_VALUES } from "@/lib/api/v1/_shared/constants"; import { fromZodError } from "@/lib/api/v1/_shared/error-envelope"; import { ProblemJsonSchema } from "@/lib/api/v1/schemas/_common"; +import { + ProviderCacheEffectivenessListQuerySchema, + ProviderCacheEffectivenessListResponseSchema, +} from "@/lib/api/v1/schemas/provider-cache-effectiveness"; import { ProviderApiTestSchema, ProviderArrayResponseSchema, @@ -42,6 +46,7 @@ import { getProviderModelSuggestions, getProvidersHealth, getProviderTestPresets, + listProviderCacheEffectiveness, listProviderGroups, listProviders, previewBatchPatch, @@ -292,6 +297,31 @@ providersRouter.openapi( getProvidersHealth as never ); +providersRouter.openapi( + createRoute({ + method: "get", + path: "/providers/cache-effectiveness", + middleware: requireAuth("admin"), + tags: ["Providers"], + summary: "List provider cache effectiveness windows", + description: + "Lists aggregated prompt cache effectiveness windows per provider, model, and cache TTL bucket, ordered by window end descending. Read-only metrics; routing and billing are unaffected.", + "x-required-access": "admin", + security, + request: { query: ProviderCacheEffectivenessListQuerySchema }, + responses: { + 200: { + description: "Cache effectiveness windows.", + content: { + "application/json": { schema: ProviderCacheEffectivenessListResponseSchema }, + }, + }, + ...problemResponses, + }, + }), + listProviderCacheEffectiveness as never +); + providersRouter.openapi( createRoute({ method: "post", diff --git a/src/app/v1/_lib/codex/session-completer.ts b/src/app/v1/_lib/codex/session-completer.ts index d6fceaa6d..7fe4d249a 100644 --- a/src/app/v1/_lib/codex/session-completer.ts +++ b/src/app/v1/_lib/codex/session-completer.ts @@ -154,7 +154,7 @@ async function getOrCreateSessionIdFromFingerprint( const ttlSeconds = getSessionTtlSeconds(); const fingerprintHash = calculateFingerprintHash(args); - if (!redis || redis.status !== "ready" || !fingerprintHash) { + if (redis?.status !== "ready" || !fingerprintHash) { return { sessionId: generateUuidV7(), source: "generated_uuid_v7", diff --git a/src/app/v1/_lib/proxy/affinity/affinity-recorder.ts b/src/app/v1/_lib/proxy/affinity/affinity-recorder.ts new file mode 100644 index 000000000..1f212e40b --- /dev/null +++ b/src/app/v1/_lib/proxy/affinity/affinity-recorder.ts @@ -0,0 +1,67 @@ +import { getEnvConfig } from "@/lib/config/env.schema"; +import { logger } from "@/lib/logger"; +import type { ProxySession } from "../session"; +import { getAffinityStore } from "./affinity-store"; +import { isAffinityRoutingEnabled } from "./config"; +import { fingerprintTip } from "./fingerprint"; + +/** + * F3a 亲和写回与墓碑(不变量:仅 owner 成功终态写回;软提名失败定向自愈)。 + * 全部 fire-and-forget 语义:任何失败只记日志,绝不影响请求主路径。 + */ + +/** + * 成功终态写回:tip + sys 两键绑定到胜出供应商,滑动 TTL。 + * 调用点:流式 commitSideEffects(计费持久化成功后)与非流式成功分支。 + * replay serve / 竞速败者 / 失败重试不得调用。 + */ +export async function recordAffinityWinner( + session: ProxySession, + providerId: number +): Promise { + const affinity = session.affinity; + if (!affinity || providerId <= 0) return; + try { + if (!(await isAffinityRoutingEnabled())) return; + const tip = fingerprintTip(affinity.chain); + await getAffinityStore().put( + affinity.scopeTag, + tip.fp, + affinity.chain.sys.fp, + providerId, + getEnvConfig().PREFIX_AFFINITY_TTL_SECONDS + ); + } catch (error) { + logger.debug("[AffinityRecorder] winner writeback failed", { + error: error instanceof Error ? error.message : String(error), + providerId, + }); + } +} + +/** + * failover 墓碑:仅当失败供应商正是亲和提名的供应商时,对命中边界写短 TTL 墓碑, + * 阻止后续请求羊群式撞向同一故障绑定;查找会跳过墓碑继续向浅回落。 + */ +export async function tombstoneAffinityOnFailure( + session: ProxySession, + failedProviderId: number +): Promise { + const affinity = session.affinity; + if ( + !affinity?.matchedFp || + affinity.nominatedProviderId === null || + affinity.nominatedProviderId !== failedProviderId + ) { + return; + } + try { + if (!(await isAffinityRoutingEnabled())) return; + await getAffinityStore().tombstone(affinity.scopeTag, affinity.matchedFp, "failover"); + } catch (error) { + logger.debug("[AffinityRecorder] tombstone failed", { + error: error instanceof Error ? error.message : String(error), + failedProviderId, + }); + } +} diff --git a/src/app/v1/_lib/proxy/affinity/affinity-store.ts b/src/app/v1/_lib/proxy/affinity/affinity-store.ts new file mode 100644 index 000000000..e4f6c4b6b --- /dev/null +++ b/src/app/v1/_lib/proxy/affinity/affinity-store.ts @@ -0,0 +1,184 @@ +import "server-only"; + +import type Redis from "ioredis"; +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis/client"; + +/** + * 前缀亲和绑定存储(CCHP storage/dragonfly/affinity_store.go 的移植与改进)。 + * + * 键格式:cch:pfx:{}:fp: + * ({scopeTag} 为 Redis Cluster hash-tag:同 scope 的所有 fp 键落同一 slot, + * 多键 Lua 在集群下无 CROSSSLOT;单机 Redis 下花括号只是键名的一部分,无副作用。) + * + * 值格式(管道串,避免 JSON 编解码开销): + * 活跃绑定 "1|" + * 墓碑 "0|"(failover 后短 TTL 防羊群,查找时跳过继续向浅—— + * 修复 CCHP 已知缺陷:最深命中为 disabled 时直接判 miss) + * + * 查找:单次 Lua 往返,KEYS 按最深->最浅传入,首个活跃值即最长前缀命中, + * 命中时 EXPIRE 滑动续期(对齐 prompt cache 的「读即续」语义)。 + * + * 一切 Redis 失败 fail-open:lookup 返回 null(回落加权随机),写操作静默放弃。 + */ + +const LOOKUP_LONGEST_PREFIX_LUA = ` +local ttl = tonumber(ARGV[1]) +for i = 1, #KEYS do + local v = redis.call('GET', KEYS[i]) + if v and string.sub(v, 1, 2) == '1|' then + if ttl and ttl > 0 then + redis.call('EXPIRE', KEYS[i], ttl) + end + return {i, v} + end +end +return nil +`; + +const TOMBSTONE_TTL_SECONDS = 60; + +export interface AffinityHint { + providerId: number; + /** 命中的边界在传入序列中的位置换算出的深度语义 */ + tier: "conversation" | "system"; + matchedFp: string; + /** 0-based:0 = 最深(tip),越大越浅;仅用于观测 */ + matchedIndex: number; +} + +type RedisLuaClient = Pick & { + eval(...args: [script: string, numkeys: number, ...rest: (string | number)[]]): Promise; +}; + +export interface AffinityStoreOptions { + redisClient?: RedisLuaClient | null; +} + +export class AffinityStore { + private readonly injectedClient?: RedisLuaClient | null; + + constructor(options: AffinityStoreOptions = {}) { + this.injectedClient = options.redisClient; + } + + private getReadyRedis(): RedisLuaClient | null { + const redis = + this.injectedClient !== undefined + ? this.injectedClient + : (getRedisClient({ allowWhenRateLimitDisabled: true }) as RedisLuaClient | null); + if (redis?.status !== "ready") return null; + return redis; + } + + private buildKey(scopeTag: string, fp: string): string { + return `cch:pfx:{${scopeTag}}:fp:${fp}`; + } + + /** + * 最长前缀查找。fpsDeepestFirst 为最深->最浅指纹序列(最后一个是 F_sys)。 + * 命中活跃绑定即返回并滑动续期;墓碑被 Lua 跳过继续向浅。 + */ + async lookup( + scopeTag: string, + fpsDeepestFirst: string[], + slidingTtlSeconds: number + ): Promise { + if (!scopeTag || fpsDeepestFirst.length === 0) return null; + const redis = this.getReadyRedis(); + if (!redis) return null; + + const keys = fpsDeepestFirst + .filter((fp) => fp.length > 0) + .map((fp) => this.buildKey(scopeTag, fp)); + if (keys.length === 0) return null; + + try { + const result = (await redis.eval( + LOOKUP_LONGEST_PREFIX_LUA, + keys.length, + ...keys, + String(Math.max(0, Math.floor(slidingTtlSeconds))) + )) as [number, string] | null; + + if (!result || !Array.isArray(result) || result.length < 2) return null; + const [index, value] = result; + const providerId = Number.parseInt(String(value).slice(2), 10); + if (!Number.isFinite(providerId) || providerId <= 0) return null; + + const matchedIndex = Number(index) - 1; + return { + providerId, + matchedIndex, + matchedFp: fpsDeepestFirst[matchedIndex] ?? "", + // 最后一个键是 F_sys:仅系统提示词命中 + tier: matchedIndex >= fpsDeepestFirst.length - 1 ? "system" : "conversation", + }; + } catch (error) { + logger.warn("[AffinityStore] lookup failed, falling back to no-affinity", { + error: error instanceof Error ? error.message : String(error), + scopeTag, + }); + return null; + } + } + + /** + * 成功终态写回:只写 tip + sys 两键(对话推进天然累积链条,无需写全窗口)。 + * 仅 owner 成功请求调用;replay serve / 竞速败者 / 失败重试不写。 + */ + async put( + scopeTag: string, + tipFp: string, + sysFp: string, + providerId: number, + ttlSeconds: number + ): Promise { + if (!scopeTag || !tipFp || providerId <= 0 || ttlSeconds <= 0) return; + const redis = this.getReadyRedis(); + if (!redis) return; + + const value = `1|${providerId}`; + try { + await redis.set(this.buildKey(scopeTag, tipFp), value, "EX", ttlSeconds); + if (sysFp && sysFp !== tipFp) { + await redis.set(this.buildKey(scopeTag, sysFp), value, "EX", ttlSeconds); + } + } catch (error) { + logger.warn("[AffinityStore] put failed", { + error: error instanceof Error ? error.message : String(error), + scopeTag, + providerId, + }); + } + } + + /** failover 墓碑:短 TTL 覆盖,阻止旧绑定立即复活,同时允许查找向浅回落。 */ + async tombstone(scopeTag: string, fp: string, reason: string): Promise { + if (!scopeTag || !fp) return; + const redis = this.getReadyRedis(); + if (!redis) return; + try { + await redis.set( + this.buildKey(scopeTag, fp), + `0|${reason.slice(0, 32)}`, + "EX", + TOMBSTONE_TTL_SECONDS + ); + } catch (error) { + logger.warn("[AffinityStore] tombstone failed", { + error: error instanceof Error ? error.message : String(error), + scopeTag, + }); + } + } +} + +let sharedStore: AffinityStore | null = null; + +export function getAffinityStore(): AffinityStore { + if (!sharedStore) { + sharedStore = new AffinityStore(); + } + return sharedStore; +} diff --git a/src/app/v1/_lib/proxy/affinity/config.ts b/src/app/v1/_lib/proxy/affinity/config.ts new file mode 100644 index 000000000..d27bbb26a --- /dev/null +++ b/src/app/v1/_lib/proxy/affinity/config.ts @@ -0,0 +1,17 @@ +import { getEnvConfig } from "@/lib/config/env.schema"; +import { + getProxyRuntimeSettings, + type ProxyRuntimeSettings, +} from "@/lib/system-settings/proxy-runtime"; + +/** + * F3a 亲和路由总开关:env 强制开启,或系统设置「忽略客户端 Session ID」开启(产品默认开)。 + * 日常开关由系统设置驱动,ENABLE_PREFIX_AFFINITY 仅作显式强制/兜底。 + */ +export function isAffinityRoutingEnabledWith(settings: ProxyRuntimeSettings): boolean { + return getEnvConfig().ENABLE_PREFIX_AFFINITY || settings.affinityIgnoreClientSessionId; +} + +export async function isAffinityRoutingEnabled(): Promise { + return isAffinityRoutingEnabledWith(await getProxyRuntimeSettings()); +} diff --git a/src/app/v1/_lib/proxy/affinity/fingerprint.ts b/src/app/v1/_lib/proxy/affinity/fingerprint.ts new file mode 100644 index 000000000..cd6be60ad --- /dev/null +++ b/src/app/v1/_lib/proxy/affinity/fingerprint.ts @@ -0,0 +1,521 @@ +import { sha256Hex, stableStringify } from "@/lib/request-identity"; +import type { ClientFormat } from "../format-mapper"; + +/** + * 最长前缀亲和的链式指纹(CCHP planner/session/fingerprint.go 的移植与改进)。 + * + * 算法: + * F_sys = H( normalize(system + tools) ) + * F_i = H( F_{i-1} || normalize(message_i) ) + * H = sha256 截 32 hex(只需系统内自洽,不与 CCHP 字节对齐)。 + * 任意单字符改动令 F_i 及更深全部改变,查找时自然回落到最长未变祖先。 + * + * 对 CCHP 已知缺陷的三处改进(获批计划明确采纳): + * 1. 工具 Parameters 用键序稳定的 canonical JSON 全量序列化(CCHP 丢弃嵌套值); + * 2. 图片/文档取内容 sha256 摘要(CCHP 仅取 base64 长度,同长异图会碰撞); + * 3. anthropic cache_control 断点标记在消息边界上(子消息粒度省略:真实客户端的 + * 断点落在顶层块边界,消息级边界已覆盖前缀匹配语义)。 + * + * 归一化规则: + * - role 永远进哈希;tool_use/tool_call 的易变 id 一律剥离(网关可能重写); + * - openai chat 的前导 system/developer 消息并入 F_sys(跨对话稳定段); + * - 空消息跳过;任何异常返回 null(fail-open,不做亲和)。 + */ + +export interface FingerprintBoundary { + /** 0 = F_sys(系统+工具);>=1 = 第 depth 条会话消息后的累计边界 */ + depth: number; + /** sha256 截 32 hex */ + fp: string; + /** 从开头到本边界(含)的规范化累计字节数,用于最长匹配排序与理论缓存估算 */ + prefixBytes: number; + /** anthropic cache_control 显式断点落在本消息上 */ + hasCacheControl?: boolean; +} + +export interface FingerprintChain { + sys: FingerprintBoundary; + /** 浅 -> 深(追加式构建),长度受 window 截断,Sys 永远单独保留 */ + tail: FingerprintBoundary[]; +} + +export const DEFAULT_AFFINITY_WINDOW = 8; +export const MAX_AFFINITY_WINDOW = 64; + +const SEP = ""; + +export function fingerprintTip(chain: FingerprintChain): FingerprintBoundary { + return chain.tail.length > 0 ? chain.tail[chain.tail.length - 1] : chain.sys; +} + +/** 供查找使用的最深 -> 最浅指纹序列(最后一个永远是 Sys)。 */ +export function fingerprintsDeepestFirst(chain: FingerprintChain): string[] { + const out: string[] = []; + for (let i = chain.tail.length - 1; i >= 0; i--) { + out.push(chain.tail[i].fp); + } + out.push(chain.sys.fp); + return out; +} + +export function computeFingerprintChain( + message: Record, + format: ClientFormat, + window: number = DEFAULT_AFFINITY_WINDOW +): FingerprintChain | null { + try { + return computeChainInner(message, format, normalizeWindow(window)); + } catch { + return null; + } +} + +function normalizeWindow(window: number): number { + if (!Number.isFinite(window) || window <= 0) return DEFAULT_AFFINITY_WINDOW; + return Math.min(Math.floor(window), MAX_AFFINITY_WINDOW); +} + +interface NormalizedMessage { + bytes: string; + hasCacheControl: boolean; +} + +function computeChainInner( + message: Record, + format: ClientFormat, + window: number +): FingerprintChain | null { + const extracted = extractConversation(message, format); + if (!extracted) return null; + + const sysBytes = extracted.sysSegments.join(""); + const sysFp = h32(sysBytes); + let cumBytes = byteLength(sysBytes); + const sys: FingerprintBoundary = { depth: 0, fp: sysFp, prefixBytes: cumBytes }; + + const tail: FingerprintBoundary[] = []; + let prev = sysFp; + let depth = 0; + for (const normalized of extracted.messages) { + if (normalized.bytes.length === 0) continue; + depth++; + prev = h32(prev + normalized.bytes); + cumBytes += byteLength(normalized.bytes); + tail.push({ + depth, + fp: prev, + prefixBytes: cumBytes, + ...(normalized.hasCacheControl ? { hasCacheControl: true } : {}), + }); + } + + if (tail.length > window) { + tail.splice(0, tail.length - window); + } + + return { sys, tail }; +} + +function h32(input: string): string { + return sha256Hex(input).slice(0, 32); +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +interface ExtractedConversation { + sysSegments: string[]; + messages: NormalizedMessage[]; +} + +function extractConversation( + body: Record, + format: ClientFormat +): ExtractedConversation | null { + switch (format) { + case "claude": + return extractClaude(body); + case "openai": + return extractOpenAIChat(body); + case "response": + return extractResponses(body); + case "gemini": + return extractGemini(body); + case "gemini-cli": { + const request = body.request; + if (request && typeof request === "object") { + return extractGemini(request as Record); + } + return extractGemini(body); + } + default: + return null; + } +} + +// ===== claude (Anthropic Messages) ===== + +function extractClaude(body: Record): ExtractedConversation | null { + const messages = body.messages; + if (!Array.isArray(messages)) return null; + + const sysSegments: string[] = [SEP]; + const system = body.system; + if (typeof system === "string") { + sysSegments.push(system); + } else if (Array.isArray(system)) { + for (const block of system) { + sysSegments.push(normalizeContentBlock(block)); + } + } + appendTools(sysSegments, body.tools, (tool) => ({ + name: readString(tool, "name"), + description: readString(tool, "description"), + parameters: readRecord(tool, "input_schema"), + })); + + const normalizedMessages: NormalizedMessage[] = []; + for (const raw of messages) { + if (!raw || typeof raw !== "object") continue; + const msg = raw as Record; + const parts: string[] = [SEP, readString(msg, "role")]; + let hasCacheControl = false; + const content = msg.content; + if (typeof content === "string") { + parts.push(SEP, "text:", content); + } else if (Array.isArray(content)) { + for (const block of content) { + parts.push(normalizeContentBlock(block)); + if ( + block && + typeof block === "object" && + (block as Record).cache_control + ) { + hasCacheControl = true; + } + } + } + normalizedMessages.push(finishMessage(parts, hasCacheControl)); + } + + return { sysSegments, messages: normalizedMessages }; +} + +/** anthropic 内容块归一化(system 块与 message 块共用)。 */ +function normalizeContentBlock(block: unknown): string { + if (!block || typeof block !== "object") { + return typeof block === "string" ? `${SEP}text:${block}` : ""; + } + const typed = block as Record; + const type = readString(typed, "type"); + switch (type) { + case "text": + return `${SEP}text:${readString(typed, "text")}`; + case "thinking": + // 思考签名跨轮可能变化,不入指纹 + return `${SEP}thinking:${readString(typed, "thinking")}`; + case "redacted_thinking": + return `${SEP}redacted_thinking:${readString(typed, "data")}`; + case "tool_use": + // 剥 id,保留工具身份(name + input) + return `${SEP}tool_use:${readString(typed, "name")}:${stableStringify(typed.input ?? null)}`; + case "tool_result": + // 剥 tool_use_id,保留结果内容 + return `${SEP}tool_result:${serializeUnknownContent(typed.content)}`; + case "image": + case "document": { + const source = readRecord(typed, "source"); + return `${SEP}${type}:${digestMediaSource(source)}`; + } + default: + return type ? `${SEP}${type}:${stableStringify(stripVolatileKeys(typed))}` : ""; + } +} + +function digestMediaSource(source: Record | null): string { + if (!source) return ""; + const mediaType = readString(source, "media_type") || readString(source, "mediaType"); + const data = readString(source, "data"); + if (data) { + // 内容摘要(而非长度):同长异图不碰撞;base64 原文绝不进指纹 + return `${mediaType}:${sha256Hex(data).slice(0, 32)}`; + } + const url = readString(source, "url"); + return `${mediaType}:${url}`; +} + +// ===== openai (Chat Completions) ===== + +function extractOpenAIChat(body: Record): ExtractedConversation | null { + const messages = body.messages; + if (!Array.isArray(messages)) return null; + + const sysSegments: string[] = [SEP]; + appendTools(sysSegments, body.tools, (tool) => { + const fn = readRecord(tool, "function"); + return { + name: fn ? readString(fn, "name") : readString(tool, "name"), + description: fn ? readString(fn, "description") : readString(tool, "description"), + parameters: fn ? readRecord(fn, "parameters") : null, + }; + }); + + const normalizedMessages: NormalizedMessage[] = []; + let inLeadingSystem = true; + for (const raw of messages) { + if (!raw || typeof raw !== "object") continue; + const msg = raw as Record; + const role = readString(msg, "role"); + // 前导 system/developer 消息属于跨对话稳定段,并入 F_sys + if (inLeadingSystem && (role === "system" || role === "developer")) { + sysSegments.push(SEP, role, ":", serializeUnknownContent(msg.content)); + continue; + } + inLeadingSystem = false; + + const parts: string[] = [SEP, role]; + parts.push(SEP, "content:", serializeUnknownContent(msg.content)); + const toolCalls = msg.tool_calls; + if (Array.isArray(toolCalls)) { + for (const call of toolCalls) { + if (!call || typeof call !== "object") continue; + const typedCall = call as Record; + const fn = readRecord(typedCall, "function"); + // 剥 call id + parts.push( + SEP, + "tool_call:", + fn ? readString(fn, "name") : "", + ":", + fn ? readString(fn, "arguments") : "" + ); + } + } + if (msg.tool_call_id !== undefined) { + // tool 角色消息:剥 tool_call_id,内容已在 content 段 + parts.push(SEP, "tool_result"); + } + normalizedMessages.push(finishMessage(parts, false)); + } + + return { sysSegments, messages: normalizedMessages }; +} + +// ===== response (OpenAI Responses / Codex) ===== + +function extractResponses(body: Record): ExtractedConversation | null { + const input = body.input; + + const sysSegments: string[] = [SEP]; + const instructions = body.instructions; + if (typeof instructions === "string") { + sysSegments.push(instructions); + } + appendTools(sysSegments, body.tools, (tool) => ({ + name: readString(tool, "name"), + description: readString(tool, "description"), + parameters: readRecord(tool, "parameters"), + })); + + const normalizedMessages: NormalizedMessage[] = []; + if (typeof input === "string") { + normalizedMessages.push({ bytes: `${SEP}user${SEP}text:${input}`, hasCacheControl: false }); + } else if (Array.isArray(input)) { + for (const raw of input) { + if (!raw || typeof raw !== "object") continue; + const item = raw as Record; + const type = readString(item, "type") || "message"; + const parts: string[] = [SEP]; + switch (type) { + case "message": + parts.push( + readString(item, "role"), + SEP, + "content:", + serializeUnknownContent(item.content) + ); + break; + case "function_call": + // 剥 call_id / id + parts.push( + "function_call", + SEP, + readString(item, "name"), + ":", + readString(item, "arguments") + ); + break; + case "function_call_output": + parts.push("function_call_output", SEP, serializeUnknownContent(item.output)); + break; + case "reasoning": + parts.push("reasoning", SEP, serializeUnknownContent(item.summary ?? item.content)); + break; + default: + parts.push(type, SEP, stableStringify(stripVolatileKeys(item))); + } + normalizedMessages.push(finishMessage(parts, false)); + } + } else { + return null; + } + + return { sysSegments, messages: normalizedMessages }; +} + +// ===== gemini ===== + +function extractGemini(body: Record): ExtractedConversation | null { + const contents = body.contents; + if (!Array.isArray(contents)) return null; + + const sysSegments: string[] = [SEP]; + const systemInstruction = + readRecord(body, "systemInstruction") ?? readRecord(body, "system_instruction"); + if (systemInstruction) { + const parts = systemInstruction.parts; + if (Array.isArray(parts)) { + for (const part of parts) { + sysSegments.push(normalizeGeminiPart(part)); + } + } + } + appendTools(sysSegments, flattenGeminiTools(body.tools), (decl) => ({ + name: readString(decl, "name"), + description: readString(decl, "description"), + parameters: readRecord(decl, "parameters"), + })); + + const normalizedMessages: NormalizedMessage[] = []; + for (const raw of contents) { + if (!raw || typeof raw !== "object") continue; + const content = raw as Record; + const parts: string[] = [SEP, readString(content, "role")]; + const contentParts = content.parts; + if (Array.isArray(contentParts)) { + for (const part of contentParts) { + parts.push(normalizeGeminiPart(part)); + } + } + normalizedMessages.push(finishMessage(parts, false)); + } + + return { sysSegments, messages: normalizedMessages }; +} + +function flattenGeminiTools(tools: unknown): unknown[] { + if (!Array.isArray(tools)) return []; + const declarations: unknown[] = []; + for (const tool of tools) { + if (!tool || typeof tool !== "object") continue; + const decls = (tool as Record).functionDeclarations; + if (Array.isArray(decls)) { + declarations.push(...decls); + } else { + declarations.push(tool); + } + } + return declarations; +} + +function normalizeGeminiPart(part: unknown): string { + if (!part || typeof part !== "object") return ""; + const typed = part as Record; + if (typeof typed.text === "string") { + return `${SEP}text:${typed.text}`; + } + const functionCall = readRecord(typed, "functionCall"); + if (functionCall) { + return `${SEP}tool_use:${readString(functionCall, "name")}:${stableStringify(functionCall.args ?? null)}`; + } + const functionResponse = readRecord(typed, "functionResponse"); + if (functionResponse) { + // 剥易变 id,保留工具名 + 响应内容 + return `${SEP}tool_result:${readString(functionResponse, "name")}:${stableStringify(functionResponse.response ?? null)}`; + } + const inlineData = readRecord(typed, "inlineData") ?? readRecord(typed, "inline_data"); + if (inlineData) { + const mime = readString(inlineData, "mimeType") || readString(inlineData, "mime_type"); + const data = readString(inlineData, "data"); + return `${SEP}image:${mime}:${data ? sha256Hex(data).slice(0, 32) : ""}`; + } + const fileData = readRecord(typed, "fileData") ?? readRecord(typed, "file_data"); + if (fileData) { + const mime = readString(fileData, "mimeType") || readString(fileData, "mime_type"); + const uri = readString(fileData, "fileUri") || readString(fileData, "file_uri"); + return `${SEP}file:${mime}:${uri}`; + } + return `${SEP}part:${stableStringify(stripVolatileKeys(typed))}`; +} + +// ===== 共享工具 ===== + +interface NormalizedToolSpec { + name: string; + description: string; + parameters: Record | null; +} + +function appendTools( + segments: string[], + tools: unknown, + project: (tool: Record) => NormalizedToolSpec +): void { + if (!Array.isArray(tools) || tools.length === 0) return; + const specs: NormalizedToolSpec[] = []; + for (const raw of tools) { + if (!raw || typeof raw !== "object") continue; + const spec = project(raw as Record); + if (!spec.name) continue; + specs.push(spec); + } + // 按 name 排序:工具顺序差异不产生不同 F_sys + specs.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const spec of specs) { + segments.push( + SEP, + spec.name, + ":", + spec.description, + ":", + spec.parameters ? stableStringify(spec.parameters) : "" + ); + } +} + +function finishMessage(parts: string[], hasCacheControl: boolean): NormalizedMessage { + // 只有分隔符 + role 而无任何内容段的消息视为空 + const bytes = parts.join(""); + const meaningful = parts.length > 2; + return { bytes: meaningful ? bytes : "", hasCacheControl }; +} + +function serializeUnknownContent(content: unknown): string { + if (content === undefined || content === null) return ""; + if (typeof content === "string") return content; + return stableStringify(content); +} + +const VOLATILE_KEYS = new Set(["id", "call_id", "tool_use_id", "tool_call_id", "cache_control"]); + +function stripVolatileKeys(record: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (VOLATILE_KEYS.has(key)) continue; + out[key] = value; + } + return out; +} + +function readString(record: Record, key: string): string { + const value = record[key]; + return typeof value === "string" ? value : ""; +} + +function readRecord(record: Record, key: string): Record | null { + const value = record[key]; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/src/app/v1/_lib/proxy/fake-streaming/response-validator.ts b/src/app/v1/_lib/proxy/fake-streaming/response-validator.ts index 83546a7e8..aa1d113b0 100644 --- a/src/app/v1/_lib/proxy/fake-streaming/response-validator.ts +++ b/src/app/v1/_lib/proxy/fake-streaming/response-validator.ts @@ -1,3 +1,5 @@ +import { parseSseBody } from "../stream-gate/sse-frames"; + export type ProtocolFamily = "anthropic" | "openai-chat" | "openai-responses" | "gemini"; export type ValidationFailureCode = @@ -308,50 +310,23 @@ interface SseEvent { data: string; } +// SSE 分帧复用 stream-gate 的共享增量分帧器;本函数只做 kind 映射 +// ([DONE] / event:error 识别与空载荷过滤),判定语义与历史实现一致。 function collectSseEvents(body: string): SseEvent[] { const events: SseEvent[] = []; - const dataLines: string[] = []; - let currentEvent: string | null = null; - - const flush = () => { - if (dataLines.length === 0) { - currentEvent = null; - return; - } - const payload = dataLines.join("\n").trim(); - dataLines.length = 0; - const event = currentEvent; - currentEvent = null; - if (!payload) return; + for (const frame of parseSseBody(body)) { + const payload = frame.data.trim(); + if (!payload) continue; if (payload === "[DONE]") { - events.push({ kind: "done", eventName: event, data: payload }); - return; - } - if (event === "error") { - events.push({ kind: "error", eventName: event, data: payload }); - return; - } - events.push({ kind: "data", eventName: event, data: payload }); - }; - - for (const rawLine of body.split(/\r?\n/)) { - const line = rawLine; - if (line.length === 0) { - flush(); + events.push({ kind: "done", eventName: frame.eventName, data: payload }); continue; } - if (line.startsWith(":")) continue; // SSE comment - if (line.startsWith("event:")) { - currentEvent = line.slice(6).trim(); + if (frame.eventName === "error") { + events.push({ kind: "error", eventName: frame.eventName, data: payload }); continue; } - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).replace(/^\s/, "")); - } - // `id:` / `retry:` are valid SSE fields that don't carry deliverable data, - // so we intentionally skip them without bumping any state. + events.push({ kind: "data", eventName: frame.eventName, data: payload }); } - flush(); return events; } diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 405710c3b..b7b40fd3a 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -66,6 +66,7 @@ import { RESERVED_INTERNAL_HEADERS } from "../responses-ws/internal-secret"; import { markResponsesWsUnsupported } from "../responses-ws/unsupported-cache"; import { tryResponsesWebsocketUpstream } from "../responses-ws/upstream-adapter"; import { buildProxyUrl } from "../url"; +import { recordAffinityWinner, tombstoneAffinityOnFailure } from "./affinity/affinity-recorder"; import { rectifyBillingHeader } from "./billing-header-rectifier"; import { bindClientAbortListener } from "./client-abort-listener"; import { deriveClientSafeUpstreamErrorMessage } from "./client-error-message"; @@ -112,6 +113,13 @@ import { type DeferredStreamingHedgeBindingAuthority, setDeferredStreamingFinalization, } from "./stream-finalization"; +import { mapProviderTypeToFamily } from "./stream-gate/frame-classifier"; +import { + concatChunks, + resolveStreamGateCaps, + resolveStreamGateMode, + runStreamContentGate, +} from "./stream-gate/stream-content-gate"; import { detectThinkingBudgetRectifierTrigger, rectifyThinkingBudget, @@ -204,20 +212,6 @@ const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商( const DISCOVERY_LEASE_HANDOFF_GRACE_SECONDS = 5; const DISCOVERY_TERMINAL_CLEANUP_MAX_MS = 1_000; -function isDiscoveryRolloutEligible(keyId: number, sessionId: string, percent: number): boolean { - const normalizedPercent = Math.max(0, Math.min(100, Math.floor(percent))); - if (normalizedPercent === 0) return false; - if (normalizedPercent === 100) return true; - - // FNV-1a provides a deterministic bucket without persisting rollout state. - let hash = 0x811c9dc5; - for (const character of `${keyId}:${sessionId}`) { - hash ^= character.charCodeAt(0); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0) % 100 < normalizedPercent; -} - type CacheTtlOption = CacheTtlPreference | null | undefined; type ProxySessionWithAttemptRuntime = ProxySession & { @@ -298,7 +292,6 @@ type DiscoveryBypassReason = | "raw_cross_provider_fallback" | "missing_session" | "missing_key" - | "rollout_ineligible" | "redis_capability_unavailable" | "binding_conflict" | "lease_conflict" @@ -1655,6 +1648,84 @@ export class ProxyForwarder { // 解决:Forwarder 只负责尽快把 Response 返回给下游开始透传, // 把最终成功/失败结算延迟到 ResponseHandler:等 SSE 正常结束后再基于最终 body 补充检查并更新内部状态。 if (isSSE) { + // ========== F1 流式内容门控(enforce 模式)========== + // 在向客户端提交响应前等待首个有效内容帧: + // - 中性前缀(ping/metadata/usage-only)缓冲后随提交一并冲刷; + // - error/malformed/空流在此抛错 -> 外层 catch 归类 -> 换供应商(客户端零字节); + // - 首字节计时器(doForward 设置,response-handler 读到首字节才清除) + // 在门控期间继续生效,天然升级为「首个有效内容超时」。 + let streamingResponse = response; + const gateMode = resolveStreamGateMode(); + if ( + gateMode === "enforce" && + response.body && + session.getEndpointPolicy().kind !== "raw_passthrough" + ) { + const gateFamily = mapProviderTypeToFamily(currentProvider.providerType); + if (gateFamily) { + const gateReader = response.body.getReader(); + const gate = await runStreamContentGate(gateReader, { + family: gateFamily, + providerId: currentProvider.id, + providerName: currentProvider.name, + ...resolveStreamGateCaps(), + }); + + if (!gate.committed) { + const runtime = session as ProxySession & { + responseController?: AbortController; + clearResponseTimeout?: () => void; + releaseAgent?: () => void; + }; + // 先于清理读取超时来源:区分首字节/首内容超时与客户端断开 + const timedOutBeforeContent = + runtime.responseController?.signal.aborted === true && + session.clientAbortSignal?.aborted !== true; + + void gateReader.cancel("stream_gate_precommit").catch(() => undefined); + // response-handler 不会接手该响应:本层负责清理计时器与 agent 引用 + runtime.clearResponseTimeout?.(); + runtime.releaseAgent?.(); + + if (timedOutBeforeContent) { + throw new ProxyError( + `供应商首个有效内容超时: 门控在收到有效内容帧前被首字节计时器中止`, + 524, + { + body: JSON.stringify({ + error: { + type: "timeout_error", + message: "Provider failed to deliver first valid content frame", + timeout_type: "streaming_first_valid_content", + }, + }), + providerId: currentProvider.id, + providerName: currentProvider.name, + } + ); + } + throw gate.error; + } + + logger.info("ProxyForwarder: Stream content gate committed", { + providerId: currentProvider.id, + providerName: currentProvider.name, + framesSeen: gate.framesSeen, + prefixChunks: gate.prefixChunks.length, + readerDone: gate.readerDone, + }); + + streamingResponse = new Response( + ProxyForwarder.buildBufferedPrefixStream(gate.prefixChunks, gateReader), + { + status: response.status, + statusText: response.statusText, + headers: response.headers, + } + ); + } + } + setDeferredStreamingFinalization(session, { providerId: currentProvider.id, providerName: currentProvider.name, @@ -1677,7 +1748,7 @@ export class ProxyForwarder { statusCode: response.status, }); - return response; + return streamingResponse; } // 非流式响应:检测空响应 @@ -1915,6 +1986,9 @@ export class ProxyForwarder { circuitState: getCircuitState(currentProvider.id), }); + // F3a 亲和写回(非流式成功;流式由 finalizeStream 的终态副作用负责) + void recordAffinityWinner(session, currentProvider.id); + logger.info("ProxyForwarder: Request successful", { providerId: currentProvider.id, providerName: currentProvider.name, @@ -1934,6 +2008,14 @@ export class ProxyForwarder { if (databaseError) { errorCategory = ErrorCategory.LOCAL_OVERLOAD; } + + // F3a:亲和提名的供应商发生供应商侧失败 -> 定向写墓碑(短 TTL 自愈防羊群) + if ( + errorCategory === ErrorCategory.PROVIDER_ERROR || + errorCategory === ErrorCategory.RESOURCE_NOT_FOUND + ) { + void tombstoneAffinityOnFailure(session, currentProvider.id); + } const errorMessage = databaseError?.message ?? (lastError instanceof ProxyError @@ -4056,9 +4138,6 @@ export class ProxyForwarder { const keyId = session.authState?.key?.id ?? session.messageContext?.key?.id ?? null; if (!sessionId) return { status: "skipped", reason: "missing_session" }; if (keyId == null) return { status: "skipped", reason: "missing_key" }; - if (!isDiscoveryRolloutEligible(keyId, sessionId, getEnvConfig().DISCOVERY_ROLLOUT_PERCENT)) { - return { status: "skipped", reason: "rollout_ineligible" }; - } const capabilityState = await SessionManager.ensureVersionedBindingCapability(); if (capabilityState !== "available") { @@ -4567,18 +4646,41 @@ export class ProxyForwarder { attempt.reader = response.body.getReader(); try { - const firstChunk = await ProxyForwarder.readFirstReadableChunk(attempt.reader); - if (firstChunk.done) { - await handleAttemptFailure( - attempt, - new EmptyResponseError(attempt.provider.id, attempt.provider.name, "empty_body") - ); - return; - } + // F1 门控(enforce):胜者判定从「首个非空字节」升级为「首个有效内容帧」。 + // 级联阈值计时器保持不动——内容慢的 attempt 不提交,自动触发下一候选竞速。 + const hedgeGateFamily = + resolveStreamGateMode() === "enforce" && + session.getEndpointPolicy().kind !== "raw_passthrough" + ? mapProviderTypeToFamily(attempt.provider.providerType) + : null; + + if (hedgeGateFamily) { + const gate = await runStreamContentGate(attempt.reader, { + family: hedgeGateFamily, + providerId: attempt.provider.id, + providerName: attempt.provider.name, + ...resolveStreamGateCaps(), + }); + if (!gate.committed) { + throw gate.error; + } + // 保留完整门控前缀:若本 attempt 落败且需要计费,drain 时补回前缀里的 usage。 + attempt.firstChunk = concatChunks(gate.prefixChunks); + await commitWinner(attempt, gate.prefixChunks); + } else { + const firstChunk = await ProxyForwarder.readFirstReadableChunk(attempt.reader); + if (firstChunk.done) { + await handleAttemptFailure( + attempt, + new EmptyResponseError(attempt.provider.id, attempt.provider.name, "empty_body") + ); + return; + } - // 保留首块:若本 attempt 落败且需要计费,drain 时需要补回首块的 usage。 - attempt.firstChunk = firstChunk.value; - await commitWinner(attempt, firstChunk.value); + // 保留首块:若本 attempt 落败且需要计费,drain 时需要补回首块的 usage。 + attempt.firstChunk = firstChunk.value; + await commitWinner(attempt, [firstChunk.value]); + } // 本 attempt 读到首块却落败(winner 已先提交,commitWinner 早退): // 若开启输家计费且本 attempt 不是赢家,在此发起后台 drain(此时已无并发读)。 @@ -4629,6 +4731,13 @@ export class ProxyForwarder { let errorCategory = await categorizeErrorAsync(error); lastErrorCategory = errorCategory; + // F3a:hedge attempt 供应商侧失败且正是亲和提名者 -> 定向墓碑 + if ( + errorCategory === ErrorCategory.PROVIDER_ERROR || + errorCategory === ErrorCategory.RESOURCE_NOT_FOUND + ) { + void tombstoneAffinityOnFailure(session, attempt.provider.id); + } const statusCode = error instanceof ProxyError ? error.statusCode : undefined; const databaseError = findSafeDatabaseError(error); const errorMessage = @@ -4840,7 +4949,7 @@ export class ProxyForwarder { await finishIfExhausted(); }; - const commitWinner = async (attempt: StreamingHedgeAttempt, firstChunk: Uint8Array) => { + const commitWinner = async (attempt: StreamingHedgeAttempt, prefixChunks: Uint8Array[]) => { if (settled || winnerCommitted || attempt.settled || !attempt.response || !attempt.reader) return; @@ -4975,7 +5084,7 @@ export class ProxyForwarder { }); const response = new Response( - ProxyForwarder.buildBufferedFirstChunkStream(firstChunk, attempt.reader), + ProxyForwarder.buildBufferedPrefixStream(prefixChunks, attempt.reader), { status: attempt.response.status, statusText: attempt.response.statusText, @@ -5905,22 +6014,9 @@ export class ProxyForwarder { providerSessionRefRetainOnSuccess: attempt.providerSessionRefRetainOnSuccess, }); leaseTransferred = true; - const prefix = - attempt.chunks.length === 1 - ? attempt.chunks[0] - : (() => { - const size = attempt.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); - const output = new Uint8Array(size); - let offset = 0; - for (const chunk of attempt.chunks) { - output.set(chunk, offset); - offset += chunk.byteLength; - } - return output; - })(); resolveResult?.({ response: new Response( - ProxyForwarder.buildBufferedFirstChunkStream(prefix, attempt.reader), + ProxyForwarder.buildBufferedPrefixStream(attempt.chunks, attempt.reader), { status: attempt.response.status, statusText: attempt.response.statusText, @@ -7713,17 +7809,17 @@ export class ProxyForwarder { } } - private static buildBufferedFirstChunkStream( - firstChunk: Uint8Array, + private static buildBufferedPrefixStream( + prefixChunks: Uint8Array[], reader: ReadableStreamDefaultReader ): ReadableStream { - let firstChunkSent = false; + let prefixIndex = 0; return new ReadableStream({ async pull(controller) { - if (!firstChunkSent) { - firstChunkSent = true; - controller.enqueue(firstChunk); + if (prefixIndex < prefixChunks.length) { + controller.enqueue(prefixChunks[prefixIndex]); + prefixIndex++; return; } diff --git a/src/app/v1/_lib/proxy/guard-pipeline.ts b/src/app/v1/_lib/proxy/guard-pipeline.ts index d372f9df1..289037ba4 100644 --- a/src/app/v1/_lib/proxy/guard-pipeline.ts +++ b/src/app/v1/_lib/proxy/guard-pipeline.ts @@ -6,6 +6,7 @@ import { ProxyModelGuard } from "./model-guard"; import { ProxyProviderRequestFilter } from "./provider-request-filter"; import { ProxyProviderResolver } from "./provider-selector"; import { ProxyRateLimitGuard } from "./rate-limit-guard"; +import { ProxyReplayGuard } from "./replay/replay-guard"; import { ProxyRequestFilter } from "./request-filter"; import { ProxySensitiveWordGuard } from "./sensitive-word-guard"; import type { ProxySession } from "./session"; @@ -36,6 +37,7 @@ export type GuardStepKey = | "warmup" | "requestFilter" | "sensitive" + | "replayAttach" | "rateLimit" | "provider" | "providerRequestFilter" @@ -113,6 +115,14 @@ const Steps: Record = { return ProxySensitiveWordGuard.ensure(session); }, }, + replayAttach: { + // F2:相同请求体命中活跃/已完成重放时直接短路返回缓存响应; + // 位于 rateLimit 之前(重放命中完全免费),auth/sensitive 等仍在前面 + name: "replayAttach", + async execute(session) { + return ProxyReplayGuard.ensure(session); + }, + }, rateLimit: { name: "rateLimit", async execute(session) { @@ -209,6 +219,7 @@ export const CHAT_PIPELINE: GuardConfig = { "session", "warmup", "requestFilter", + "replayAttach", "rateLimit", "provider", "providerRequestFilter", diff --git a/src/app/v1/_lib/proxy/openai-image-compat.ts b/src/app/v1/_lib/proxy/openai-image-compat.ts index 3bad94bb3..ac58f33b4 100644 --- a/src/app/v1/_lib/proxy/openai-image-compat.ts +++ b/src/app/v1/_lib/proxy/openai-image-compat.ts @@ -1024,7 +1024,7 @@ export async function validateOpenAIImageRequest(options: { } if (endpoint === "variations") { - if (!options.imageRequestMetadata || options.imageRequestMetadata.bodyKind !== "multipart") { + if (options.imageRequestMetadata?.bodyKind !== "multipart") { return fail("Invalid request: /images/variations requires multipart/form-data."); } return validateVariationsMultipartRequest(options.imageRequestMetadata); diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 6d8b5aa72..2df37b345 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -1,9 +1,12 @@ import { matchesAllowedModelRules } from "@/lib/allowed-model-rules"; import { getCircuitState, isCircuitOpen } from "@/lib/circuit-breaker"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit"; +import { buildScopeTag } from "@/lib/request-identity"; import { SessionManager } from "@/lib/session-manager"; +import { getProxyRuntimeSettings } from "@/lib/system-settings/proxy-runtime"; import { parseProviderGroups, resolveBillingProviderGroups, @@ -16,6 +19,9 @@ import { findAllProviders, findProviderById } from "@/repository/provider"; import { getGroupCostMultiplier } from "@/repository/provider-groups"; import type { ProviderChainItem } from "@/types/message"; import type { Provider } from "@/types/provider"; +import { getAffinityStore } from "./affinity/affinity-store"; +import { isAffinityRoutingEnabledWith } from "./affinity/config"; +import { computeFingerprintChain, fingerprintsDeepestFirst } from "./affinity/fingerprint"; import { isClientAllowedDetailed } from "./client-detector"; import type { ClientFormat } from "./format-mapper"; import { getVerboseProviderErrorCached } from "./provider-selector-settings-cache"; @@ -185,41 +191,70 @@ export class ProxyProviderResolver { // 动态尝试所有可用供应商(避免无限循环通过 excludedProviders 和 null 返回) const excludedProviders: number[] = []; - // === 会话复用 === - const reusedProvider = await ProxyProviderResolver.findReusable(session); - if (reusedProvider) { - session.setProvider(reusedProvider); - - // 记录会话复用上下文 - session.addProviderToChain(reusedProvider, { - reason: "session_reuse", - selectionMethod: "session_reuse", - circuitState: getCircuitState(reusedProvider.id), - decisionContext: { - totalProviders: 0, // 复用不需要筛选 - enabledProviders: 0, - targetType: reusedProvider.providerType as NonNullable< - ProviderChainItem["decisionContext"] - >["targetType"], - requestedModel: session.getOriginalModel() || "", - groupFilterApplied: false, - beforeHealthCheck: 0, - afterHealthCheck: 0, - priorityLevels: [reusedProvider.priority || 0], - selectedPriority: reusedProvider.priority || 0, - candidatesAtPriority: [ - { - id: reusedProvider.id, - name: reusedProvider.name, - weight: reusedProvider.weight, - costMultiplier: reusedProvider.costMultiplier, - }, - ], - sessionId: session.sessionId || undefined, - }, + // === F3a 前置:读一次运行时设置并计算指纹状态 === + // 「忽略客户端 Session ID」开启且请求可指纹化时,粘性交给最长前缀亲和, + // 跳过 session-ID 绑定的读取;不可指纹化(如非 chat 体)仍走既有会话复用。 + // 任何异常整体退回旧顺序(会话复用正常执行),绝不影响主选路。 + let affinityRoutingEnabled = false; + let skipSessionBinding = false; + try { + const runtimeSettings = await getProxyRuntimeSettings(); + affinityRoutingEnabled = isAffinityRoutingEnabledWith(runtimeSettings); + const fingerprintable = ProxyProviderResolver.ensureAffinityState( + session, + affinityRoutingEnabled + ); + skipSessionBinding = runtimeSettings.affinityIgnoreClientSessionId && fingerprintable; + } catch (error) { + affinityRoutingEnabled = false; + skipSessionBinding = false; + logger.warn("ProviderSelector: Affinity settings unavailable, using legacy order", { + error: error instanceof Error ? error.message : String(error), }); } + // === 会话复用(「忽略客户端 Session ID」语义下仅跳过读取;写路径不变)=== + if (!skipSessionBinding) { + const reusedProvider = await ProxyProviderResolver.findReusable(session); + if (reusedProvider) { + session.setProvider(reusedProvider); + + // 记录会话复用上下文 + session.addProviderToChain(reusedProvider, { + reason: "session_reuse", + selectionMethod: "session_reuse", + circuitState: getCircuitState(reusedProvider.id), + decisionContext: { + totalProviders: 0, // 复用不需要筛选 + enabledProviders: 0, + targetType: reusedProvider.providerType as NonNullable< + ProviderChainItem["decisionContext"] + >["targetType"], + requestedModel: session.getOriginalModel() || "", + groupFilterApplied: false, + beforeHealthCheck: 0, + afterHealthCheck: 0, + priorityLevels: [reusedProvider.priority || 0], + selectedPriority: reusedProvider.priority || 0, + candidatesAtPriority: [ + { + id: reusedProvider.id, + name: reusedProvider.name, + weight: reusedProvider.weight, + costMultiplier: reusedProvider.costMultiplier, + }, + ], + sessionId: session.sessionId || undefined, + }, + }); + } + } + + // === 前缀亲和提名(优先级:显式 session 绑定 > 亲和 > 加权随机)=== + if (affinityRoutingEnabled && !session.provider) { + await ProxyProviderResolver.tryPrefixAffinityNomination(session); + } + // === 首次选择或重试 === if (!session.provider) { const { provider, context } = await ProxyProviderResolver.pickRandomProvider( @@ -485,6 +520,193 @@ export class ProxyProviderResolver { return provider; } + /** + * F3a 指纹状态:计算链式指纹与 scopeTag 挂到 session.affinity(幂等,已有则跳过)。 + * + * 仅 default endpoint policy 建状态——raw 端点(如 count_tokens)绝不建, + * 各终态写回随之全部 no-op;亲和路由与缓存效果指标(F3b)任一开启即计算, + * 仅指标模式下也要指纹供终态落值。返回 session.affinity 是否可用(可指纹化)。 + */ + private static ensureAffinityState( + session: ProxySession, + affinityRoutingEnabled: boolean + ): boolean { + if (session.affinity) return true; + if (session.getEndpointPolicy().kind !== "default") return false; + const keyId = session.authState?.key?.id; + if (!keyId) return false; + + const env = getEnvConfig(); + if (!affinityRoutingEnabled && !env.ENABLE_CACHE_EFFECTIVENESS) return false; + + const chain = computeFingerprintChain( + session.request.message, + session.originalFormat, + env.PREFIX_AFFINITY_WINDOW + ); + if (!chain) return false; + + session.affinity = { + scopeTag: buildScopeTag(keyId, session.originalFormat, session.getOriginalModel()), + chain, + nominatedProviderId: null, + matchedFp: null, + matchedTier: null, + }; + return true; + } + + /** + * F3a 最长前缀亲和提名(软提名)。 + * + * 基于 ensureAffinityState 挂好的指纹链,在 Redis 中做最深->最浅的最长前缀 + * 查找;命中后候选供应商仍须通过与会话复用完全相同的硬校验(见 + * validateAffinityCandidate),任一不过即静默回落加权随机——亲和永远只是 + * 提名,不绕过任何硬性约束。 + */ + private static async tryPrefixAffinityNomination(session: ProxySession): Promise { + try { + const affinity = session.affinity; + if (!affinity) return; + + const hint = await getAffinityStore().lookup( + affinity.scopeTag, + fingerprintsDeepestFirst(affinity.chain), + getEnvConfig().PREFIX_AFFINITY_TTL_SECONDS + ); + if (!hint) return; + + affinity.matchedFp = hint.matchedFp; + affinity.matchedTier = hint.tier; + + const provider = await ProxyProviderResolver.validateAffinityCandidate( + session, + hint.providerId + ); + if (!provider) { + // 候选不过硬校验:软回落,不写墓碑(可能只是临时熔断/调度窗口外) + logger.debug("ProviderSelector: Affinity candidate rejected by hard validation", { + providerId: hint.providerId, + tier: hint.tier, + }); + return; + } + + affinity.nominatedProviderId = provider.id; + session.setProvider(provider); + session.addProviderToChain(provider, { + reason: "affinity_hit", + selectionMethod: "prefix_affinity", + circuitState: getCircuitState(provider.id), + decisionContext: { + totalProviders: 0, + enabledProviders: 0, + targetType: provider.providerType as NonNullable< + ProviderChainItem["decisionContext"] + >["targetType"], + requestedModel: session.getOriginalModel() || "", + groupFilterApplied: false, + beforeHealthCheck: 0, + afterHealthCheck: 0, + priorityLevels: [provider.priority || 0], + selectedPriority: provider.priority || 0, + candidatesAtPriority: [ + { + id: provider.id, + name: provider.name, + weight: provider.weight, + costMultiplier: provider.costMultiplier, + }, + ], + sessionId: session.sessionId || undefined, + }, + }); + logger.info("ProviderSelector: Prefix affinity nomination accepted", { + providerId: provider.id, + providerName: provider.name, + tier: hint.tier, + matchedIndex: hint.matchedIndex, + }); + } catch (error) { + // 亲和路径任何异常都不影响主选路 + logger.warn("ProviderSelector: Prefix affinity nomination failed, falling back", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * 亲和候选硬校验:与 findReusable 同一套检查——enabled/粘性 opt-out/调度窗口/ + * 熔断/格式/模型/客户端限制/分组/金额限额(不含 session 绑定的清理副作用)。 + * 任一不过返回 null。 + */ + private static async validateAffinityCandidate( + session: ProxySession, + providerId: number + ): Promise { + const provider = await findProviderById(providerId); + if (!provider?.isEnabled) return null; + // 尊重供应商的会话粘性 opt-out:亲和与会话复用同属粘性机制 + if (provider.disableSessionReuse) return null; + + const systemTimezone = await resolveSystemTimezone(); + if (!isProviderActiveNow(provider.activeTimeStart, provider.activeTimeEnd, systemTimezone)) { + return null; + } + if ( + provider.providerVendorId && + provider.providerVendorId > 0 && + (await isVendorTypeCircuitOpen(provider.providerVendorId, provider.providerType)) + ) { + return null; + } + if (await isCircuitOpen(provider.id)) return null; + if ( + session.originalFormat && + !checkFormatProviderTypeCompatibility(session.originalFormat, provider.providerType) + ) { + return null; + } + const requestedModel = session.getOriginalModel(); + if (requestedModel && !providerSupportsModel(provider, requestedModel)) return null; + + const clientResult = isClientAllowedDetailed( + session, + provider.allowedClients ?? [], + provider.blockedClients ?? [] + ); + if (!clientResult.allowed) return null; + + const effectiveGroup = getEffectiveProviderGroup(session); + if (effectiveGroup && !checkProviderGroupMatch(provider.groupTag, effectiveGroup)) { + return null; + } + + // 亲和提名同样不得绕过金额限额(5h/日/周/月 + 总额),与 findReusable 一致 + const costCheck = await RateLimitService.checkCostLimitsWithLease(provider.id, "provider", { + limit_5h_usd: provider.limit5hUsd, + limit_5h_reset_mode: provider.limit5hResetMode, + limit_daily_usd: provider.limitDailyUsd, + daily_reset_mode: provider.dailyResetMode, + daily_reset_time: provider.dailyResetTime, + limit_weekly_usd: provider.limitWeeklyUsd, + limit_monthly_usd: provider.limitMonthlyUsd, + }); + if (!costCheck.allowed) return null; + + const totalCheck = await RateLimitService.checkTotalCostLimit( + provider.id, + "provider", + provider.limitTotalUsd, + { + resetAt: provider.totalCostResetAt, + } + ); + if (!totalCheck.allowed) return null; + + return provider; + } + /** * Select a bounded Discovery batch using the exact same filters, priority * and weighted selection as the normal selector. The method is intentionally diff --git a/src/app/v1/_lib/proxy/replay/replay-guard.ts b/src/app/v1/_lib/proxy/replay/replay-guard.ts new file mode 100644 index 000000000..c91fe78aa --- /dev/null +++ b/src/app/v1/_lib/proxy/replay/replay-guard.ts @@ -0,0 +1,268 @@ +import { randomUUID } from "node:crypto"; +import { db } from "@/drizzle/db"; +import { messageRequest } from "@/drizzle/schema"; +import { getEnvConfig } from "@/lib/config/env.schema"; +import { logger } from "@/lib/logger"; +import type { ProxySession } from "../session"; +import { deriveReplayIdentity, REPLAY_BYPASS_HEADER, type ReplayIdentity } from "./replay-identity"; +import { getReplayStore, type ReplayMeta, type ReplayStore } from "./replay-store"; + +/** + * F2 replayAttach guard 步骤:插在 requestFilter 之后、rateLimit 之前。 + * + * 完全免费语义:命中重放的请求不占限流配额、不占供应商并发、不计费—— + * 但 auth/sensitive/client/model 等前置校验一律先行,绝不绕过鉴权。 + * + * 角色分派(CCHP coordinator 状态机的移植): + * - meta completed(verifier 复核通过) -> 全量重放(Redis 热层,miss 落 PG 持久层) + * - meta owning + 心跳新鲜 + 去重开启 -> attach-live:吐已缓存前缀 + 轮询跟实时尾部 + * - miss / aborted / 心跳过期 -> 尝试 SET NX 抢 owner:成功则本请求成为 owner + * (挂 session.replayState,spool 由 handleStream 建), + * 失败(竞态輸掉且不可 attach)则放弃 replay 照常执行 + * - verifier 不符(哈希碰撞) -> 视为无 replay,照常执行 + * - x-cch-no-replay: 1 -> 跳过 attach(有意重复采样),仍可成为 owner; + * 但条目已 completed 时不 claim(不覆写,保留给其他客户端) + * + * 一切异常 fail-open:返回 null 让请求照常执行。 + */ + +/** attach 跟尾轮询参数(对齐 CCHP tail:起步小步长,指数上限) */ +const ATTACH_POLL_INITIAL_MS = 25; +const ATTACH_POLL_MAX_MS = 200; +/** owner 心跳超过该时长且无新块 -> 判定 owner 失联,跟尾优雅收尾 */ +const ATTACH_STALL_MS = 30_000; +/** attach 等待 meta / 尾部数据的总预算(防御性上限,正常流远短于此) */ +const ATTACH_MAX_WAIT_MS = 10 * 60 * 1000; + +export class ProxyReplayGuard { + static async ensure(session: ProxySession): Promise { + try { + const identity = deriveReplayIdentity(session); + if (!identity) return null; + + const env = getEnvConfig(); + const store = getReplayStore(); + const bypassAttach = session.headers.get(REPLAY_BYPASS_HEADER) === "1"; + + if (bypassAttach) { + // 有意重复采样:跳过 attach,但已完成条目不可被覆写—— + // 不 claim、照常执行,条目保留给其他客户端 + const meta = await store.getMeta(identity.replayId); + if (meta?.status === "completed" && meta.verifier === identity.verifier) { + return null; + } + } else { + const served = await ProxyReplayGuard.tryServe(session, identity, store, env); + if (served) return served; + } + + // 未命中可服务条目:尝试成为 owner(跨副本 single-flight) + const ownerToken = randomUUID(); + const claimed = await store.tryClaimOwner(identity.replayId, ownerToken); + if (claimed) { + // 清掉上一 owner 异常退出遗留的旧 LIST 残块,防止与新流拼接 + await store.deleteChunks(identity.replayId); + session.replayState = { identity, ownerToken, role: "owner" }; + } + // claim 失败:竞态输掉且(去重关闭/绕过/不可 attach)——照常执行,无 replay 角色 + return null; + } catch (error) { + logger.warn("[ReplayGuard] ensure failed, proceeding without replay", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + private static async tryServe( + session: ProxySession, + identity: ReplayIdentity, + store: ReplayStore, + env: ReturnType + ): Promise { + const meta = await store.getMeta(identity.replayId); + + if (meta) { + if (meta.verifier !== identity.verifier) { + // 哈希碰撞:绝不错发他人响应 + logger.warn("[ReplayGuard] verifier mismatch (hash collision), skipping replay", { + replayId: identity.replayId.slice(0, 12), + }); + return null; + } + if (meta.status === "completed") { + const chunks = await store.readChunks(identity.replayId, 0); + if (chunks && chunks.length > 0) { + await ProxyReplayGuard.writeAuditRow( + session, + identity, + meta.statusCode, + "redis_completed" + ); + return ProxyReplayGuard.buildStaticResponse(meta, chunks.join("")); + } + // 热层块已过期:落 PG + } else if (meta.status === "owning") { + const heartbeatFresh = Date.now() - meta.heartbeatAt < ATTACH_STALL_MS; + if (env.REPLAY_LIVE_DEDUP_ENABLED && heartbeatFresh) { + await ProxyReplayGuard.writeAuditRow(session, identity, meta.statusCode, "attached_live"); + return ProxyReplayGuard.buildLiveAttachResponse(identity, meta, store); + } + // 心跳过期(owner 崩溃/停机):不 attach 半截死流;owner 租约到期后可被重新 claim + return null; + } else { + // aborted:终态失败条目不可重放 + return null; + } + } + + // Redis miss:查 PG 完成持久层(跨小时/跨副本/跨滚动发布) + const persisted = await store.findCompleted(identity.replayId); + if (persisted && persisted.verifier === identity.verifier && persisted.payload.length > 0) { + await ProxyReplayGuard.writeAuditRow(session, identity, persisted.statusCode, "pg_completed"); + return ProxyReplayGuard.buildStaticResponse( + { + statusCode: persisted.statusCode, + headers: persisted.headersJson ?? { "content-type": "text/event-stream" }, + }, + persisted.payload + ); + } + return null; + } + + /** 已完成条目的全量重放。 */ + private static buildStaticResponse( + meta: Pick, + payload: string + ): Response { + const headers = ProxyReplayGuard.buildServeHeaders(meta.headers, "completed"); + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }); + return new Response(body, { status: meta.statusCode || 200, headers }); + } + + /** + * attach-live:先吐已缓存前缀,然后轮询 LIST 跟实时尾部直到 completed/aborted/stall。 + * 订阅者断开只影响自身(cancel 时停止轮询),对 owner 零影响。 + */ + private static buildLiveAttachResponse( + identity: ReplayIdentity, + initialMeta: ReplayMeta, + store: ReplayStore + ): Response { + const headers = ProxyReplayGuard.buildServeHeaders(initialMeta.headers, "live"); + const encoder = new TextEncoder(); + let offset = 0; + let cancelled = false; + let pollDelay = ATTACH_POLL_INITIAL_MS; + const startedAt = Date.now(); + let lastProgressAt = Date.now(); + + const body = new ReadableStream({ + async pull(controller) { + while (!cancelled) { + const chunks = await store.readChunks(identity.replayId, offset); + if (chunks === null) { + // Redis 失联:无法继续跟尾,按传输错误终止 + controller.error(new Error("replay attach lost redis connection")); + return; + } + if (chunks.length > 0) { + offset += chunks.length; + lastProgressAt = Date.now(); + pollDelay = ATTACH_POLL_INITIAL_MS; + controller.enqueue(encoder.encode(chunks.join(""))); + return; + } + + const meta = await store.getMeta(identity.replayId); + if (!meta || meta.status === "aborted") { + controller.error(new Error("replay source aborted")); + return; + } + if (meta.status === "completed") { + // 终态后补读一次尾部,防 completed 与最后一批块之间的竞态 + const tail = await store.readChunks(identity.replayId, offset); + if (tail && tail.length > 0) { + offset += tail.length; + controller.enqueue(encoder.encode(tail.join(""))); + return; + } + controller.close(); + return; + } + // owning:stall 检测(owner 心跳 + 本地进度双重判定) + const now = Date.now(); + if (now - lastProgressAt > ATTACH_STALL_MS && now - meta.heartbeatAt > ATTACH_STALL_MS) { + controller.error(new Error("replay owner stalled")); + return; + } + if (now - startedAt > ATTACH_MAX_WAIT_MS) { + controller.error(new Error("replay attach exceeded max wait")); + return; + } + await sleep(pollDelay); + pollDelay = Math.min(pollDelay * 2, ATTACH_POLL_MAX_MS); + } + }, + cancel() { + cancelled = true; + }, + }); + return new Response(body, { status: initialMeta.statusCode || 200, headers }); + } + + private static buildServeHeaders( + stored: Record, + mode: "completed" | "live" + ): Headers { + const headers = new Headers(); + headers.set("content-type", stored["content-type"] ?? "text/event-stream"); + headers.set("cache-control", "no-cache"); + headers.set("x-cch-replay", mode); + return headers; + } + + /** 审计行:costUsd 0、blockedBy replay_serve;不写 usageLedger、不绑 session/亲和。 */ + private static async writeAuditRow( + session: ProxySession, + identity: ReplayIdentity, + statusCode: number, + source: string + ): Promise { + try { + if (!session.authState?.user || !session.authState.apiKey) return; + await db.insert(messageRequest).values({ + providerId: 0, + userId: session.authState.user.id, + key: session.authState.apiKey, + model: session.request.model ?? undefined, + sessionId: session.sessionId ?? undefined, + statusCode: statusCode || 200, + costUsd: "0", + blockedBy: "replay_serve", + blockedReason: JSON.stringify({ + source, + replayId: identity.replayId.slice(0, 12), + }), + endpoint: identity.endpoint, + messagesCount: session.getMessagesLength(), + userAgent: session.userAgent ?? undefined, + }); + } catch (error) { + logger.warn("[ReplayGuard] audit row insert failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/app/v1/_lib/proxy/replay/replay-identity.ts b/src/app/v1/_lib/proxy/replay/replay-identity.ts new file mode 100644 index 000000000..e07a7cb67 --- /dev/null +++ b/src/app/v1/_lib/proxy/replay/replay-identity.ts @@ -0,0 +1,73 @@ +import { getEnvConfig } from "@/lib/config/env.schema"; +import { buildScopeTag, sha256Hex, stableStringify } from "@/lib/request-identity"; +import type { ClientFormat } from "../format-mapper"; +import type { ProxySession } from "../session"; + +/** + * F2 Replay 身份推导(CCHP replay/identity.go 语义的简化移植)。 + * + * replayId:全量作用域哈希(租户 scopeTag + endpoint + model + stream + 幂等键 + body 哈希), + * 确定性——相同 body 与身份重推导得到相同 ID,跨副本一致;scopeTag 含 keyId,跨租户不可能命中。 + * verifier:仅内容维度(body 哈希 + endpoint + model + stream)的不同盐哈希, + * attach 时严格比对,防 replayId 哈希碰撞(CCHP 主 ID 含 principal / verifier 仅内容的结构对齐)。 + * + * body 哈希基于过滤后的逻辑请求体(guard 位于 requestFilter 之后的 + * session.request.message,键序稳定序列化),不取过滤前的原始 buffer: + * 过滤规则变更后自然产生新身份,绝不命中旧规则时代的条目。 + * + * 不合格条件(返回 null,请求按现状处理): + * - 功能开关关闭;非 default endpoint policy(raw passthrough 等) + * - 非 POST;非流式请求(stream !== true) + * - 缺认证主体(key/user) + * (probe/warmup/count_tokens 由 guard 管线顺序与 preset 天然排除,不达本步。) + */ + +export interface ReplayIdentity { + replayId: string; + verifier: string; + scopeTag: string; + keyId: number; + userId: number; + format: ClientFormat; + model: string | null; + endpoint: string; +} + +export const REPLAY_BYPASS_HEADER = "x-cch-no-replay"; + +export function deriveReplayIdentity(session: ProxySession): ReplayIdentity | null { + try { + const env = getEnvConfig(); + if (!env.ENABLE_REQUEST_REPLAY) return null; + if (session.getEndpointPolicy().kind !== "default") return null; + if (session.method !== "POST") return null; + + const message = session.request.message; + if ((message as Record).stream !== true) return null; + + const keyId = session.authState?.key?.id; + const userId = session.authState?.user?.id; + if (!keyId || !userId) return null; + + const format = session.originalFormat; + const model = session.getOriginalModel(); + const endpoint = session.getEndpoint() ?? "/"; + const scopeTag = buildScopeTag(keyId, format, model); + const bodyHash = sha256Hex(stableStringify(message)); + const idempotencyKey = + session.headers.get("idempotency-key")?.trim() || + session.headers.get("x-idempotency-key")?.trim() || + ""; + + const replayId = sha256Hex( + `cch_replay_v1|${scopeTag}|${endpoint}|${model ?? ""}|stream|ik=${idempotencyKey}|${bodyHash}` + ).slice(0, 32); + const verifier = sha256Hex( + `cch_replay_vf1|${bodyHash}|${endpoint}|${model ?? ""}|stream|ik=${idempotencyKey}` + ).slice(0, 32); + + return { replayId, verifier, scopeTag, keyId, userId, format, model, endpoint }; + } catch { + return null; + } +} diff --git a/src/app/v1/_lib/proxy/replay/replay-spool.ts b/src/app/v1/_lib/proxy/replay/replay-spool.ts new file mode 100644 index 000000000..baa20cbe9 --- /dev/null +++ b/src/app/v1/_lib/proxy/replay/replay-spool.ts @@ -0,0 +1,372 @@ +import { getEnvConfig } from "@/lib/config/env.schema"; +import { logger } from "@/lib/logger"; +import type { ProxySession } from "../session"; +import type { ReplayIdentity } from "./replay-identity"; +import { getReplayStore, type ReplayMeta } from "./replay-store"; + +/** + * F2 owner 侧 spool:把客户端可见字节(pump 处理后流)以 write-behind 方式 + * 喂入 Redis 热层,供并发/断线的相同请求 attach 跟尾。 + * + * - observe() 在流热路径同步调用:只做累积与调度,绝不阻塞; + * 实际写 Redis 走串行 promise 链(保序)。 + * - 冲刷条件:累积 >= 64KB 或 100ms 定时;每次冲刷同时续 meta 心跳与 owner 租约。 + * - 超出 REPLAY_MAX_PAYLOAD_BYTES:自失效(删除已写块,后续 attach 视为 miss), + * fail-open 不影响主流。 + * - completeAfterBilling():计费持久化成功后才调用(终态屏障不变量), + * 冲刷尾部 -> 写 PG 持久层 -> meta 置 completed(PG 不 durable 绝不置 completed)。 + * - abort():meta 置 aborted + 删除块,绝不被已完成重放命中。 + * - 续租 compare 失败(所有权被接管)-> halt:停止 spool 但不删条目。 + */ + +const FLUSH_INTERVAL_MS = 100; +const FLUSH_BYTES_THRESHOLD = 64 * 1024; + +let activeSpoolCount = 0; + +export function getActiveReplaySpoolCount(): number { + return activeSpoolCount; +} + +export class ReplaySpool { + private readonly store = getReplayStore(); + private readonly decoder = new TextDecoder("utf-8"); + private readonly parts: string[] = []; + private pending: string[] = []; + private pendingBytes = 0; + private totalBytes = 0; + private chunkCount = 0; + private disabled = false; + private terminal = false; + private flushTimer: ReturnType | null = null; + private writeChain: Promise = Promise.resolve(); + private metaWritten = false; + + constructor( + private readonly identity: ReplayIdentity, + private readonly ownerToken: string, + private readonly statusCode: number, + private readonly contentType: string + ) { + activeSpoolCount++; + } + + /** 已达终态(complete/abort)或已失效(disable/halt):调用方兜底判断用。 */ + get isTerminal(): boolean { + return this.terminal || this.disabled; + } + + /** 流热路径同步观察:累积并调度冲刷。 */ + observe(chunk: Uint8Array): void { + if (this.disabled || this.terminal || chunk.byteLength === 0) return; + try { + this.totalBytes += chunk.byteLength; + const env = getEnvConfig(); + if (this.totalBytes > env.REPLAY_MAX_PAYLOAD_BYTES) { + this.disable("payload_too_large"); + return; + } + const text = this.decoder.decode(chunk, { stream: true }); + if (text.length === 0) return; + this.pending.push(text); + this.parts.push(text); + this.pendingBytes += chunk.byteLength; + + if (this.pendingBytes >= FLUSH_BYTES_THRESHOLD) { + this.scheduleFlush(0); + } else { + this.scheduleFlush(FLUSH_INTERVAL_MS); + } + } catch (error) { + logger.debug("[ReplaySpool] observe failed, disabling spool", { + error: error instanceof Error ? error.message : String(error), + }); + this.disable("observe_error"); + } + } + + private scheduleFlush(delayMs: number): void { + if (this.flushTimer) { + if (delayMs > 0) return; + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (delayMs <= 0) { + this.enqueueFlush(); + return; + } + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.enqueueFlush(); + }, delayMs); + // 不阻止进程退出 + this.flushTimer.unref?.(); + } + + private enqueueFlush(): void { + const batch = this.pending; + if (batch.length === 0) return; + this.pending = []; + this.pendingBytes = 0; + // 续接体自带 try/catch:链永不 rejected;每个 await 之后复查 disabled, + // 防止与 disable/halt 竞态时在清理之后又写回 owning meta + this.writeChain = this.writeChain.then(async () => { + try { + if (this.disabled) return; + const appended = await this.store.appendChunks(this.identity.replayId, batch); + if (this.disabled) return; + if (appended === null) { + // Redis 不可用:本次 replay 放弃(已写块靠 TTL 清理) + this.disable("redis_unavailable"); + return; + } + this.chunkCount = appended; + await this.writeMeta("owning"); + if (this.disabled) return; + const leaseHeld = await this.store.renewOwnerLease(this.identity.replayId, this.ownerToken); + if (!leaseHeld) { + // 所有权已失(租约被他人接管):停止 spool,但绝不删条目 + this.halt("owner_lease_lost"); + } + } catch (error) { + logger.debug("[ReplaySpool] flush failed, disabling spool", { + error: error instanceof Error ? error.message : String(error), + }); + this.disable("flush_error"); + } + }); + } + + private async writeMeta( + status: ReplayMeta["status"], + extra?: Partial + ): Promise { + const meta: ReplayMeta = { + status, + verifier: this.identity.verifier, + scopeTag: this.identity.scopeTag, + statusCode: this.statusCode, + headers: { "content-type": this.contentType }, + format: this.identity.format, + model: this.identity.model, + chunkCount: this.chunkCount, + byteSize: this.totalBytes, + heartbeatAt: Date.now(), + ...extra, + }; + await this.store.setMeta(this.identity.replayId, meta); + this.metaWritten = true; + } + + /** 立即建立 owning meta(handleStream 创建 spool 时调用,供 attach 读者尽早看到状态)。 */ + bootstrap(): void { + this.writeChain = this.writeChain.then(async () => { + try { + if (this.disabled || this.metaWritten) return; + await this.writeMeta("owning"); + } catch (error) { + logger.debug("[ReplaySpool] bootstrap failed, disabling spool", { + error: error instanceof Error ? error.message : String(error), + }); + this.disable("flush_error"); + } + }); + } + + /** + * 计费持久化成功后的完成屏障:尾部冲刷 -> PG 持久层 -> meta 置 completed。 + * 顺序不变量:completed 只能在 payload 与计费均已 durable 之后出现。 + */ + async completeAfterBilling(messageRequestId: number | null): Promise { + if (this.disabled || this.terminal) return; + this.terminal = true; + this.clearTimer(); + const tail = this.decoder.decode(); + if (tail.length > 0) { + this.pending.push(tail); + this.parts.push(tail); + } + const batch = this.pending; + this.pending = []; + this.pendingBytes = 0; + + this.writeChain = this.writeChain.then(async () => { + let pgPersisted = false; + try { + if (this.disabled) return; + if (batch.length > 0) { + const appended = await this.store.appendChunks(this.identity.replayId, batch); + if (appended === null) { + // 尾批丢失则热层条目不完整,绝不能置 completed + throw new Error("final replay flush failed"); + } + this.chunkCount = appended; + } + // 先写 PG(持久 payload),再翻 Redis meta 为 completed(热层可服务) + await this.store.persistCompleted({ + replayId: this.identity.replayId, + verifier: this.identity.verifier, + scopeTag: this.identity.scopeTag, + keyId: this.identity.keyId, + userId: this.identity.userId, + format: this.identity.format, + model: this.identity.model, + statusCode: this.statusCode, + headers: { "content-type": this.contentType }, + payload: this.parts.join(""), + byteSize: this.totalBytes, + sourceMessageRequestId: messageRequestId, + }); + pgPersisted = true; + await this.writeMeta("completed", { messageRequestId }); + logger.info("[ReplaySpool] replay entry completed", { + replayId: this.identity.replayId.slice(0, 12), + chunkCount: this.chunkCount, + byteSize: this.totalBytes, + }); + } catch (error) { + // pgPersisted=true:payload 已 durable,仅 completed 翻转失败——热层封死为 + // aborted 仍正确(meta 过期后可由 PG 持久层继续服务);false 则未持久化,整体作废 + logger.warn("[ReplaySpool] complete failed, aborting entry", { + error: error instanceof Error ? error.message : String(error), + pgPersisted, + }); + await this.writeMeta("aborted", { abortReason: "complete_failed" }).catch(() => undefined); + await this.store.deleteChunks(this.identity.replayId).catch(() => undefined); + } finally { + await this.store.releaseOwner(this.identity.replayId, this.ownerToken); + this.release(); + } + }); + await this.writeChain; + } + + /** 终态失败:meta 置 aborted + 删块;已 aborted 的条目绝不被重放命中。 */ + async abort(reason: string): Promise { + if (this.terminal) return; + this.terminal = true; + this.clearTimer(); + this.pending = []; + this.pendingBytes = 0; + this.writeChain = this.writeChain.then(async () => { + try { + // 已失效(disable 已清理 / halt 已让渡所有权):不得再写 meta 覆盖新 owner + if (this.disabled) return; + // aborted meta 保留(短 TTL)供 attach 读者感知终态;块立即删除 + await this.writeMeta("aborted", { abortReason: reason }); + await this.store.deleteChunks(this.identity.replayId); + } catch { + // 热层清理失败靠 TTL 兜底 + } finally { + await this.store.releaseOwner(this.identity.replayId, this.ownerToken); + this.release(); + } + }); + await this.writeChain; + } + + /** 失效并删除条目(payload 超限 / Redis 不可用 / 冲刷异常等本 spool 自身的失败)。 */ + private disable(reason: string): void { + this.teardown(reason, true); + } + + /** 所有权已失(续租 compare 失败):停止 spool 但绝不删条目——新 owner 可能已在写同一 LIST。 */ + private halt(reason: string): void { + this.teardown(reason, false); + } + + private teardown(reason: string, deleteEntry: boolean): void { + if (this.disabled) return; + this.disabled = true; + this.clearTimer(); + this.pending = []; + this.parts.length = 0; + this.pendingBytes = 0; + // 清理顺着 writeChain 串行:与 in-flight append 竞态时绝不出现「删除后又写回」 + this.writeChain = this.writeChain.then(async () => { + if (deleteEntry) { + await this.store.deleteEntry(this.identity.replayId).catch(() => undefined); + } + // compare-delete 只删自己的 token:所有权已失时为安全 no-op + await this.store.releaseOwner(this.identity.replayId, this.ownerToken).catch(() => undefined); + }); + logger.debug("[ReplaySpool] spool disabled", { + replayId: this.identity.replayId.slice(0, 12), + reason, + }); + this.release(); + } + + private released = false; + + private release(): void { + if (this.released) return; + this.released = true; + activeSpoolCount = Math.max(0, activeSpoolCount - 1); + } + + private clearTimer(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + } +} + +/** + * 不会建 spool 的路径统一释放 owner 租约并清角色—— + * 否则相同请求的重试会被残留租约挡满 45s。 + */ +export function releaseReplayOwnership(session: ProxySession): void { + const replayState = session.replayState; + if (replayState?.role !== "owner") return; + void getReplayStore() + .releaseOwner(replayState.identity.replayId, replayState.ownerToken) + .catch(() => undefined); + session.replayState = null; +} + +/** + * handleStream 建 pump 时创建 owner spool。 + * 前置:guard 阶段已成功 claim owner(session.replayState.role === "owner")。 + * 并发 spool 超上限 / 非 2xx / 非 SSE / 开关关闭 / 异常时返回 null(本请求不做 + * replay),并立即释放 owner 租约。 + */ +export function createReplaySpoolIfOwner( + session: ProxySession, + response: Response +): ReplaySpool | null { + const replayState = session.replayState; + if (replayState?.role !== "owner") return null; + const declineOwnership = (): null => { + releaseReplayOwnership(session); + return null; + }; + try { + const env = getEnvConfig(); + if (!env.ENABLE_REQUEST_REPLAY) return declineOwnership(); + if (activeSpoolCount >= env.REPLAY_MAX_CONCURRENT_SPOOLS) { + logger.debug("[ReplaySpool] concurrent spool cap reached, skipping replay", { + active: activeSpoolCount, + }); + return declineOwnership(); + } + if (response.status < 200 || response.status >= 300) return declineOwnership(); + const contentType = response.headers.get("content-type") ?? "text/event-stream"; + if (!contentType.toLowerCase().includes("text/event-stream")) return declineOwnership(); + + const spool = new ReplaySpool( + replayState.identity, + replayState.ownerToken, + response.status, + contentType + ); + spool.bootstrap(); + return spool; + } catch (error) { + logger.debug("[ReplaySpool] create failed", { + error: error instanceof Error ? error.message : String(error), + }); + return declineOwnership(); + } +} diff --git a/src/app/v1/_lib/proxy/replay/replay-store.ts b/src/app/v1/_lib/proxy/replay/replay-store.ts new file mode 100644 index 000000000..f3ea4bd11 --- /dev/null +++ b/src/app/v1/_lib/proxy/replay/replay-store.ts @@ -0,0 +1,254 @@ +import "server-only"; + +import { and, eq, gt, lt } from "drizzle-orm"; +import type Redis from "ioredis"; +import { db } from "@/drizzle/db"; +import { replayPayloads } from "@/drizzle/schema"; +import { getEnvConfig } from "@/lib/config/env.schema"; +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis/client"; +import { RedisKVStore } from "@/lib/redis/redis-kv-store"; +import { RedisListStore } from "@/lib/redis/redis-list-store"; + +/** + * F2 Replay 双层存储: + * - Redis 热层(TTL 有界):meta(状态机)+ chunks(客户端可见字节的 LIST)+ owner(租约) + * 任意副本可读实时尾部——共享存储等效替代 CCHP 的本地磁盘 spool + owner-proxy。 + * - PG 持久层:仅存已通过计费终态屏障的完整响应(跨小时/跨滚动发布重放)。 + * + * 一切 Redis 失败 fail-open:读 miss、写放弃,请求回退现状行为。 + */ + +export type ReplayStatus = "owning" | "completed" | "aborted"; + +export interface ReplayMeta { + status: ReplayStatus; + verifier: string; + scopeTag: string; + statusCode: number; + /** 仅保留承载语义的响应头(content-type 等) */ + headers: Record; + format: string; + model: string | null; + chunkCount: number; + byteSize: number; + /** owner 心跳(epoch ms):spool 每次冲刷时更新,attach 读者据此做 stall 检测 */ + heartbeatAt: number; + messageRequestId?: number | null; + abortReason?: string; +} + +/** owner 租约 TTL:owner 崩溃后新的 claim 最多等这么久即可接管 */ +const OWNER_LEASE_TTL_SECONDS = 45; + +const LUA_COMPARE_DELETE = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0`; + +const LUA_COMPARE_EXPIRE = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('EXPIRE', KEYS[1], ARGV[2]) + return 1 +end +return 0`; + +type RedisRawClient = Pick & { + eval(...args: [script: string, numkeys: number, ...rest: (string | number)[]]): Promise; +}; + +export interface ReplayPersistedRow { + replayId: string; + verifier: string; + scopeTag: string; + keyId: number; + userId: number; + format: string; + model: string | null; + statusCode: number; + headers: Record; + payload: string; + byteSize: number; + sourceMessageRequestId: number | null; +} + +export class ReplayStore { + private readonly meta: RedisKVStore; + private readonly chunks: RedisListStore; + + constructor() { + const ttl = resolveReplayTtlSeconds(); + this.meta = new RedisKVStore({ + prefix: "cch:replay:meta:", + defaultTtlSeconds: ttl, + }); + this.chunks = new RedisListStore({ prefix: "cch:replay:chunks:" }); + } + + private getRawRedis(): RedisRawClient | null { + const redis = getRedisClient({ allowWhenRateLimitDisabled: true }) as RedisRawClient | null; + if (redis?.status !== "ready") return null; + return redis; + } + + async getMeta(replayId: string): Promise { + return this.meta.get(replayId); + } + + async setMeta(replayId: string, meta: ReplayMeta, ttlSeconds?: number): Promise { + return this.meta.set(replayId, meta, ttlSeconds ?? resolveReplayTtlSeconds()); + } + + async appendChunks(replayId: string, values: string[]): Promise { + return this.chunks.rpushBatch(replayId, values, resolveReplayTtlSeconds()); + } + + /** 从 offset(0-based)读到当前末尾;Redis 不可用返回 null。 */ + async readChunks(replayId: string, fromIndex: number): Promise { + return this.chunks.lrangeFrom(replayId, fromIndex); + } + + async deleteEntry(replayId: string): Promise { + await Promise.all([this.meta.delete(replayId), this.chunks.delete(replayId)]); + } + + async deleteChunks(replayId: string): Promise { + await this.chunks.delete(replayId); + } + + /** owner 租约:SET NX EX。成功即成为唯一 owner;Redis 不可用视为失败(不做 replay)。 */ + async tryClaimOwner(replayId: string, ownerToken: string): Promise { + const redis = this.getRawRedis(); + if (!redis) return false; + try { + const result = await redis.set( + `cch:replay:owner:${replayId}`, + ownerToken, + "EX", + OWNER_LEASE_TTL_SECONDS, + "NX" + ); + return result === "OK"; + } catch (error) { + logger.warn("[ReplayStore] owner claim failed", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + /** + * 心跳续租(compare-and-expire):仅 token 仍属自己时续期,防止租约过期后 + * 被并发 claim 抢走、旧 owner 却继续无条件覆写租约。 + * 返回 false 表示所有权已失(或续租异常,保守视为失去); + * Redis 不可用返回 true——状态未知,不惩罚仍在正常冲刷的 owner。 + */ + async renewOwnerLease(replayId: string, ownerToken: string): Promise { + const redis = this.getRawRedis(); + if (!redis) return true; + try { + const result = await redis.eval( + LUA_COMPARE_EXPIRE, + 1, + `cch:replay:owner:${replayId}`, + ownerToken, + OWNER_LEASE_TTL_SECONDS + ); + return result === 1; + } catch { + return false; + } + } + + /** 释放租约(compare-delete,只删自己的)。 */ + async releaseOwner(replayId: string, ownerToken: string): Promise { + const redis = this.getRawRedis(); + if (!redis) return; + try { + await redis.eval(LUA_COMPARE_DELETE, 1, `cch:replay:owner:${replayId}`, ownerToken); + } catch { + // 租约会自然过期 + } + } + + // ===== PG 完成持久层 ===== + + /** + * 写 PG 完成持久层。失败必须向调用方抛出:completeAfterBilling 依赖该异常 + * 走 abort——payload 未 durable 时绝不能把 meta 翻成 completed。 + * (过期行清理由 instrumentation 定时调度器负责,不在写路径顺带执行。) + */ + async persistCompleted(row: ReplayPersistedRow): Promise { + const env = getEnvConfig(); + const expiresAt = new Date(Date.now() + env.REPLAY_COMPLETED_TTL_SECONDS * 1000); + try { + await db + .insert(replayPayloads) + .values({ + replayId: row.replayId, + verifier: row.verifier, + scopeTag: row.scopeTag, + keyId: row.keyId, + userId: row.userId, + format: row.format, + model: row.model, + statusCode: row.statusCode, + headersJson: row.headers, + payload: row.payload, + byteSize: row.byteSize, + sourceMessageRequestId: row.sourceMessageRequestId, + expiresAt, + }) + .onConflictDoNothing(); + } catch (error) { + logger.warn("[ReplayStore] persistCompleted failed", { + error: error instanceof Error ? error.message : String(error), + replayId: row.replayId.slice(0, 12), + }); + throw error; + } + } + + /** 删除 PG 持久层已过期行;返回删除数(错误由调用方处理)。 */ + async cleanupExpired(): Promise { + const deleted = await db + .delete(replayPayloads) + .where(lt(replayPayloads.expiresAt, new Date())) + .returning({ replayId: replayPayloads.replayId }); + return deleted.length; + } + + async findCompleted(replayId: string): Promise { + try { + const rows = await db + .select() + .from(replayPayloads) + .where(and(eq(replayPayloads.replayId, replayId), gt(replayPayloads.expiresAt, new Date()))) + .limit(1); + return rows[0] ?? null; + } catch (error) { + logger.warn("[ReplayStore] findCompleted failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } +} + +export function resolveReplayTtlSeconds(): number { + try { + return getEnvConfig().REPLAY_TTL_SECONDS; + } catch { + return 600; + } +} + +let sharedReplayStore: ReplayStore | null = null; + +export function getReplayStore(): ReplayStore { + if (!sharedReplayStore) { + sharedReplayStore = new ReplayStore(); + } + return sharedReplayStore; +} diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 9bf1bc9c9..f628f390c 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -5,6 +5,7 @@ import { import { ResponseFixer } from "@/app/v1/_lib/proxy/response-fixer"; import { findSafeDatabaseError } from "@/drizzle/admitted-client"; import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { computeCacheScoreFields } from "@/lib/cache-effectiveness/gate"; import { getEnvConfig } from "@/lib/config/env.schema"; import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; import { emitProxyLangfuseTrace } from "@/lib/langfuse/emit-proxy-trace"; @@ -51,6 +52,7 @@ import type { LongContextPricingSpecialSetting } from "@/types/special-settings" import { GeminiAdapter } from "../gemini/adapter"; import type { GeminiResponse } from "../gemini/types"; import { extractActualResponseModelForProvider, extractJsonChunks } from "./actual-response-model"; +import { recordAffinityWinner, tombstoneAffinityOnFailure } from "./affinity/affinity-recorder"; import { bindClientAbortListener } from "./client-abort-listener"; import { createDemandDrivenResponsePump, @@ -58,6 +60,7 @@ import { } from "./demand-driven-response-pump"; import { isDiscoveryProtocolErrorPayload } from "./discovery-validity"; import { isClientAbortError, isTransportError } from "./errors"; +import { createReplaySpoolIfOwner, releaseReplayOwnership } from "./replay/replay-spool"; import type { ProxySession } from "./session"; import { consumeDeferredStreamingFinalization, @@ -65,6 +68,8 @@ import { type DeferredStreamingFinalization, peekDeferredStreamingFinalization, } from "./stream-finalization"; +import { mapProviderTypeToFamily } from "./stream-gate/frame-classifier"; +import { createShadowGateObserver, resolveStreamGateMode } from "./stream-gate/stream-content-gate"; const CLIENT_ABORT_DRAIN_MAX_MS = 60_000; const STREAM_STATS_MAX_BUFFER_BYTES = 10 * 1024 * 1024; @@ -2333,6 +2338,8 @@ export class ProxyResponseHandler { session: ProxySession, response: Response ): Promise { + // F2:stream 请求被上游以非流响应回答时不做 replay,立即让出 owner 租约 + releaseReplayOwnership(session); const messageContext = session.messageContext; const provider = session.provider; const discoveryLeaseLifecycle = startDiscoveryLeaseLifecycle(session); @@ -3280,6 +3287,7 @@ export class ProxyResponseHandler { const discoveryLeaseLifecycle = startDiscoveryLeaseLifecycle(session); if (!messageContext || !provider || !response.body) { + releaseReplayOwnership(session); discardBeforeResponseBodySnapshot(session); releaseSessionAgent(session); const deferredMeta = peekDeferredStreamingFinalization(session); @@ -3311,6 +3319,22 @@ export class ProxyResponseHandler { }); discardBeforeResponseBodySnapshot(session); + // F2:passthrough 分支不建 replay spool——owner 租约立即释放并清角色 + releaseReplayOwnership(session); + + // F1 shadow 遥测:enforce 已在 forwarder 作用于该流量,shadow 观察同样不留盲区 + const passthroughShadowObserver = (() => { + if (resolveStreamGateMode() !== "shadow") return null; + if (session.getEndpointPolicy().kind === "raw_passthrough") return null; + const family = mapProviderTypeToFamily(provider.providerType); + if (!family) return null; + return createShadowGateObserver({ + family, + providerId: provider.id, + providerName: provider.name, + }); + })(); + // 注意:不要在“仅收到响应头”时清除首字节超时。 // 背景:部分上游可能会快速返回 200 + SSE headers,但随后长时间不发送任何 body 数据。 // 若在 headers 阶段就 clearResponseTimeout,会导致首字节超时失效,客户端与服务端都会表现为一直“请求中”。 @@ -3343,7 +3367,10 @@ export class ProxyResponseHandler { passthroughPump = createDemandDrivenResponsePump({ source: response.body, onReadStart: () => observePassthroughReadStart(), - onChunk: (value) => observePassthroughChunk(value), + onChunk: (value) => { + passthroughShadowObserver?.observe(value); + observePassthroughChunk(value); + }, onClientCancel: (reason) => { startPassthroughDrain(reason); }, @@ -3838,7 +3865,9 @@ export class ProxyResponseHandler { ? provider.streamingIdleTimeoutMs : Number.POSITIVE_INFINITY; const streamTaskStaleTimeoutMs = resolveStreamTaskStaleTimeoutMs(); - const clientAbortDrainTimeoutMs = CLIENT_ABORT_DRAIN_MAX_MS; + // F2:owner 请求(活跃 spool)的断线引流窗口延长到 REPLAY_MAX_DETACHED_MS, + // 让上游响应在客户端断开后继续被缓存直至完成;非 replay 请求维持 60s 现状。 + let clientAbortDrainTimeoutMs = CLIENT_ABORT_DRAIN_MAX_MS; let responsePump: DemandDrivenResponsePump | null = null; // 提升 idleTimeoutId 到外部作用域,以便客户端断开时能清除 @@ -4387,8 +4416,56 @@ export class ProxyResponseHandler { } }); } + + // F2 终态屏障:replay completed 只能出现在计费落库(onCommitted)之后; + // 任何失败终态(假 200/中断/非 2xx)立即 abort,绝不被已完成重放命中。 + if (replaySpool) { + const isReplayableSuccess = + finalized.commitSideEffects !== undefined && + effectiveStatusCode >= 200 && + effectiveStatusCode < 300; + if (isReplayableSuccess) { + postTerminalSideEffects.push(async () => { + try { + await replaySpool.completeAfterBilling(messageContext.id); + } catch (err) { + logger.warn("[ResponseHandler] Replay spool completion failed:", { error: err }); + } + }); + } else { + void replaySpool.abort(streamErrorMessage ?? `status_${effectiveStatusCode}`); + } + } + + // F3a 亲和写回:owner 成功终态(计费落库后)才绑定 tip/sys -> 胜出供应商 + if ( + finalized.commitSideEffects !== undefined && + effectiveStatusCode >= 200 && + effectiveStatusCode < 300 && + session.affinity && + providerIdForPersistence + ) { + const winnerProviderId = providerIdForPersistence; + postTerminalSideEffects.push(async () => { + await recordAffinityWinner(session, winnerProviderId); + }); + } else if (session.affinity && providerIdForPersistence && finalized.errorMessage) { + // 流终态失败且失败者正是亲和提名的供应商:写墓碑自愈 + void tombstoneAffinityOnFailure(session, providerIdForPersistence); + } latestStreamCommitSideEffects = postTerminalSideEffects; + // F3b 缓存模拟列:仅开关开启时派生(关闭时保持 undefined,不落值) + const cacheScoreFields = getEnvConfig().ENABLE_CACHE_EFFECTIVENESS + ? computeCacheScoreFields({ + affinity: session.affinity, + succeeded: effectiveStatusCode >= 200 && effectiveStatusCode < 300, + usageObservable: usageForCost?.input_tokens != null, + streamTruncated: !streamEndedNormally, + cacheTtl: usageForCost?.cache_ttl ?? null, + }) + : undefined; + // 保存扩展信息(status code, tokens, provider chain) terminalDetailsPersisted = await awaitFinalization( updateMessageRequestDetailsDurably( @@ -4413,6 +4490,7 @@ export class ProxyResponseHandler { context1mApplied: session.getContext1mApplied(), swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, specialSettings: session.getSpecialSettings() ?? undefined, + ...(cacheScoreFields ?? {}), }, { onCommitted: scheduleStreamCommitSideEffects, @@ -4433,14 +4511,49 @@ export class ProxyResponseHandler { errorMessage: streamErrorMessage ?? undefined, }); })(); + // F2 兜底:finalize 在终态决策点之前抛出时,spool 会永挂 owning、租约悬置、 + // activeSpoolCount 泄漏。旁路 catch 只做 abort,不吞异常——原 promise 仍向 + // 调用方原样 reject(既有传播语义不变)。 + streamFinalizationPromise.catch(() => { + if (replaySpool && !replaySpool.isTerminal) { + void replaySpool.abort("finalize_error"); + } + }); return streamFinalizationPromise; }; + // F1 shadow 模式:旁路逐帧分类,记录「首非空字节 vs 首有效内容」的分歧与延迟差, + // 不缓冲、不 failover,仅用于 enforce 灰度前评估误判率。 + const shadowGateObserver = (() => { + if (resolveStreamGateMode() !== "shadow") return null; + if (session.getEndpointPolicy().kind === "raw_passthrough") return null; + const family = mapProviderTypeToFamily(provider.providerType); + if (!family) return null; + return createShadowGateObserver({ + family, + providerId: provider.id, + providerName: provider.name, + }); + })(); + + // F2 owner spool:guard 阶段已抢到 owner 租约的请求,把客户端可见字节 + // write-behind 喂入 Redis 热层,供并发/断线的相同请求 attach 跟尾。 + const replaySpool = createReplaySpoolIfOwner(session, response); + if (replaySpool) { + try { + clientAbortDrainTimeoutMs = getEnvConfig().REPLAY_MAX_DETACHED_MS; + } catch { + // env 解析失败保持 60s 现状 + } + } + const observeChunk = (value: Uint8Array) => { const chunkSize = value.length; clearIdleTimer(); streamTextAccumulator.pushBytes(value); AsyncTaskManager.touch(taskId); + shadowGateObserver?.observe(value); + replaySpool?.observe(value); logger.trace("ResponseHandler: Upstream stream chunk received", { taskId, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index a91a2c25c..b14042f5c 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -31,6 +31,7 @@ import { import type { SpecialSetting } from "@/types/special-settings"; import type { BillingModelSource, CodexPriorityBillingSource } from "@/types/system-config"; import type { User } from "@/types/user"; +import type { FingerprintChain } from "./affinity/fingerprint"; import { isCountTokensEndpointPath } from "./endpoint-paths"; import { type EndpointPolicy, resolveEndpointPolicy } from "./endpoint-policy"; import { ProxyError } from "./errors"; @@ -44,8 +45,27 @@ import { type OpenAIImageRequestMetadata, parseOpenAIImageMultipartMetadata, } from "./openai-image-compat"; +import type { ReplayIdentity } from "./replay/replay-identity"; import { decodeRequestBody } from "./request-body-codec"; +/** F2 Replay 的会话内状态:guard 阶段抢到 owner 租约后填充。 */ +export interface SessionReplayState { + identity: ReplayIdentity; + ownerToken: string; + role: "owner"; +} + +/** F3a 前缀亲和的会话内状态:指纹链计算一次,供提名、写回与缓存效果指标复用。 */ +export interface SessionAffinityState { + scopeTag: string; + chain: FingerprintChain; + /** 亲和提名成功并 setProvider 后填充;用于 failover 时定向写墓碑 */ + nominatedProviderId: number | null; + /** 查找命中的边界指纹(未命中为 null) */ + matchedFp: string | null; + matchedTier: "conversation" | "system" | null; +} + /** * Classification of an auth failure, used to decide whether to record the * failure against the brute-force rate limiter. @@ -150,6 +170,12 @@ export class ProxySession { originalFormat: ClientFormat = "claude"; providerType: ProviderType | null = null; + // 最长前缀亲和状态(F3a 计算一次,供提名/写回/缓存效果指标复用) + affinity: SessionAffinityState | null = null; + + // Replay 角色状态(F2 guard 阶段 claim owner 成功后填充,spool 由 handleStream 建立) + replayState: SessionReplayState | null = null; + private readonly endpointPolicy: EndpointPolicy; // 模型重定向追踪:保存原始模型名(重定向前) @@ -681,12 +707,14 @@ export class ProxySession { | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消(未计费) | "hedge_loser_billed" // 该供应商输掉 Hedge 竞速,但其响应被后台拿回并计费 - | "client_abort"; // 客户端在响应完成前断开连接 + | "client_abort" // 客户端在响应完成前断开连接 + | "affinity_hit"; // 最长前缀亲和命中(软提名,已通过全套硬校验) selectionMethod?: | "session_reuse" | "weighted_random" | "group_filtered" - | "fail_open_fallback"; + | "fail_open_fallback" + | "prefix_affinity"; circuitState?: "closed" | "open" | "half-open"; attemptNumber?: number; errorMessage?: string; // 错误信息(失败时记录) @@ -1183,7 +1211,7 @@ export class ProxySession { } const text = typeof blockObj.text === "string" ? blockObj.text.trim() : ""; - if (!text || text.toLowerCase() !== "warmup") { + if (text?.toLowerCase() !== "warmup") { return false; } diff --git a/src/app/v1/_lib/proxy/stream-gate/frame-classifier.ts b/src/app/v1/_lib/proxy/stream-gate/frame-classifier.ts new file mode 100644 index 000000000..7913f7c00 --- /dev/null +++ b/src/app/v1/_lib/proxy/stream-gate/frame-classifier.ts @@ -0,0 +1,435 @@ +/** + * 流式帧内容分类器(CCHP Layer-1 内容信号的 TypeScript 移植)。 + * + * 对单个完整 SSE 帧(event 名 + data JSON)给出五态判定: + * - content:携带用户可感知内容,可作为「首个有效内容 chunk」开启透传 + * - error:上游错误信号(fake-200 / 流中 error 帧),commit 前出现即 failover + * - malformed:data 不是合法 JSON 载荷,立即终止当前 attempt(fail-closed) + * - terminal:干净终止标记([DONE] / message_stop 等),不开启透传 + * - neutral:bookkeeping / 未知事件,继续缓冲,由首块超时兜底 + * + * 判定优先级:doneSentinel(terminal) > malformed(非 JSON) > error > content > terminalRules > neutral。 + * error 先于 content:fake-200 上游可能在 error 帧里附带残缺内容字段。 + * 未知事件一律中性(provider 新增 lifecycle 事件前向兼容)。 + * + * 规则数据移植自 CCHP generated/content_signals.json(团队拥有版权), + * 求值语义与 CCHP pkg/protocol/sdkcatalog/content_gate.go 对齐: + * - 规则内多条件 AND;空规则永不命中 + * - anyPaths:任一路径解析出「非空」值即命中(gjson 语义,见 isNonEmptyValue) + * - valueMatches:路径值(数组则任一元素)等于任一候选串 + */ + +export type ProtocolFamily = "anthropic" | "openai-chat" | "openai-responses" | "gemini"; + +export type FrameVerdict = "content" | "error" | "malformed" | "terminal" | "neutral"; + +interface ValueMatch { + path: string; + values: string[]; +} + +interface FrameRule { + eventTypes?: string[]; + anyPaths?: string[]; + valueMatches?: ValueMatch[]; +} + +interface StreamSignal { + contentRules: FrameRule[]; + errorRules: FrameRule[]; + terminalRules?: FrameRule[]; + terminalEvents?: string[]; + doneSentinel?: string; +} + +const STREAM_SIGNALS: Record = { + anthropic: { + contentRules: [ + { + // text_delta / input_json_delta / thinking_delta / signature_delta / citations_delta + eventTypes: ["content_block_delta"], + anyPaths: [ + "delta.text", + "delta.partial_json", + "delta.thinking", + "delta.signature", + "delta.citation", + ], + }, + { + // start 帧即携带实体 payload 的内容块;text/thinking 空启动块等 delta + eventTypes: ["content_block_start"], + valueMatches: [ + { + path: "content_block.type", + values: [ + "tool_use", + "server_tool_use", + "mcp_tool_use", + "redacted_thinking", + "web_search_tool_result", + "web_fetch_tool_result", + "code_execution_tool_result", + "bash_code_execution_tool_result", + "text_editor_code_execution_tool_result", + "tool_search_tool_result", + "mcp_tool_result", + "container_upload", + ], + }, + ], + }, + ], + errorRules: [ + // SSE event: error + data {"type":"error","error":{...}} + { eventTypes: ["error"] }, + // 任意帧携带非空顶层 error 对象(非规范上游 fake-200 兜底) + { anyPaths: ["error"] }, + ], + terminalEvents: ["message_stop"], + }, + "openai-chat": { + contentRules: [ + { + // chunk 无事件名;delta 携带 content/tool_calls/refusal/audio 即内容 + anyPaths: [ + "choices.#.delta.content", + "choices.#.delta.tool_calls", + "choices.#.delta.function_call", + "choices.#.delta.refusal", + "choices.#.delta.audio.data", + "choices.#.delta.audio.transcript", + ], + }, + ], + errorRules: [ + // data: {"error":{...}} 可出现在流中任意位置 + { anyPaths: ["error"] }, + ], + doneSentinel: "[DONE]", + }, + "openai-responses": { + contentRules: [ + { + // 所有 *.delta 内容事件载荷字段统一为 delta + eventTypes: [ + "response.output_text.delta", + "response.refusal.delta", + "response.reasoning_text.delta", + "response.reasoning_summary_text.delta", + "response.audio.delta", + "response.audio.transcript.delta", + "response.function_call_arguments.delta", + "response.custom_tool_call_input.delta", + "response.code_interpreter_call_code.delta", + "response.mcp_call_arguments.delta", + ], + anyPaths: ["delta"], + }, + { + // 渐进图片生成 partial base64 + eventTypes: ["response.image_generation_call.partial_image"], + anyPaths: ["partial_image_b64"], + }, + { + // done 帧携带完整文本(兜住跳过 delta 的上游) + eventTypes: [ + "response.output_text.done", + "response.reasoning_text.done", + "response.reasoning_summary_text.done", + ], + anyPaths: ["text"], + }, + { + eventTypes: ["response.audio.transcript.done"], + anyPaths: ["transcript", "text"], + }, + { + eventTypes: ["response.refusal.done"], + anyPaths: ["refusal"], + }, + { + eventTypes: ["response.function_call_arguments.done", "response.mcp_call_arguments.done"], + anyPaths: ["arguments"], + }, + { + eventTypes: ["response.custom_tool_call_input.done"], + anyPaths: ["input"], + }, + { + eventTypes: ["response.code_interpreter_call_code.done"], + anyPaths: ["code"], + }, + { + // function_call / mcp_call output item 携带工具名 = 模型已决定调用工具 + eventTypes: ["response.output_item.added"], + anyPaths: ["item.name"], + }, + ], + errorRules: [ + // 顶层 error 事件(code/message/param) + { eventTypes: ["error"] }, + // 整个 response 失败(response.error 已填充); + // 子工具失败(mcp_call.failed 等)模型可继续,为中性 + { eventTypes: ["response.failed"] }, + // 任意帧携带非空 error 对象(response.* 事件的 error:null 不命中) + { anyPaths: ["error", "response.error"] }, + ], + terminalEvents: ["response.completed", "response.incomplete"], + }, + gemini: { + contentRules: [ + { + // candidates[].content.parts[] 任一实体载荷字段非空 + anyPaths: [ + "candidates.#.content.parts.#.text", + "candidates.#.content.parts.#.inlineData.data", + "candidates.#.content.parts.#.fileData.fileUri", + "candidates.#.content.parts.#.functionCall.name", + "candidates.#.content.parts.#.functionResponse.name", + "candidates.#.content.parts.#.executableCode.code", + "candidates.#.content.parts.#.codeExecutionResult.output", + ], + }, + ], + errorRules: [ + // 流中 {"error":{code,message,status}} chunk + { anyPaths: ["error"] }, + // prompt 被安全策略拦截(首 chunk,无 candidates) + { anyPaths: ["promptFeedback.blockReason"] }, + { + // 异常终止原因;STOP 与 MAX_TOKENS 为正常终止 + valueMatches: [ + { + path: "candidates.#.finishReason", + values: [ + "SAFETY", + "RECITATION", + "LANGUAGE", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", + "MALFORMED_FUNCTION_CALL", + "IMAGE_SAFETY", + "UNEXPECTED_TOOL_CALL", + "IMAGE_PROHIBITED_CONTENT", + "NO_IMAGE", + "IMAGE_RECITATION", + "IMAGE_OTHER", + "OTHER", + ], + }, + ], + }, + ], + terminalRules: [ + // chunk 无事件名;finishReason 出现即近终止(无显式终止哨兵) + { anyPaths: ["candidates.#.finishReason"] }, + ], + }, +}; + +/** + * 供应商类型 → 协议家族映射。 + * + * 门控分类作用于上游原生 wire 格式(在 ResponseFixer / Gemini 转换之前), + * 因此按 provider 类型而非入口格式选择家族。未知类型返回 null(跳过门控,fail-open)。 + */ +export function mapProviderTypeToFamily( + providerType: string | null | undefined +): ProtocolFamily | null { + switch (providerType) { + case "claude": + case "claude-auth": + return "anthropic"; + case "codex": + return "openai-responses"; + case "openai-compatible": + return "openai-chat"; + case "gemini": + case "gemini-cli": + return "gemini"; + default: + return null; + } +} + +/** + * 对单个完整 SSE 帧分类。 + * + * eventName 为空时取 data 顶层 "type" 字段作为事件判别值(OpenAI Responses / + * Anthropic 的 data 内嵌 type;OpenAI Chat 与 Gemini 无事件名走纯路径规则)。 + * data 非 JSON 时:命中 doneSentinel -> terminal;空 data 保持中性; + * 其余损坏或非对象/数组 JSON -> malformed(fail-closed)。 + * + * 分类器自身异常一律吞为 neutral:绝不因门控 bug 杀正常流。 + */ +export function classifyFrame( + family: ProtocolFamily, + eventName: string | null, + data: string +): FrameVerdict { + try { + return classifyFrameInner(STREAM_SIGNALS[family], eventName, data); + } catch { + return "neutral"; + } +} + +function classifyFrameInner( + signal: StreamSignal, + eventName: string | null, + data: string +): FrameVerdict { + const trimmed = data.trim(); + if (trimmed.length > 0 && signal.doneSentinel && trimmed === signal.doneSentinel) { + return "terminal"; + } + if (trimmed.length === 0) { + return "neutral"; + } + const first = trimmed[0]; + if (first !== "{" && first !== "[") { + return "malformed"; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return "malformed"; + } + if (parsed === null || typeof parsed !== "object") { + return "malformed"; + } + + let effective = (eventName ?? "").trim(); + if (effective === "" && !Array.isArray(parsed)) { + const typeField = (parsed as Record).type; + if (typeof typeField === "string") { + effective = typeField; + } + } + + for (const rule of signal.errorRules) { + if (frameRuleMatches(rule, effective, parsed)) return "error"; + } + for (const rule of signal.contentRules) { + if (frameRuleMatches(rule, effective, parsed)) return "content"; + } + for (const rule of signal.terminalRules ?? []) { + if (frameRuleMatches(rule, effective, parsed)) return "terminal"; + } + if (effective !== "" && signal.terminalEvents?.includes(effective)) { + return "terminal"; + } + return "neutral"; +} + +/** 单条帧规则 AND 语义;空规则永不命中(防目录笔误把所有帧判成内容/错误)。 */ +function frameRuleMatches(rule: FrameRule, eventType: string, parsed: unknown): boolean { + if (rule.eventTypes && rule.eventTypes.length > 0 && !rule.eventTypes.includes(eventType)) { + return false; + } + if (rule.anyPaths && rule.anyPaths.length > 0) { + let hit = false; + for (const path of rule.anyPaths) { + if (isNonEmptyValue(resolvePath(parsed, path))) { + hit = true; + break; + } + } + if (!hit) return false; + } + if (rule.valueMatches) { + for (const match of rule.valueMatches) { + if (!valueMatchHits(match, parsed)) return false; + } + } + return ( + (rule.eventTypes?.length ?? 0) > 0 || + (rule.anyPaths?.length ?? 0) > 0 || + (rule.valueMatches?.length ?? 0) > 0 + ); +} + +/** 路径值(数组则任一元素)的字符串形式等于任一候选值即命中。 */ +function valueMatchHits(match: ValueMatch, parsed: unknown): boolean { + if (!match.path || match.values.length === 0) return false; + const resolved = resolvePath(parsed, match.path); + if (resolved === undefined) return false; + const candidates = Array.isArray(resolved) ? resolved : [resolved]; + for (const candidate of candidates) { + if (typeof candidate === "string" && match.values.includes(candidate)) return true; + if (typeof candidate === "number" || typeof candidate === "boolean") { + if (match.values.includes(String(candidate))) return true; + } + } + return false; +} + +/** + * gjson 风格路径求值:`a.b.c` 逐层取键;`#` 段在数组上映射收集。 + * + * 含 `#` 的路径返回收集数组(可能为空数组 = 存在性视路径而定); + * 路径中断(键不存在 / 非对象)返回 undefined。 + */ +function resolvePath(node: unknown, path: string): unknown { + const segments = path.split("."); + return resolveSegments(node, segments, 0); +} + +function resolveSegments(node: unknown, segments: string[], index: number): unknown { + if (index === segments.length) { + return node; + } + const segment = segments[index]; + if (segment === "#") { + if (!Array.isArray(node)) return undefined; + const collected: unknown[] = []; + for (const item of node) { + const resolved = resolveSegments(item, segments, index + 1); + if (resolved !== undefined) { + if (Array.isArray(resolved) && segments.slice(index + 1).includes("#")) { + // 嵌套 # 收集结果展平(gjson a.#.b.#.c 语义) + collected.push(...resolved); + } else { + collected.push(resolved); + } + } + } + return collected; + } + if (node === null || typeof node !== "object" || Array.isArray(node)) { + return undefined; + } + const child = (node as Record)[segment]; + if (child === undefined) return undefined; + return resolveSegments(child, segments, index + 1); +} + +/** + * gjson 语义的「非空」判定: + * - 字符串:非 ""(base64 / 文本 / URL) + * - 数字:算内容(含 0) + * - true 算内容;false / null / undefined 不算 + * - 数组:任一元素非空(覆盖 # 收集结果) + * - 对象:至少一个键(空对象不算内容) + */ +function isNonEmptyValue(value: unknown): boolean { + if (value === undefined || value === null || value === false) return false; + if (typeof value === "string") return value !== ""; + if (typeof value === "number" || value === true) return true; + if (Array.isArray(value)) { + for (const item of value) { + if (isNonEmptyValue(item)) return true; + } + return false; + } + if (typeof value === "object") { + for (const _key in value as Record) { + return true; + } + return false; + } + return false; +} diff --git a/src/app/v1/_lib/proxy/stream-gate/sse-frames.ts b/src/app/v1/_lib/proxy/stream-gate/sse-frames.ts new file mode 100644 index 000000000..c728a1533 --- /dev/null +++ b/src/app/v1/_lib/proxy/stream-gate/sse-frames.ts @@ -0,0 +1,111 @@ +/** + * 增量 SSE 分帧器。 + * + * 面向流式内容门控与 fake-streaming 校验器共享:把任意切分的字节流 + * 还原成完整的 SSE 帧(event 名 + data 载荷),容忍: + * - 任意网络切分(帧/行/UTF-8 码点跨 chunk 边界) + * - LF 与 CRLF 行尾(含 CR 落在 chunk 末尾的跨块场景) + * - 注释行(`:` 开头)、多行 `data:`、`id:`/`retry:` 等无关字段 + * + * 帧边界语义与既有 fake-streaming 校验器保持一致: + * - 空行触发 dispatch;无 data 行的事件不产出帧(但会重置 event 名) + * - `event:` 值 trim;`data:` 仅剥一个前导空白 + */ + +export interface SseFrame { + /** SSE event 字段值;未出现时为 null */ + eventName: string | null; + /** 多行 data 以 \n 连接后的原始载荷(未 trim) */ + data: string; +} + +export class SseFrameParser { + private readonly decoder = new TextDecoder("utf-8"); + private lineTail = ""; + private currentEvent: string | null = null; + private dataLines: string[] = []; + + /** 喂入一个网络 chunk,返回其中完成的帧(可能为空数组)。 */ + push(chunk: Uint8Array): SseFrame[] { + return this.consume(this.decoder.decode(chunk, { stream: true })); + } + + /** 直接喂入已解码文本(供对完整 body 做一次性解析的调用方使用)。 */ + pushText(text: string): SseFrame[] { + return this.consume(text); + } + + /** 流终止:冲刷尾部未换行的行与未 dispatch 的帧。 */ + finish(): SseFrame[] { + const frames: SseFrame[] = []; + const tail = this.lineTail + this.decoder.decode(); + this.lineTail = ""; + if (tail.length > 0) { + // 尾部残行按一行处理(与既有校验器对无终止空行的流的行为一致) + const frame = this.handleLine(stripTrailingCr(tail)); + if (frame) frames.push(frame); + } + const last = this.flush(); + if (last) frames.push(last); + return frames; + } + + private consume(text: string): SseFrame[] { + const frames: SseFrame[] = []; + let buffer = this.lineTail + text; + // CR 落在末尾时可能是被切开的 CRLF,留到下一个 chunk 再判 + let holdCr = false; + if (buffer.endsWith("\r")) { + buffer = buffer.slice(0, -1); + holdCr = true; + } + const lines = buffer.split(/\r\n|\n|\r/); + // 最后一段是未完成行,保留 + this.lineTail = (lines.pop() ?? "") + (holdCr ? "\r" : ""); + for (const line of lines) { + const frame = this.handleLine(line); + if (frame) frames.push(frame); + } + return frames; + } + + private handleLine(line: string): SseFrame | null { + if (line.length === 0) { + return this.flush(); + } + if (line.startsWith(":")) { + return null; // SSE 注释 + } + if (line.startsWith("event:")) { + this.currentEvent = line.slice(6).trim(); + return null; + } + if (line.startsWith("data:")) { + this.dataLines.push(line.slice(5).replace(/^\s/, "")); + return null; + } + // id: / retry: / 未知字段:忽略 + return null; + } + + private flush(): SseFrame | null { + const event = this.currentEvent; + this.currentEvent = null; + if (this.dataLines.length === 0) { + return null; + } + const data = this.dataLines.join("\n"); + this.dataLines = []; + return { eventName: event, data }; + } +} + +function stripTrailingCr(line: string): string { + return line.endsWith("\r") ? line.slice(0, -1) : line; +} + +/** 对完整 SSE body 一次性解析出全部帧。 */ +export function parseSseBody(body: string): SseFrame[] { + const parser = new SseFrameParser(); + return [...parser.pushText(body), ...parser.finish()]; +} diff --git a/src/app/v1/_lib/proxy/stream-gate/stream-content-gate.ts b/src/app/v1/_lib/proxy/stream-gate/stream-content-gate.ts new file mode 100644 index 000000000..9f3db0e17 --- /dev/null +++ b/src/app/v1/_lib/proxy/stream-gate/stream-content-gate.ts @@ -0,0 +1,285 @@ +import { getEnvConfig } from "@/lib/config/env.schema"; +import { logger } from "@/lib/logger"; +import { getCachedProxyRuntimeSettings } from "@/lib/system-settings/proxy-runtime"; +import { ProxyError } from "../errors"; +import { classifyFrame, type FrameVerdict, type ProtocolFamily } from "./frame-classifier"; +import { SseFrameParser } from "./sse-frames"; + +/** + * 流式内容门控(F1):在向客户端透传前,按帧分类等待「首个有效内容 chunk」。 + * + * - content 帧到达 -> 提交:返回已缓冲的前缀字节 + 原 reader,调用方拼接透传 + * - error / malformed 帧 -> precommit 失败:调用方抛错走现有供应商切换循环 + * - terminal 先于 content / 流提前结束 -> 空流失败 + * - neutral 帧入缓冲;超过 event/byte 上限 -> prebuffer_overflow 失败 + * - read 拒绝(首字节超时 abort / 客户端断开)-> 原样返回错误,由调用方按来源归类 + * + * 客户端在提交前收到的字节数恒为 0:失败时整段前缀被丢弃。 + */ + +export type StreamGateFailureReason = + | "gate_error" + | "decode_error" + | "empty_stream" + | "prebuffer_overflow"; + +/** + * 门控 precommit 错误。继承 ProxyError(statusCode 502)—— + * categorizeErrorAsync 将其归为 PROVIDER_ERROR:计入熔断器并切换供应商, + * 无需改动现有错误分类逻辑。gate_error 时把上游错误帧原文带入 + * upstreamError.body,供错误规则匹配(如不可重试的客户端输入错误)与审计。 + */ +export class StreamPrecommitError extends ProxyError { + readonly gateReason: StreamGateFailureReason; + + constructor( + reason: StreamGateFailureReason, + detail: { + family: ProtocolFamily; + providerId: number; + providerName: string; + frameData?: string; + framesSeen?: number; + bufferedBytes?: number; + } + ) { + const message = `Stream content gate rejected upstream before first valid content (${reason})`; + super(message, 502, { + body: buildGateErrorBody(reason, detail), + providerId: detail.providerId, + providerName: detail.providerName, + }); + this.name = "StreamPrecommitError"; + this.gateReason = reason; + } +} + +function buildGateErrorBody( + reason: StreamGateFailureReason, + detail: { + family: ProtocolFamily; + frameData?: string; + framesSeen?: number; + bufferedBytes?: number; + } +): string { + if (reason === "gate_error" && detail.frameData) { + // 上游错误帧原文(截断):让错误规则/覆写与人工排查看到真实上游错误 + return detail.frameData.length > 2000 ? detail.frameData.slice(0, 2000) : detail.frameData; + } + return JSON.stringify({ + error: { + type: "stream_gate_precommit", + reason, + family: detail.family, + frames_seen: detail.framesSeen, + buffered_bytes: detail.bufferedBytes, + ...(detail.frameData ? { frame_preview: detail.frameData.slice(0, 500) } : {}), + }, + }); +} + +export type StreamGateMode = "off" | "shadow" | "enforce"; + +/** + * 门控模式:系统设置快照优先(每请求的 provider-selector 读取与开机预热保鲜), + * 无快照时回退 env STREAM_GATE_MODE。 + */ +export function resolveStreamGateMode(): StreamGateMode { + try { + return getCachedProxyRuntimeSettings()?.streamGateMode ?? getEnvConfig().STREAM_GATE_MODE; + } catch { + return "off"; + } +} + +export interface StreamGateCaps { + prebufferEventCap: number; + prebufferByteCap: number; +} + +export function resolveStreamGateCaps(): StreamGateCaps { + try { + const env = getEnvConfig(); + return { + prebufferEventCap: env.STREAM_GATE_PREBUFFER_EVENT_CAP, + prebufferByteCap: env.STREAM_GATE_PREBUFFER_BYTE_CAP, + }; + } catch { + return { prebufferEventCap: 64, prebufferByteCap: 256 * 1024 }; + } +} + +export interface StreamGateOptions extends StreamGateCaps { + family: ProtocolFamily; + providerId: number; + providerName: string; +} + +export type StreamGateResult = + | { committed: true; prefixChunks: Uint8Array[]; framesSeen: number; readerDone: boolean } + | { committed: false; error: Error }; + +/** + * 对上游 SSE body reader 执行首个有效内容门控。 + * + * 提交时返回缓冲前缀(含触发提交的 content 帧所在 chunk)与 framesSeen; + * reader 所有权归还调用方(committed 且 readerDone=false 时后续字节仍在 reader 上)。 + * 失败时错误对象已按语义构造,reader 由调用方负责 cancel。 + */ +export async function runStreamContentGate( + reader: ReadableStreamDefaultReader, + options: StreamGateOptions +): Promise { + const parser = new SseFrameParser(); + const buffered: Uint8Array[] = []; + let bufferedBytes = 0; + let framesSeen = 0; + + const failure = (reason: StreamGateFailureReason, frameData?: string): StreamGateResult => ({ + committed: false, + error: new StreamPrecommitError(reason, { + family: options.family, + providerId: options.providerId, + providerName: options.providerName, + frameData, + framesSeen, + bufferedBytes, + }), + }); + + while (true) { + let readResult: ReadableStreamReadResult; + try { + readResult = await reader.read(); + } catch (readError) { + // 首字节超时 abort / 客户端断开 / 传输错误:原样上抛,调用方按来源归类 + return { + committed: false, + error: readError instanceof Error ? readError : new Error(String(readError)), + }; + } + + if (readResult.done) { + // 冲刷尾部未终止帧(无结尾空行的流) + for (const frame of parser.finish()) { + framesSeen++; + const verdict = classifyFrame(options.family, frame.eventName, frame.data); + if (verdict === "content") { + return { committed: true, prefixChunks: buffered, framesSeen, readerDone: true }; + } + if (verdict === "error") return failure("gate_error", frame.data); + if (verdict === "malformed") return failure("decode_error", frame.data); + } + return failure("empty_stream"); + } + + const chunk = readResult.value; + if (!chunk || chunk.byteLength === 0) { + continue; + } + buffered.push(chunk); + bufferedBytes += chunk.byteLength; + + for (const frame of parser.push(chunk)) { + framesSeen++; + const verdict: FrameVerdict = classifyFrame(options.family, frame.eventName, frame.data); + if (verdict === "content") { + return { committed: true, prefixChunks: buffered, framesSeen, readerDone: false }; + } + if (verdict === "error") { + return failure("gate_error", frame.data); + } + if (verdict === "malformed") { + return failure("decode_error", frame.data); + } + if (verdict === "terminal") { + // 干净终止先于任何内容 = 空流 + return failure("empty_stream", frame.data); + } + // neutral: 继续缓冲;event 上限为逐帧硬上限(单 chunk 大量小帧也会触发) + if (framesSeen > options.prebufferEventCap) { + return failure("prebuffer_overflow"); + } + } + + if (bufferedBytes > options.prebufferByteCap) { + return failure("prebuffer_overflow"); + } + } +} + +/** 拼接门控前缀字节(供竞速败者计费 drain 恢复 usage 时复用现有单块逻辑)。 */ +export function concatChunks(chunks: Uint8Array[]): Uint8Array | null { + if (chunks.length === 0) return null; + if (chunks.length === 1) return chunks[0]; + let total = 0; + for (const chunk of chunks) total += chunk.byteLength; + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +/** + * shadow 模式旁路观察者:不缓冲不 failover,只统计 + * 「首非空字节 vs 首有效内容帧」的延迟差与提交前的判定分布, + * 在首个 content 帧出现时打一条低敏日志(无 body 原文),用于灰度前评估。 + */ +export interface ShadowGateObserver { + observe(chunk: Uint8Array): void; +} + +export function createShadowGateObserver(context: { + family: ProtocolFamily; + providerId: number; + providerName: string; +}): ShadowGateObserver { + const parser = new SseFrameParser(); + const verdictCounts: Record = { + content: 0, + error: 0, + malformed: 0, + terminal: 0, + neutral: 0, + }; + let firstByteAt: number | null = null; + let reported = false; + + return { + observe(chunk: Uint8Array): void { + if (reported) return; + try { + if (firstByteAt === null && chunk.byteLength > 0) { + firstByteAt = Date.now(); + } + for (const frame of parser.push(chunk)) { + const verdict = classifyFrame(context.family, frame.eventName, frame.data); + verdictCounts[verdict]++; + if (verdict === "content" || verdict === "error" || verdict === "malformed") { + reported = true; + logger.info("StreamGate[shadow]: first decisive frame observed", { + providerId: context.providerId, + providerName: context.providerName, + family: context.family, + decisiveVerdict: verdict, + // 现状「首非空字节即提交」与门控「首有效内容才提交」的判定分歧: + // divergent=true 表示门控会推迟提交(中性前缀)或触发 failover(error/malformed) + divergent: + verdict !== "content" || verdictCounts.neutral + verdictCounts.terminal > 0, + firstContentLagMs: firstByteAt === null ? null : Date.now() - firstByteAt, + verdictCounts: { ...verdictCounts }, + }); + return; + } + } + } catch { + // shadow 观察绝不影响热路径 + reported = true; + } + }, + }; +} diff --git a/src/components/customs/model-vendor-icon.tsx b/src/components/customs/model-vendor-icon.tsx index 92500ff18..b53a55cdb 100644 --- a/src/components/customs/model-vendor-icon.tsx +++ b/src/components/customs/model-vendor-icon.tsx @@ -47,7 +47,6 @@ function RemoteVendorIcon({ return ; } return ( - // biome-ignore lint/performance/noImgElement: 远程小尺寸 SVG,无需 next/image 优化管线 Tip > Sys),聚合任务按此维度回测供应商缓存效力 + cacheCompatibilityKey: varchar('cache_compatibility_key', { length: 64 }), + // 是否纳入缓存效力窗口聚合(失败/竞速败者/replay serve/不可观测/截断样本排除) + cacheScoreEligible: boolean('cache_score_eligible'), + cacheScoreExcludedReason: varchar('cache_score_excluded_reason', { length: 32 }), + // 理论可命中缓存 token(按匹配边界的规范化前缀字节估算) + theoreticalCacheTokens: bigint('theoretical_cache_tokens', { mode: 'number' }), + cacheTtlBucket: varchar('cache_ttl_bucket', { length: 10 }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), deletedAt: timestamp('deleted_at', { withTimezone: true }), @@ -999,6 +1009,17 @@ export const systemSettings = pgTable('system_settings', { .notNull() .default(5), + // F1 流式内容门控模式: 'off' | 'shadow' | 'enforce'(默认 enforce) + // enforce:首个有效内容帧前缓冲,错误/空流时自动切换供应商;shadow:仅旁路统计分歧 + streamGateMode: varchar('stream_gate_mode', { length: 10 }).notNull().default('enforce'), + + // 忽略客户端 Session ID(默认开启) + // 开启后:可指纹化的请求强制使用最长前缀亲和做供应商粘性(跳过客户端 Session ID 绑定), + // 不可指纹化的请求仍走会话复用 + affinityIgnoreClientSessionId: boolean('affinity_ignore_client_session_id') + .notNull() + .default(true), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }); @@ -1237,6 +1258,55 @@ export const auditLog = pgTable('audit_log', { .on(table.createdAt.desc(), table.id.desc()), })); +// F2 Replay 完成持久层:已完成流式响应的客户端可见字节(跨副本/跨小时重放)。 +// Redis 热层(cch:replay:*)承担活跃期与实时跟尾;本表只存已通过计费终态屏障的完整响应。 +export const replayPayloads = pgTable('replay_payloads', { + // 确定性 Replay ID(身份哈希截 32 hex,含 scopeTag 租户隔离) + replayId: varchar('replay_id', { length: 64 }).primaryKey(), + // 身份复核值(不同盐的内容维度哈希,attach 时严格比对防哈希碰撞) + verifier: varchar('verifier', { length: 64 }).notNull(), + scopeTag: varchar('scope_tag', { length: 16 }).notNull(), + keyId: integer('key_id').notNull(), + userId: integer('user_id').notNull(), + format: varchar('format', { length: 16 }).notNull(), + model: varchar('model', { length: 128 }), + statusCode: integer('status_code').notNull(), + headersJson: jsonb('headers_json').$type>(), + // 客户端可见字节(SSE UTF-8 文本;上限由 REPLAY_MAX_PAYLOAD_BYTES 控制) + payload: text('payload').notNull(), + byteSize: integer('byte_size').notNull(), + sourceMessageRequestId: integer('source_message_request_id'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), +}, (table) => ({ + replayPayloadsKeyIdIdx: index('idx_replay_payloads_key_id').on(table.keyId), + replayPayloadsExpiresAtIdx: index('idx_replay_payloads_expires_at').on(table.expiresAt), +})); + +// F3b 缓存效果窗口聚合历史:按 provider + model + TTL 桶统计理论 vs 实际缓存命中。 +// 定点整数(万分比 bp),禁浮点;仅指标展示,不参与路由。 +export const providerCacheEffectiveness = pgTable('provider_cache_effectiveness', { + id: serial('id').primaryKey(), + providerId: integer('provider_id').notNull(), + model: varchar('model', { length: 128 }).notNull(), + cacheTtlBucket: varchar('cache_ttl_bucket', { length: 10 }).notNull(), + windowStart: timestamp('window_start', { withTimezone: true }).notNull(), + windowEnd: timestamp('window_end', { withTimezone: true }).notNull(), + // 窗口内总样本与合格样本数 + sampleCount: integer('sample_count').notNull().default(0), + eligibleCount: integer('eligible_count').notNull().default(0), + theoreticalCacheTokens: bigint('theoretical_cache_tokens', { mode: 'number' }).notNull().default(0), + observedCacheReadTokens: bigint('observed_cache_read_tokens', { mode: 'number' }).notNull().default(0), + // 万分比定点值:raw = clamp(observed/theoretical);confidence = 可观测率 x 样本量分档 + rawEffectivenessBp: integer('raw_effectiveness_bp').notNull().default(0), + confidenceBp: integer('confidence_bp').notNull().default(0), + effectivenessBp: integer('effectiveness_bp').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), +}, (table) => ({ + providerCacheEffectivenessWindowIdx: index('idx_provider_cache_effectiveness_window') + .on(table.providerId, table.model, table.windowStart.desc()), +})); + // Relations export const usersRelations = relations(users, ({ many }) => ({ keys: many(keys), diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 2827dcd57..2ba076002 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -19,6 +19,10 @@ const instrumentationState = globalThis as unknown as { __CCH_SHUTDOWN_IN_PROGRESS__?: boolean; __CCH_CLOUD_PRICE_SYNC_STARTED__?: boolean; __CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__?: ReturnType; + __CCH_CACHE_EFFECTIVENESS_STARTED__?: boolean; + __CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__?: ReturnType; + __CCH_REPLAY_CLEANUP_STARTED__?: boolean; + __CCH_REPLAY_CLEANUP_INTERVAL_ID__?: ReturnType; __CCH_API_KEY_VF_SYNC_STARTED__?: boolean; __CCH_API_KEY_VF_SYNC_CLEANUP__?: (() => void) | null; __CCH_LIFECYCLE_MARKERS_LOGGED__?: boolean; @@ -248,6 +252,80 @@ async function startRoutingTraceOutboxRecovery(): Promise { } } +/** + * F3b:缓存效果窗口聚合定时任务(每 5 分钟,ENABLE_CACHE_EFFECTIVENESS 开启时)。 + * 服务内部有 advisory lock 防多副本重复跑;tick 失败仅记日志。 + */ +async function startCacheEffectivenessScheduler(): Promise { + if (instrumentationState.__CCH_CACHE_EFFECTIVENESS_STARTED__) { + return; + } + + try { + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_CACHE_EFFECTIVENESS) { + return; + } + const { aggregateCacheEffectiveness } = await import("@/lib/cache-effectiveness/service"); + const intervalMs = 5 * 60 * 1000; + + instrumentationState.__CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__ = setInterval(() => { + void aggregateCacheEffectiveness().catch((error) => { + logger.warn("[Instrumentation] Cache effectiveness aggregation tick failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }, intervalMs); + + instrumentationState.__CCH_CACHE_EFFECTIVENESS_STARTED__ = true; + logger.info("[Instrumentation] Cache effectiveness scheduler started", { + intervalSeconds: intervalMs / 1000, + }); + } catch (error) { + logger.warn("[Instrumentation] Cache effectiveness scheduler init failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * F2:Replay PG 持久层过期行清理(每 10 分钟,ENABLE_REQUEST_REPLAY 开启时)。 + * 写入路径已有机会式扫尾,此任务兜底低流量期无写入的场景。 + */ +async function startReplayCleanupScheduler(): Promise { + if (instrumentationState.__CCH_REPLAY_CLEANUP_STARTED__) { + return; + } + + try { + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_REQUEST_REPLAY) { + return; + } + const { getReplayStore } = await import("@/app/v1/_lib/proxy/replay/replay-store"); + const intervalMs = 10 * 60 * 1000; + + instrumentationState.__CCH_REPLAY_CLEANUP_INTERVAL_ID__ = setInterval(() => { + void getReplayStore() + .cleanupExpired() + .catch((error) => { + logger.warn("[Instrumentation] Replay cleanup tick failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }, intervalMs); + + instrumentationState.__CCH_REPLAY_CLEANUP_STARTED__ = true; + logger.info("[Instrumentation] Replay cleanup scheduler started", { + intervalSeconds: intervalMs / 1000, + }); + } catch (error) { + logger.warn("[Instrumentation] Replay cleanup scheduler init failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + /** * 多实例:订阅 API Key 变更广播,触发本机 Vacuum Filter 失效并重建。 * @@ -534,6 +612,23 @@ export async function register() { }); } + await startCacheEffectivenessScheduler(); + await startReplayCleanupScheduler(); + + // F1/F3a:预热代理运行时设置快照(stream gate / affinity 的同步读取路径) + try { + const { getProxyRuntimeSettings } = await import("@/lib/system-settings/proxy-runtime"); + void getProxyRuntimeSettings().catch((error) => { + logger.warn("[Instrumentation] Proxy runtime settings warmup failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + logger.warn("[Instrumentation] Proxy runtime settings warmup init failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + logger.info("Application ready"); } // 开发环境: 执行迁移 + 初始化价格表(禁用 Bull Queue 避免 Turbopack 冲突) @@ -680,6 +775,23 @@ export async function register() { error: error instanceof Error ? error.message : String(error), }); } + + await startCacheEffectivenessScheduler(); + await startReplayCleanupScheduler(); + + // F1/F3a:预热代理运行时设置快照(stream gate / affinity 的同步读取路径) + try { + const { getProxyRuntimeSettings } = await import("@/lib/system-settings/proxy-runtime"); + void getProxyRuntimeSettings().catch((error) => { + logger.warn("[Instrumentation] Proxy runtime settings warmup failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + logger.warn("[Instrumentation] Proxy runtime settings warmup init failed", { + error: error instanceof Error ? error.message : String(error), + }); + } } else { logger.warn( "[Instrumentation] Database unavailable: skipping endpoint probe scheduler and cleanup" diff --git a/src/lib/api-client/v1/actions/provider-cache-effectiveness.ts b/src/lib/api-client/v1/actions/provider-cache-effectiveness.ts new file mode 100644 index 000000000..d161acfe0 --- /dev/null +++ b/src/lib/api-client/v1/actions/provider-cache-effectiveness.ts @@ -0,0 +1,42 @@ +import { + apiGet, + searchParams, + toActionResult, + unwrapItems, +} from "@/lib/api-client/v1/actions/_compat"; +import type { ActionResult } from "@/lib/api-client/v1/actions/types"; + +export interface ProviderCacheEffectivenessWindowDto { + id: number; + providerId: number; + model: string; + cacheTtlBucket: string; + windowStart: string; + windowEnd: string; + sampleCount: number; + eligibleCount: number; + theoreticalCacheTokens: number; + observedCacheReadTokens: number; + rawEffectivenessBp: number; + confidenceBp: number; + effectivenessBp: number; + createdAt: string | null; +} + +export interface GetProviderCacheEffectivenessParams { + providerId?: number; + limit?: number; +} + +export function getProviderCacheEffectivenessWindows( + params?: GetProviderCacheEffectivenessParams +): Promise> { + return toActionResult( + apiGet<{ items?: ProviderCacheEffectivenessWindowDto[] }>( + `/api/v1/providers/cache-effectiveness${searchParams({ + providerId: params?.providerId, + limit: params?.limit, + })}` + ).then(unwrapItems) + ); +} diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 4af7d75bd..c8bc16148 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -204,6 +204,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/providers/cache-effectiveness": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List provider cache effectiveness windows + * @description Lists aggregated prompt cache effectiveness windows per provider, model, and cache TTL bucket, ordered by window end descending. Read-only metrics; routing and billing are unaffected. + */ + get: operations["getProvidersCacheEffectiveness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/providers/{id}/circuit:reset": { parameters: { query?: never; @@ -6401,6 +6421,220 @@ export interface operations { }; }; }; + getProvidersCacheEffectiveness: { + parameters: { + query?: { + /** @description Optional provider id filter. */ + providerId?: number; + /** @description Maximum number of windows to return, capped at 200. */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cache effectiveness windows. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Cache effectiveness windows ordered by window end descending. */ + items: { + /** @description Aggregation window row id. */ + id: number; + /** @description Provider id. */ + providerId: number; + /** @description Model name the window was aggregated for. */ + model: string; + /** @description Cache TTL bucket, e.g. 5m or 1h. */ + cacheTtlBucket: string; + /** + * Format: date-time + * @description Aggregation window start. + */ + windowStart: string; + /** + * Format: date-time + * @description Aggregation window end. + */ + windowEnd: string; + /** @description Total samples in the window. */ + sampleCount: number; + /** @description Samples eligible for cache observation. */ + eligibleCount: number; + /** @description Theoretical cacheable prompt tokens in the window. */ + theoreticalCacheTokens: number; + /** @description Observed cache read tokens in the window. */ + observedCacheReadTokens: number; + /** @description Raw observed/theoretical ratio in basis points (1/100 of a percent). */ + rawEffectivenessBp: number; + /** @description Confidence of the raw ratio in basis points. */ + confidenceBp: number; + /** @description Confidence-adjusted effectiveness score in basis points. */ + effectivenessBp: number; + /** + * Format: date-time + * @description Row creation time. + */ + createdAt: string | null; + }[]; + }; + }; + }; + /** @description Invalid request. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Authentication required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Admin access required. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Provider not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + }; + }; postProvidersByIdCircuitReset: { parameters: { query?: never; @@ -11999,6 +12233,13 @@ export interface operations { publicStatusWindowHours: number; /** @description Public status aggregation interval in minutes. */ publicStatusAggregationIntervalMinutes: number; + /** + * @description Stream content gate mode: buffer until the first valid content frame and fail over on error or empty streams (enforce), observe divergence only (shadow), or disable (off). + * @enum {string} + */ + streamGateMode: "off" | "shadow" | "enforce"; + /** @description Whether fingerprintable requests force longest-prefix affinity for provider stickiness, skipping client session id binding. */ + affinityIgnoreClientSessionId: boolean; /** * Format: date-time * @description Creation time. @@ -12274,6 +12515,13 @@ export interface operations { publicStatusWindowHours?: number; /** @description Public status aggregation interval in minutes. */ publicStatusAggregationIntervalMinutes?: number; + /** + * @description Stream content gate mode: buffer until the first valid content frame and fail over on error or empty streams (enforce), observe divergence only (shadow), or disable (off). + * @enum {string} + */ + streamGateMode?: "off" | "shadow" | "enforce"; + /** @description Whether fingerprintable requests force longest-prefix affinity for provider stickiness, skipping client session id binding. */ + affinityIgnoreClientSessionId?: boolean; }; }; }; @@ -12424,6 +12672,13 @@ export interface operations { publicStatusWindowHours: number; /** @description Public status aggregation interval in minutes. */ publicStatusAggregationIntervalMinutes: number; + /** + * @description Stream content gate mode: buffer until the first valid content frame and fail over on error or empty streams (enforce), observe divergence only (shadow), or disable (off). + * @enum {string} + */ + streamGateMode: "off" | "shadow" | "enforce"; + /** @description Whether fingerprintable requests force longest-prefix affinity for provider stickiness, skipping client session id binding. */ + affinityIgnoreClientSessionId: boolean; /** * Format: date-time * @description Creation time. diff --git a/src/lib/api/v1/action-migration-matrix.ts b/src/lib/api/v1/action-migration-matrix.ts index f7ec9f3c4..acd7e0ca0 100644 --- a/src/lib/api/v1/action-migration-matrix.ts +++ b/src/lib/api/v1/action-migration-matrix.ts @@ -69,6 +69,14 @@ export const ACTION_MIGRATION_MATRIX = [ access: "admin", exportPolicy: "all-action-exports", }, + { + module: "provider-cache-effectiveness", + sourceFile: "provider-cache-effectiveness.ts", + resource: "providers", + endpointFamilies: ["/api/v1/providers/cache-effectiveness"], + access: "admin", + exportPolicy: "all-action-exports", + }, { module: "provider-endpoints", sourceFile: "provider-endpoints.ts", diff --git a/src/lib/api/v1/schemas/provider-cache-effectiveness.ts b/src/lib/api/v1/schemas/provider-cache-effectiveness.ts new file mode 100644 index 000000000..860e16c69 --- /dev/null +++ b/src/lib/api/v1/schemas/provider-cache-effectiveness.ts @@ -0,0 +1,59 @@ +import { z } from "@hono/zod-openapi"; +import { IsoDateTimeStringSchema } from "@/lib/api/v1/schemas/_common"; + +export const ProviderCacheEffectivenessListQuerySchema = z.object({ + providerId: z.coerce + .number() + .int() + .positive() + .optional() + .describe("Optional provider id filter."), + limit: z.coerce + .number() + .int() + .min(1) + .max(200) + .default(50) + .describe("Maximum number of windows to return, capped at 200."), +}); + +export const ProviderCacheEffectivenessWindowSchema = z.object({ + id: z.number().int().positive().describe("Aggregation window row id."), + providerId: z.number().int().positive().describe("Provider id."), + model: z.string().describe("Model name the window was aggregated for."), + cacheTtlBucket: z.string().describe("Cache TTL bucket, e.g. 5m or 1h."), + windowStart: IsoDateTimeStringSchema.describe("Aggregation window start."), + windowEnd: IsoDateTimeStringSchema.describe("Aggregation window end."), + sampleCount: z.number().int().min(0).describe("Total samples in the window."), + eligibleCount: z.number().int().min(0).describe("Samples eligible for cache observation."), + theoreticalCacheTokens: z + .number() + .int() + .min(0) + .describe("Theoretical cacheable prompt tokens in the window."), + observedCacheReadTokens: z + .number() + .int() + .min(0) + .describe("Observed cache read tokens in the window."), + rawEffectivenessBp: z + .number() + .int() + .describe("Raw observed/theoretical ratio in basis points (1/100 of a percent)."), + confidenceBp: z.number().int().describe("Confidence of the raw ratio in basis points."), + effectivenessBp: z + .number() + .int() + .describe("Confidence-adjusted effectiveness score in basis points."), + createdAt: IsoDateTimeStringSchema.nullable().describe("Row creation time."), +}); + +export const ProviderCacheEffectivenessListResponseSchema = z.object({ + items: z + .array(ProviderCacheEffectivenessWindowSchema) + .describe("Cache effectiveness windows ordered by window end descending."), +}); + +export type ProviderCacheEffectivenessListQuery = z.infer< + typeof ProviderCacheEffectivenessListQuerySchema +>; diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index 2fb19b32c..bdb409111 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -212,6 +212,16 @@ export const SystemSettingsSchema = z .number() .int() .describe("Public status aggregation interval in minutes."), + streamGateMode: z + .enum(["off", "shadow", "enforce"]) + .describe( + "Stream content gate mode: buffer until the first valid content frame and fail over on error or empty streams (enforce), observe divergence only (shadow), or disable (off)." + ), + affinityIgnoreClientSessionId: z + .boolean() + .describe( + "Whether fingerprintable requests force longest-prefix affinity for provider stickiness, skipping client session id binding." + ), createdAt: IsoDateTimeStringSchema.describe("Creation time."), updatedAt: IsoDateTimeStringSchema.describe("Last update time."), }) diff --git a/src/lib/auth-session-store/redis-session-store.ts b/src/lib/auth-session-store/redis-session-store.ts index 9a6d03e10..285edd8ca 100644 --- a/src/lib/auth-session-store/redis-session-store.ts +++ b/src/lib/auth-session-store/redis-session-store.ts @@ -119,7 +119,7 @@ export class RedisSessionStore implements SessionStore { private getReadyRedis(): RedisSessionClient | null { const redis = this.resolveRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return null; } diff --git a/src/lib/cache-effectiveness/gate.ts b/src/lib/cache-effectiveness/gate.ts new file mode 100644 index 000000000..809261cc2 --- /dev/null +++ b/src/lib/cache-effectiveness/gate.ts @@ -0,0 +1,97 @@ +import { fingerprintTip } from "@/app/v1/_lib/proxy/affinity/fingerprint"; +import type { SessionAffinityState } from "@/app/v1/_lib/proxy/session"; + +/** + * F3b 缓存效果门控(CCHP finalize/cache_score.go 的移植,纯函数无 IO)。 + * + * 在流式终态结算时派生 message_request 的缓存模拟列: + * - cacheCompatibilityKey:scopeTag:fp(优先级 Matched > Tip > Sys)—— + * 同 key 请求理论上可命中同一供应商 prompt cache,聚合任务按此回测。 + * - cacheScoreEligible / excludedReason:只有成功交付、上游可观测、非截断的 + * 请求才纳入窗口聚合,避免污染供应商缓存回测。 + * - theoreticalCacheTokens:按 tip 边界的规范化前缀字节粗估(bytes/4), + * 表示「本请求理论可命中的缓存量上限」。 + * + * 门控顺序(短路):no_affinity_key -> attempt_failed -> not_observable -> stream_truncated -> eligible。 + * (replay serve 走 guard 短路不经流式终态;hedge 败者在独立计费行——两者天然不入本路径。) + */ + +export const CACHE_SCORE_EXCLUDED = { + noAffinityKey: "no_affinity_key", + attemptFailed: "attempt_failed", + notObservable: "not_observable", + streamTruncated: "stream_truncated", +} as const; + +export interface CacheScoreInput { + affinity: SessionAffinityState | null; + /** 终态是否 2xx 成功 */ + succeeded: boolean; + /** 上游是否报告了可观测 usage(input tokens 存在) */ + usageObservable: boolean; + /** 流是否被截断(未自然结束) */ + streamTruncated: boolean; + /** 实际应用的 cache TTL("5m"/"1h" 等),缺省归入 "5m" 桶 */ + cacheTtl: string | null; +} + +export interface CacheScoreFields { + cacheCompatibilityKey: string | null; + cacheScoreEligible: boolean; + cacheScoreExcludedReason: string | null; + theoreticalCacheTokens: number | null; + cacheTtlBucket: string | null; +} + +/** 规范化字节 -> token 粗估系数(英文 ~4 bytes/token 的通用近似) */ +const BYTES_PER_TOKEN = 4; + +export function computeCacheScoreFields(input: CacheScoreInput): CacheScoreFields { + const affinity = input.affinity; + const matchedFp = affinity?.matchedFp ?? null; + const tip = affinity ? fingerprintTip(affinity.chain) : null; + const fp = matchedFp ?? tip?.fp ?? affinity?.chain.sys.fp ?? null; + + if (!affinity || !fp) { + return { + cacheCompatibilityKey: null, + cacheScoreEligible: false, + cacheScoreExcludedReason: CACHE_SCORE_EXCLUDED.noAffinityKey, + theoreticalCacheTokens: null, + cacheTtlBucket: null, + }; + } + + const key = `${affinity.scopeTag}:${fp}`; + const theoreticalCacheTokens = tip ? Math.floor(tip.prefixBytes / BYTES_PER_TOKEN) : null; + const cacheTtlBucket = input.cacheTtl && input.cacheTtl.length > 0 ? input.cacheTtl : "5m"; + + const base = { + cacheCompatibilityKey: key, + theoreticalCacheTokens, + cacheTtlBucket, + }; + + if (!input.succeeded) { + return { + ...base, + cacheScoreEligible: false, + cacheScoreExcludedReason: CACHE_SCORE_EXCLUDED.attemptFailed, + }; + } + if (!input.usageObservable) { + return { + ...base, + cacheScoreEligible: false, + cacheScoreExcludedReason: CACHE_SCORE_EXCLUDED.notObservable, + }; + } + if (input.streamTruncated) { + return { + ...base, + cacheScoreEligible: false, + cacheScoreExcludedReason: CACHE_SCORE_EXCLUDED.streamTruncated, + }; + } + return { ...base, cacheScoreEligible: true, cacheScoreExcludedReason: null }; +} diff --git a/src/lib/cache-effectiveness/service.ts b/src/lib/cache-effectiveness/service.ts new file mode 100644 index 000000000..434336b18 --- /dev/null +++ b/src/lib/cache-effectiveness/service.ts @@ -0,0 +1,161 @@ +import "server-only"; + +import { sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { logger } from "@/lib/logger"; + +/** + * F3b 缓存效果窗口聚合(仿 ledger-backfill:事务级 advisory lock 防多副本重复跑)。 + * + * 按 {最终 providerId, model, cacheTtlBucket} 把 message_request 上的缓存模拟列 + * 聚合成 provider_cache_effectiveness 历史行。定点整数(万分比 bp),禁浮点: + * + * rawBp = clamp(observed * 10000 / theoretical, 0, 10000) + * sampleFactorBp = eligible>=100 -> 10000 | >=30 -> 6000 | >=5 -> 3000 | 否则 1000 + * observableBp = eligible * 10000 / sample + * confidenceBp = observableBp * sampleFactorBp / 10000 + * effectivenessBp= rawBp * confidenceBp / 10000 + * + * 仅指标展示:结果不参与路由排序、不调价格系数(获批计划明确约束)。 + */ + +const LOCK_KEY = 20260722; +/** + * 终态迟到缓冲:窗口终点留 15 分钟余量,避免统计到未完成结算的行。 + * message_request.updated_at 无 $onUpdate 自动更新语义,只能按 created_at 过滤; + * 超过 15 分钟才终态的流仍会漏计,展示级指标可接受。 + */ +const WINDOW_SAFETY_LAG_MS = 15 * 60 * 1000; +/** 首次运行回看窗口 */ +const INITIAL_LOOKBACK_MS = 60 * 60 * 1000; + +export interface CacheEffectivenessSummary { + windowStart: Date | null; + windowEnd: Date | null; + groupsWritten: number; + durationMs: number; + skipped: boolean; +} + +export async function aggregateCacheEffectiveness( + signal?: AbortSignal +): Promise { + const startTime = Date.now(); + signal?.throwIfAborted(); + + return await db.transaction(async (tx) => { + signal?.throwIfAborted(); + const lockResult = await tx.execute(sql` + SELECT pg_try_advisory_xact_lock(${LOCK_KEY}) AS acquired + `); + const acquired = (lockResult as unknown as Array<{ acquired: boolean }>)[0]?.acquired; + if (!acquired) { + return { + windowStart: null, + windowEnd: null, + groupsWritten: 0, + durationMs: Date.now() - startTime, + skipped: true, + }; + } + + const windowEnd = new Date(Date.now() - WINDOW_SAFETY_LAG_MS); + const lastWindowResult = await tx.execute(sql` + SELECT MAX(window_end) AS last_end FROM provider_cache_effectiveness + `); + const lastEndRaw = (lastWindowResult as unknown as Array<{ last_end: string | Date | null }>)[0] + ?.last_end; + const windowStart = lastEndRaw + ? new Date(lastEndRaw) + : new Date(Date.now() - INITIAL_LOOKBACK_MS); + + if (windowStart >= windowEnd) { + return { + windowStart, + windowEnd, + groupsWritten: 0, + durationMs: Date.now() - startTime, + skipped: true, + }; + } + + signal?.throwIfAborted(); + // 单条 SQL 完成分组聚合 + 定点数学 + 写入(全整数运算) + const inserted = await tx.execute(sql` + WITH grouped AS ( + SELECT + mr.provider_id, + COALESCE(mr.model, '') AS model, + COALESCE(mr.cache_ttl_bucket, '5m') AS cache_ttl_bucket, + COUNT(*)::bigint AS sample_count, + COUNT(*) FILTER (WHERE mr.cache_score_eligible)::bigint AS eligible_count, + COALESCE(SUM(mr.theoretical_cache_tokens) FILTER (WHERE mr.cache_score_eligible), 0)::bigint AS theoretical_tokens, + COALESCE(SUM(mr.cache_read_input_tokens) FILTER (WHERE mr.cache_score_eligible), 0)::bigint AS observed_tokens + FROM message_request mr + WHERE mr.cache_compatibility_key IS NOT NULL + AND mr.deleted_at IS NULL + AND mr.provider_id > 0 + AND mr.created_at >= ${windowStart} + AND mr.created_at < ${windowEnd} + GROUP BY mr.provider_id, COALESCE(mr.model, ''), COALESCE(mr.cache_ttl_bucket, '5m') + ), + scored AS ( + SELECT + g.*, + CASE + WHEN g.theoretical_tokens > 0 + THEN LEAST((g.observed_tokens * 10000) / g.theoretical_tokens, 10000)::int + ELSE 0 + END AS raw_bp, + CASE + WHEN g.eligible_count >= 100 THEN 10000 + WHEN g.eligible_count >= 30 THEN 6000 + WHEN g.eligible_count >= 5 THEN 3000 + ELSE 1000 + END AS sample_factor_bp, + CASE + WHEN g.sample_count > 0 + THEN ((g.eligible_count * 10000) / g.sample_count)::int + ELSE 0 + END AS observable_bp + FROM grouped g + ) + INSERT INTO provider_cache_effectiveness ( + provider_id, model, cache_ttl_bucket, window_start, window_end, + sample_count, eligible_count, theoretical_cache_tokens, observed_cache_read_tokens, + raw_effectiveness_bp, confidence_bp, effectiveness_bp + ) + SELECT + s.provider_id, + s.model, + s.cache_ttl_bucket, + ${windowStart}, + ${windowEnd}, + s.sample_count, + s.eligible_count, + s.theoretical_tokens, + s.observed_tokens, + s.raw_bp, + ((s.observable_bp * s.sample_factor_bp) / 10000)::int, + ((s.raw_bp * ((s.observable_bp * s.sample_factor_bp) / 10000)) / 10000)::int + FROM scored s + RETURNING id + `); + + const groupsWritten = Array.isArray(inserted) ? inserted.length : 0; + if (groupsWritten > 0) { + logger.info("[CacheEffectiveness] window aggregated", { + windowStart: windowStart.toISOString(), + windowEnd: windowEnd.toISOString(), + groupsWritten, + }); + } + return { + windowStart, + windowEnd, + groupsWritten, + durationMs: Date.now() - startTime, + skipped: false, + }; + }); +} diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index 29c66acca..a7088249c 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -183,9 +183,44 @@ export const EnvSchema = z.object({ // 超时后主动断开该输家连接,仅用已收到的内容尝试计费(通常计不出 -> 跳过)。 HEDGE_LOSER_DRAIN_TIMEOUT_MS: z.coerce.number().int().min(1000).default(120_000), - // Operational canary for the Discovery scheduler. The database feature - // switch remains authoritative; this percentage only narrows eligibility. - DISCOVERY_ROLLOUT_PERCENT: z.coerce.number().int().min(0).max(100).default(100), + // ===== CCHP 网关移植功能开关 ===== + // 流式内容门控:off=关闭;shadow=旁路分类只记录分歧;enforce=首个有效内容帧前缓冲+failover + STREAM_GATE_MODE: z.enum(["off", "shadow", "enforce"]).default("off"), + // 门控 precommit 缓冲上限:超限即视为该供应商流异常,failover 释放内存 + STREAM_GATE_PREBUFFER_EVENT_CAP: z.coerce.number().int().min(1).max(4096).default(64), + STREAM_GATE_PREBUFFER_BYTE_CAP: z.coerce + .number() + .int() + .min(1024) + .max(16 * 1024 * 1024) + .default(256 * 1024), + // 请求分离 + Replay:客户端断开后上游继续引流缓存,相同请求体重发续传 + ENABLE_REQUEST_REPLAY: z.string().default("false").transform(booleanTransform), + // owner 客户端仍在线时的并发相同请求去重(attached-live);关闭后仅 detached/completed 可命中 + REPLAY_LIVE_DEDUP_ENABLED: z.string().default("true").transform(booleanTransform), + // 客户端断开后上游继续引流的最长时长(毫秒;替代默认 60s drain 上限) + REPLAY_MAX_DETACHED_MS: z.coerce.number().int().min(10_000).max(1_800_000).default(300_000), + // 单节点并发 spool 上限(超出的请求不做 replay,回退现状) + REPLAY_MAX_CONCURRENT_SPOOLS: z.coerce.number().int().min(1).max(1024).default(64), + // Redis 热层 TTL(活跃/刚完成的响应块与元数据) + REPLAY_TTL_SECONDS: z.coerce.number().int().min(60).max(7200).default(600), + // PG 完成持久层 TTL(跨小时级重放窗口) + REPLAY_COMPLETED_TTL_SECONDS: z.coerce.number().int().min(300).max(86400).default(3600), + // 单响应缓存上限(超限即放弃 spool,fail-open 回现状) + REPLAY_MAX_PAYLOAD_BYTES: z.coerce + .number() + .int() + .min(64 * 1024) + .max(64 * 1024 * 1024) + .default(8 * 1024 * 1024), + // 最长前缀亲和路由:链式指纹匹配的供应商粘性(软提名,仍走全套硬校验) + ENABLE_PREFIX_AFFINITY: z.string().default("false").transform(booleanTransform), + // 亲和绑定滑动 TTL(秒):读即续期,目标是把供应商粘性拉长到接近 prompt cache 保留期 + PREFIX_AFFINITY_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(3600), + // 指纹链回看窗口(尾部边界数):覆盖编辑回退场景的拐点,超过 8 收益递减 + PREFIX_AFFINITY_WINDOW: z.coerce.number().int().min(1).max(64).default(8), + // 缓存效果计费模拟:理论 vs 实际缓存命中率聚合指标(仅展示,不影响路由,默认开启) + ENABLE_CACHE_EFFECTIVENESS: z.string().default("true").transform(booleanTransform), DASHBOARD_LOGS_POLL_INTERVAL_MS: z.coerce.number().int().min(250).max(60000).default(5000), diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 4707b2b76..b090337c4 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -83,6 +83,8 @@ export const DEFAULT_SETTINGS: Pick< | "passThroughUpstreamErrorMessage" | "publicStatusWindowHours" | "publicStatusAggregationIntervalMinutes" + | "streamGateMode" + | "affinityIgnoreClientSessionId" | "discoveryEnabled" | "discoveryConcurrency" | "maxDiscoveryRounds" @@ -120,6 +122,8 @@ export const DEFAULT_SETTINGS: Pick< }, publicStatusWindowHours: 24, publicStatusAggregationIntervalMinutes: 5, + streamGateMode: "enforce", + affinityIgnoreClientSessionId: true, discoveryEnabled: false, discoveryConcurrency: 2, maxDiscoveryRounds: 2, @@ -209,6 +213,8 @@ export async function getCachedSystemSettings(): Promise { publicStatusWindowHours: DEFAULT_SETTINGS.publicStatusWindowHours, publicStatusAggregationIntervalMinutes: DEFAULT_SETTINGS.publicStatusAggregationIntervalMinutes, + streamGateMode: DEFAULT_SETTINGS.streamGateMode, + affinityIgnoreClientSessionId: DEFAULT_SETTINGS.affinityIgnoreClientSessionId, discoveryEnabled: DEFAULT_SETTINGS.discoveryEnabled, discoveryConcurrency: DEFAULT_SETTINGS.discoveryConcurrency, maxDiscoveryRounds: DEFAULT_SETTINGS.maxDiscoveryRounds, diff --git a/src/lib/provider-endpoints/leader-lock.ts b/src/lib/provider-endpoints/leader-lock.ts index 2bb1cbdbb..d9691de9c 100644 --- a/src/lib/provider-endpoints/leader-lock.ts +++ b/src/lib/provider-endpoints/leader-lock.ts @@ -81,7 +81,7 @@ export async function renewLeaderLock(lock: LeaderLock, ttlMs: number): Promise< return true; } - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return false; } @@ -118,7 +118,7 @@ export async function releaseLeaderLock(lock: LeaderLock): Promise { } const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return; } diff --git a/src/lib/public-status/scheduler.ts b/src/lib/public-status/scheduler.ts index 6709dc268..a4f0d1050 100644 --- a/src/lib/public-status/scheduler.ts +++ b/src/lib/public-status/scheduler.ts @@ -66,7 +66,7 @@ async function collectTargets(): Promise< Array<{ intervalMinutes: number; rangeHours: number; hintKey?: string }> > { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return []; } @@ -144,7 +144,7 @@ async function runCycle(): Promise { try { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("[PublicStatusScheduler] Redis not ready, skipping cycle"); return; } diff --git a/src/lib/rate-limit/lease-service.ts b/src/lib/rate-limit/lease-service.ts index 99ba9a29f..221b74d31 100644 --- a/src/lib/rate-limit/lease-service.ts +++ b/src/lib/rate-limit/lease-service.ts @@ -128,7 +128,7 @@ export class LeaseService { entityId: number ): Promise<{ currentUsage: number; windowResetAtMs: number | null }> { const redis = LeaseService.redis; - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { throw new Error("Redis not ready for fixed 5h lease refresh"); } @@ -671,7 +671,7 @@ export class LeaseService { const redis = LeaseService.redis; // Fail-open if Redis is not ready - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("[LeaseService] Redis not ready, fail-open for decrement", { entityType, entityId, diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index e1927c308..0333df5bc 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -160,7 +160,7 @@ export class RateLimitService { id: number ): Promise<{ current: number; resetAt: Date | null; exists: boolean }> { const redis = RateLimitService.redis; - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return { current: 0, resetAt: null, exists: false }; } @@ -254,7 +254,7 @@ export class RateLimitService { entries: Array<{ id: number; createdAt: Date; costUsd: number }>, ttlSeconds: number ): Promise { - if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") return; + if (RateLimitService.redis?.status !== "ready") return; if (entries.length === 0) return; const pipeline = RateLimitService.redis.pipeline(); @@ -786,7 +786,7 @@ export class RateLimitService { return { allowed: true, keyCount: 0, userCount: 0, trackedKey: false, trackedUser: false }; } - if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") { + if (RateLimitService.redis?.status !== "ready") { logger.warn("[RateLimit] Redis not ready, Fail Open"); return { allowed: true, keyCount: 0, userCount: 0, trackedKey: false, trackedUser: false }; } @@ -867,7 +867,7 @@ export class RateLimitService { return { allowed: true, count: 0, tracked: false, referenced: false }; } - if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") { + if (RateLimitService.redis?.status !== "ready") { logger.warn("[RateLimit] Redis not ready, Fail Open"); return { allowed: true, count: 0, tracked: false, referenced: false }; } @@ -925,7 +925,7 @@ export class RateLimitService { } const redis = RateLimitService.redis; - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return; } @@ -979,7 +979,7 @@ export class RateLimitService { } ): Promise { const redis = RateLimitService.redis; - if (!redis || redis.status !== "ready" || cost <= 0) return; + if (redis?.status !== "ready" || cost <= 0) return; try { const keyDailyReset = RateLimitService.resolveDailyReset(options?.keyResetTime); @@ -1577,7 +1577,7 @@ export class RateLimitService { options?: { requestId?: string | number; createdAtMs?: number } ): Promise { const redis = RateLimitService.redis; - if (!redis || redis.status !== "ready" || cost <= 0) return; + if (redis?.status !== "ready" || cost <= 0) return; const mode = resetMode ?? "fixed"; const normalizedResetTime = normalizeResetTime(resetTime); @@ -1649,7 +1649,7 @@ export class RateLimitService { } // Redis 不可用时返回默认值 - if (!RateLimitService.redis || RateLimitService.redis.status !== "ready") { + if (RateLimitService.redis?.status !== "ready") { logger.warn("[RateLimit] Redis unavailable for batch cost query, returning zeros"); return result; } diff --git a/src/lib/redis/cost-cache-cleanup.ts b/src/lib/redis/cost-cache-cleanup.ts index d86c75463..c0e384557 100644 --- a/src/lib/redis/cost-cache-cleanup.ts +++ b/src/lib/redis/cost-cache-cleanup.ts @@ -55,7 +55,7 @@ export async function clearUserCostCache( const { userId, keyIds, keyHashes, includeActiveSessions = false } = options; const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return null; } @@ -192,7 +192,7 @@ export async function clearUser5hCostCache( const { userId, resetMode } = options; const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return null; } @@ -249,7 +249,7 @@ export async function clearSingleKeyCostCache( const { keyId, keyHash } = options; const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return null; } @@ -334,7 +334,7 @@ export async function clearSingleProviderCostCache( const { providerId } = options; const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return null; } diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 05c4c06c8..b3f6c0280 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -63,6 +63,12 @@ export interface LeaderboardFilters { includeModelStats?: boolean; } +/** + * 缓存值 shape 版本:条目结构变更时递增,避免 60s TTL 内新旧 payload 混用。 + * v2: provider / providerCacheHitRate 条目新增 cacheCoefficientBp + */ +const CACHE_SHAPE_VERSION = "v2"; + /** * 构建缓存键 * @param timezone - 已解析的系统时区(调用者应使用 resolveSystemTimezone() 获取) @@ -94,24 +100,26 @@ function buildCacheKey( userFilterSuffix = tagsPart + groupsPart; } + const prefix = `leaderboard:${CACHE_SHAPE_VERSION}:${scope}`; + if (period === "custom" && dateRange) { - // leaderboard:{scope}:custom:2025-01-01_2025-01-15:USD - return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + // leaderboard:v2:{scope}:custom:2025-01-01_2025-01-15:USD + return `${prefix}:custom:${dateRange.startDate}_${dateRange.endDate}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "daily") { - // leaderboard:{scope}:daily:2025-01-15:USD + // leaderboard:v2:{scope}:daily:2025-01-15:USD const dateStr = formatInTimeZone(now, timezone, "yyyy-MM-dd"); - return `leaderboard:${scope}:daily:${dateStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `${prefix}:daily:${dateStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "weekly") { - // leaderboard:{scope}:weekly:2025-W03:USD (ISO week) + // leaderboard:v2:{scope}:weekly:2025-W03:USD (ISO week) const weekStr = formatInTimeZone(now, timezone, "yyyy-'W'ww"); - return `leaderboard:${scope}:weekly:${weekStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `${prefix}:weekly:${weekStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "monthly") { - // leaderboard:{scope}:monthly:2025-01:USD + // leaderboard:v2:{scope}:monthly:2025-01:USD const monthStr = formatInTimeZone(now, timezone, "yyyy-MM"); - return `leaderboard:${scope}:monthly:${monthStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + return `${prefix}:monthly:${monthStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else { - // allTime: leaderboard:{scope}:allTime:USD (no date component) - return `leaderboard:${scope}:allTime:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; + // allTime: leaderboard:v2:{scope}:allTime:USD (no date component) + return `${prefix}:allTime:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } } diff --git a/src/lib/redis/redis-kv-store.ts b/src/lib/redis/redis-kv-store.ts index bd3787d80..7efc07fff 100644 --- a/src/lib/redis/redis-kv-store.ts +++ b/src/lib/redis/redis-kv-store.ts @@ -46,7 +46,7 @@ export class RedisKVStore { private getReadyRedis(): RedisKVClient | null { const redis = this.resolveRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return null; } return redis; diff --git a/src/lib/redis/redis-list-store.ts b/src/lib/redis/redis-list-store.ts new file mode 100644 index 000000000..a7aa7fbf4 --- /dev/null +++ b/src/lib/redis/redis-list-store.ts @@ -0,0 +1,145 @@ +import "server-only"; + +import type Redis from "ioredis"; +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis/client"; + +type RedisListClient = Pick & { + eval(...args: [script: string, numkeys: number, ...rest: (string | number)[]]): Promise; +}; + +/** RPUSH 全部值并(ttl>0 时)续期,单条脚本原子执行;返回追加后的列表长度。 */ +const LUA_RPUSH_EXPIRE = ` +local len = redis.call('RPUSH', KEYS[1], unpack(ARGV, 2)) +if tonumber(ARGV[1]) > 0 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return len`; + +export interface RedisListStoreOptions { + prefix: string; + redisClient?: RedisListClient | null; +} + +function toLogError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Redis LIST 封装(仿 RedisKVStore 的 null-guarded fail-open 约定): + * Redis 不可用或出错时返回 null/false,调用方按功能降级处理。 + * + * 用于 Replay 响应块热层:owner 批量 RPUSH,attach 读者 LRANGE 跟尾。 + */ +export class RedisListStore { + private readonly prefix: string; + private readonly injectedClient?: RedisListClient | null; + + constructor(options: RedisListStoreOptions) { + this.prefix = options.prefix; + this.injectedClient = options.redisClient; + } + + private resolveRedisClient(): RedisListClient | null { + if (this.injectedClient !== undefined) { + return this.injectedClient; + } + return getRedisClient({ allowWhenRateLimitDisabled: true }) as RedisListClient | null; + } + + private getReadyRedis(): RedisListClient | null { + const redis = this.resolveRedisClient(); + if (redis?.status !== "ready") { + return null; + } + return redis; + } + + private buildKey(key: string): string { + return `${this.prefix}${key}`; + } + + /** + * 批量追加并(可选)续期,单条 Lua 原子执行——追加成功即带 TTL, + * 不存在「RPUSH 成功但 EXPIRE 失败留下永久 key」的窗口;失败返回 null。 + */ + async rpushBatch(key: string, values: string[], ttlSeconds?: number): Promise { + if (values.length === 0) return null; + const redis = this.getReadyRedis(); + if (!redis) return null; + const fullKey = this.buildKey(key); + try { + const ttl = ttlSeconds && ttlSeconds > 0 ? ttlSeconds : 0; + const length = await redis.eval(LUA_RPUSH_EXPIRE, 1, fullKey, ttl, ...values); + return typeof length === "number" ? length : Number(length); + } catch (error) { + logger.error("[RedisListStore] Failed to rpush", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return null; + } + } + + /** 从 start(0-based,含)读到末尾;失败返回 null(与空列表 [] 区分)。 */ + async lrangeFrom(key: string, start: number): Promise { + const redis = this.getReadyRedis(); + if (!redis) return null; + try { + return await redis.lrange(this.buildKey(key), start, -1); + } catch (error) { + logger.error("[RedisListStore] Failed to lrange", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return null; + } + } + + async llen(key: string): Promise { + const redis = this.getReadyRedis(); + if (!redis) return null; + try { + return await redis.llen(this.buildKey(key)); + } catch (error) { + logger.error("[RedisListStore] Failed to llen", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return null; + } + } + + async expire(key: string, ttlSeconds: number): Promise { + const redis = this.getReadyRedis(); + if (!redis) return false; + try { + return (await redis.expire(this.buildKey(key), ttlSeconds)) === 1; + } catch (error) { + logger.error("[RedisListStore] Failed to expire", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return false; + } + } + + async delete(key: string): Promise { + const redis = this.getReadyRedis(); + if (!redis) return false; + try { + return (await redis.del(this.buildKey(key))) > 0; + } catch (error) { + logger.error("[RedisListStore] Failed to delete", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return false; + } + } +} diff --git a/src/lib/redis/session-binding.ts b/src/lib/redis/session-binding.ts index a72ebb322..812c08f9e 100644 --- a/src/lib/redis/session-binding.ts +++ b/src/lib/redis/session-binding.ts @@ -1279,7 +1279,7 @@ export async function mutateLegacySessionBindingSafely( } const redis = currentRedisClient(input.redis); - if (!redis || redis.status !== "ready") return unavailable("redis_not_ready"); + if (redis?.status !== "ready") return unavailable("redis_not_ready"); const keys = buildSessionBindingKeys(input.sessionId, input.keyId); try { diff --git a/src/lib/request-identity.ts b/src/lib/request-identity.ts new file mode 100644 index 000000000..c21d9f407 --- /dev/null +++ b/src/lib/request-identity.ts @@ -0,0 +1,61 @@ +import { createHash } from "node:crypto"; + +/** + * 请求身份原语:Replay 身份推导与前缀亲和 scope 共用的哈希工具。 + * + * 哈希只需系统内部自洽(不与 CCHP 字节级对齐),统一使用 node:crypto sha256, + * 不引入 xxh3 原生依赖。 + */ + +export function sha256Hex(input: string | Uint8Array): string { + return createHash("sha256").update(input).digest("hex"); +} + +/** + * 请求体的规范字节:优先原始 body buffer(逐字节稳定), + * 无 buffer 时对已解析 message 做键序稳定序列化。 + * 注意:Replay 身份已改为直接 stableStringify 过滤后 message(不受原始 buffer 影响), + * 本函数保留为需要「过滤前原始字节」语义场景的通用原语,当前 src 内暂无调用方。 + */ +export function canonicalRequestBytes(request: { + buffer?: ArrayBuffer; + message: Record; +}): Uint8Array { + if (request.buffer && request.buffer.byteLength > 0) { + return new Uint8Array(request.buffer); + } + return new TextEncoder().encode(stableStringify(request.message)); +} + +/** + * 租户隔离 scope 标签:sha256(keyId|format|model) 截 16 hex。 + * 含 keyId,跨租户/跨 key 不可能命中同一 scope。 + */ +export function buildScopeTag( + keyId: number | string, + format: string, + model: string | null | undefined +): string { + return sha256Hex(`${keyId}|${format}|${model ?? ""}`).slice(0, 16); +} + +/** + * 键序稳定的 JSON 序列化(对象键按字典序排序,数组保序)。 + * 用于无原始 buffer 时从解析后 message 派生确定性字节。 + */ +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + const keys = Object.keys(value as Record).sort(); + const parts: string[] = []; + for (const key of keys) { + const child = (value as Record)[key]; + if (child === undefined) continue; + parts.push(`${JSON.stringify(key)}:${stableStringify(child)}`); + } + return `{${parts.join(",")}}`; +} diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index cab4456c9..afa19641d 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -365,7 +365,7 @@ export class SessionManager { */ static async getNextRequestSequence(sessionId: string): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { // 改进的 fallback:使用时间戳 + 随机数生成伪唯一序号 // 避免 Redis 不可用时所有请求都返回 1 导致的冲突 const fallbackSeq = (Date.now() % 1000000) + Math.floor(Math.random() * 1000); @@ -410,7 +410,7 @@ export class SessionManager { */ static async getSessionRequestCount(sessionId: string): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return 0; + if (redis?.status !== "ready") return 0; try { const count = await redis.get(`session:${sessionId}:seq`); @@ -645,7 +645,7 @@ export class SessionManager { keyId: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const binding = await readOrReconcileSessionBinding({ @@ -695,7 +695,7 @@ export class SessionManager { */ private static async refreshSessionTTL(sessionId: string, keyId?: number | null): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const pipeline = redis.pipeline(); @@ -745,7 +745,7 @@ export class SessionManager { ownerToken?: string ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return acquireVersionedSessionDiscoveryLease({ sessionId, keyId, @@ -762,7 +762,7 @@ export class SessionManager { ttlSeconds: number ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return renewVersionedSessionDiscoveryLease({ sessionId, keyId, @@ -778,7 +778,7 @@ export class SessionManager { ownerToken: string ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return releaseVersionedSessionDiscoveryLease({ sessionId, keyId, ownerToken, redis }); } @@ -787,7 +787,7 @@ export class SessionManager { keyId: number ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return readOrReconcileSessionBinding({ sessionId, keyId, @@ -808,7 +808,7 @@ export class SessionManager { snapshot: SessionBindingSnapshot ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return touchSessionBinding({ sessionId: snapshot.sessionId, keyId: snapshot.keyId, @@ -824,7 +824,7 @@ export class SessionManager { providerId: number ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return compareAndSetSessionBinding({ sessionId: snapshot.sessionId, keyId: snapshot.keyId, @@ -841,7 +841,7 @@ export class SessionManager { cooldownTtlSeconds: number = 0 ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return clearVersionedSessionBinding({ sessionId: snapshot.sessionId, keyId: snapshot.keyId, @@ -859,7 +859,7 @@ export class SessionManager { providerId: number ): Promise { const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); - if (!redis || redis.status !== "ready") return redisUnavailableBindingResult(); + if (redis?.status !== "ready") return redisUnavailableBindingResult(); return readSessionProviderCooldown({ sessionId, keyId, @@ -877,7 +877,7 @@ export class SessionManager { keyId?: number | null ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { if (keyId != null) { @@ -959,7 +959,7 @@ export class SessionManager { keyId?: number | null ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { if (keyId != null) { @@ -1029,7 +1029,7 @@ export class SessionManager { keyId?: number | null ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return false; + if (redis?.status !== "ready") return false; try { if (keyId != null) { @@ -1109,7 +1109,7 @@ export class SessionManager { if (providerIds.length === 0 || keyId == null) return false; const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return false; + if (redis?.status !== "ready") return false; const binding = await readOrReconcileSessionBinding({ sessionId, @@ -1156,7 +1156,7 @@ export class SessionManager { keyId?: number | null ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const providerId = await SessionManager.getSessionProvider(sessionId, keyId); @@ -1204,7 +1204,7 @@ export class SessionManager { legacyBindingUpdated?: boolean; }> { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return { updated: false, reason: "redis_not_ready" }; } @@ -1468,7 +1468,7 @@ export class SessionManager { */ static async storeSessionInfo(sessionId: string, info: SessionStoreInfo): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const pipeline = redis.pipeline(); @@ -1503,7 +1503,7 @@ export class SessionManager { providerInfo: SessionProviderInfo ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const pipeline = redis.pipeline(); @@ -1534,7 +1534,7 @@ export class SessionManager { */ static async updateSessionUsage(sessionId: string, usage: SessionUsageUpdate): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const pipeline = redis.pipeline(); @@ -1602,7 +1602,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { // 根据配置决定是否脱敏 @@ -1686,7 +1686,7 @@ export class SessionManager { */ static async getActiveSessions(): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("SessionManager: Redis not ready, returning empty list"); return []; } @@ -1762,7 +1762,7 @@ export class SessionManager { inactive: ActiveSessionInfo[]; }> { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("SessionManager: Redis not ready, returning empty lists"); return { active: [], inactive: [] }; } @@ -1861,7 +1861,7 @@ export class SessionManager { */ static async getAllSessionIds(): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("SessionManager: Redis not ready, returning empty list"); return []; } @@ -1911,7 +1911,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { // 优先尝试新格式 @@ -1948,7 +1948,7 @@ export class SessionManager { */ static async hasAnySessionMessages(sessionId: string): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return false; + if (redis?.status !== "ready") return false; try { // 1. 先检查旧格式(直接 EXISTS 更高效) @@ -2009,7 +2009,7 @@ export class SessionManager { if (!getEnvConfig().STORE_SESSION_RESPONSE_BODY) return; const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { let responseString: string; @@ -2069,7 +2069,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2104,7 +2104,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2136,7 +2136,7 @@ export class SessionManager { } const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2153,7 +2153,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2184,7 +2184,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2204,7 +2204,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2237,7 +2237,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2257,7 +2257,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2290,7 +2290,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2310,7 +2310,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2336,7 +2336,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2359,7 +2359,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2384,7 +2384,7 @@ export class SessionManager { requestSequence?: number ): Promise | null> { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2404,7 +2404,7 @@ export class SessionManager { requestSequence?: number ): Promise | null> { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2431,7 +2431,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { // 优先尝试新格式 @@ -2462,7 +2462,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2542,7 +2542,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2598,7 +2598,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; @@ -2680,7 +2680,7 @@ export class SessionManager { requestSequence?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return null; + if (redis?.status !== "ready") return null; try { const sequence = normalizeRequestSequence(requestSequence); @@ -2774,7 +2774,7 @@ export class SessionManager { keyId?: number | null ): Promise<{ sessionId: string; updated: boolean }> { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.debug("SessionManager: Redis not ready, skipping Codex session update"); return { sessionId: currentSessionId, updated: false }; } @@ -2888,7 +2888,7 @@ export class SessionManager { expectedProviderIds?: readonly number[] ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("SessionManager: Redis not ready, cannot terminate session"); return false; } @@ -3158,7 +3158,7 @@ export class SessionManager { } const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("SessionManager: Redis not ready, cannot terminate provider sessions"); return 0; } @@ -3254,7 +3254,7 @@ export class SessionManager { } const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("SessionManager: Redis not ready, cannot terminate sessions"); return 0; } diff --git a/src/lib/session-tracker.ts b/src/lib/session-tracker.ts index 878d0fc98..dab2e9395 100644 --- a/src/lib/session-tracker.ts +++ b/src/lib/session-tracker.ts @@ -48,7 +48,7 @@ export class SessionTracker { */ static async initialize(): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.warn("SessionTracker: Redis not ready, skipping initialization"); return; } @@ -86,7 +86,7 @@ export class SessionTracker { */ static async trackSession(sessionId: string, keyId: number, userId?: number): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const now = Date.now(); @@ -141,7 +141,7 @@ export class SessionTracker { */ static async updateProvider(sessionId: string, providerId: number): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const now = Date.now(); @@ -197,7 +197,7 @@ export class SessionTracker { userId?: number ): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const now = Date.now(); @@ -295,7 +295,7 @@ export class SessionTracker { */ static async getGlobalSessionCount(): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return 0; + if (redis?.status !== "ready") return 0; try { const key = getGlobalActiveSessionsKey(); @@ -328,7 +328,7 @@ export class SessionTracker { */ static async getKeySessionCount(keyId: number): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return 0; + if (redis?.status !== "ready") return 0; try { const key = getKeyActiveSessionsKey(keyId); @@ -361,7 +361,7 @@ export class SessionTracker { */ static async getProviderSessionCount(providerId: number): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return 0; + if (redis?.status !== "ready") return 0; try { const key = `provider:${providerId}:active_sessions`; @@ -394,7 +394,7 @@ export class SessionTracker { */ static async getUserSessionCount(userId: number): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return 0; + if (redis?.status !== "ready") return 0; try { const key = getUserActiveSessionsKey(userId); @@ -439,7 +439,7 @@ export class SessionTracker { } const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { return result; } @@ -562,7 +562,7 @@ export class SessionTracker { */ static async getActiveSessions(): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return []; + if (redis?.status !== "ready") return []; try { const key = getGlobalActiveSessionsKey(); @@ -608,7 +608,7 @@ export class SessionTracker { */ private static async countFromZSet(key: string): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return 0; + if (redis?.status !== "ready") return 0; try { const now = Date.now(); @@ -665,7 +665,7 @@ export class SessionTracker { */ static async incrementConcurrentCount(sessionId: string): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const key = `session:${sessionId}:concurrent_count`; @@ -687,7 +687,7 @@ export class SessionTracker { */ static async decrementConcurrentCount(sessionId: string): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") return; + if (redis?.status !== "ready") return; try { const key = `session:${sessionId}:concurrent_count`; @@ -719,7 +719,7 @@ export class SessionTracker { } const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { for (const id of sessionIds) { result.set(id, 0); } @@ -770,7 +770,7 @@ export class SessionTracker { */ static async getConcurrentCount(sessionId: string): Promise { const redis = getRedisClient(); - if (!redis || redis.status !== "ready") { + if (redis?.status !== "ready") { logger.trace("SessionTracker: Redis not ready, returning 0 for concurrent count"); return 0; } diff --git a/src/lib/system-settings/proxy-runtime.ts b/src/lib/system-settings/proxy-runtime.ts new file mode 100644 index 000000000..cf9e3879f --- /dev/null +++ b/src/lib/system-settings/proxy-runtime.ts @@ -0,0 +1,56 @@ +import "server-only"; + +import { getEnvConfig } from "@/lib/config/env.schema"; +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; + +/** + * 代理热路径消费的系统设置快照。 + * + * - streamGateMode:F1 流式内容门控模式(系统设置优先,env 兜底;产品默认 enforce) + * - affinityIgnoreClientSessionId:F3a「忽略客户端 Session ID」开关(默认开)—— + * 可指纹化的请求强制使用最长前缀亲和做供应商粘性,跳过 session-ID 绑定读取; + * 不可指纹化的请求仍走既有 session 复用。 + * + * 读取约定:热路径用 getCachedProxyRuntimeSettings()(同步、最近快照), + * 异步场景用 getProxyRuntimeSettings()(带 TTL 缓存)。 + */ +export interface ProxyRuntimeSettings { + streamGateMode: "off" | "shadow" | "enforce"; + affinityIgnoreClientSessionId: boolean; +} + +// 最近一次成功读取的快照;同步热路径消费,异步读取与开机预热负责保鲜。 +let lastKnown: ProxyRuntimeSettings | null = null; + +function envFallback(): ProxyRuntimeSettings { + try { + const env = getEnvConfig(); + return { + streamGateMode: env.STREAM_GATE_MODE, + affinityIgnoreClientSessionId: true, + }; + } catch { + return { streamGateMode: "off", affinityIgnoreClientSessionId: true }; + } +} + +export async function getProxyRuntimeSettings(): Promise { + try { + const settings = await getCachedSystemSettings(); + lastKnown = { + streamGateMode: settings.streamGateMode, + affinityIgnoreClientSessionId: settings.affinityIgnoreClientSessionId, + }; + return lastKnown; + } catch { + // getCachedSystemSettings 自身已 fail-safe;此处兜底其意外异常 + return lastKnown ?? envFallback(); + } +} + +/** + * 同步返回最近快照;尚无快照时返回 null,调用方自行 env 兜底。 + */ +export function getCachedProxyRuntimeSettings(): ProxyRuntimeSettings | null { + return lastKnown; +} diff --git a/src/lib/validation/discovery-settings.ts b/src/lib/validation/discovery-settings.ts index f7f1da63d..d7f69831c 100644 --- a/src/lib/validation/discovery-settings.ts +++ b/src/lib/validation/discovery-settings.ts @@ -13,9 +13,7 @@ export const DISCOVERY_FIELD_LIMITS = { export type DiscoverySettingField = keyof typeof DISCOVERY_FIELD_LIMITS; export function isDiscoverySettingField(value: unknown): value is DiscoverySettingField { - return ( - typeof value === "string" && Object.prototype.hasOwnProperty.call(DISCOVERY_FIELD_LIMITS, value) - ); + return typeof value === "string" && Object.hasOwn(DISCOVERY_FIELD_LIMITS, value); } export function getDiscoveryValidationErrorCode( diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 0b5ccf793..6617cb1f0 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -1112,6 +1112,12 @@ export const UpdateSystemSettingsSchema = z } }) .optional(), + // F1 流式内容门控模式(可选) + streamGateMode: z + .enum(["off", "shadow", "enforce"], { message: "不支持的流式门控模式" }) + .optional(), + // 忽略客户端 Session ID(可选) + affinityIgnoreClientSessionId: z.boolean().optional(), // Codex Session ID 补全(可选) enableCodexSessionIdCompletion: z.boolean().optional(), // Claude metadata.user_id 注入(可选) diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index b91beb78f..5dfbf830b 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -309,6 +309,13 @@ export function toSystemSettings(dbSettings: any): SystemSettings { stickyTimeoutCooldownMs: dbSettings?.stickyTimeoutCooldownMs ?? 300_000, ipExtractionConfig: dbSettings?.ipExtractionConfig ?? null, ipGeoLookupEnabled: dbSettings?.ipGeoLookupEnabled ?? true, + streamGateMode: + dbSettings?.streamGateMode === "off" || + dbSettings?.streamGateMode === "shadow" || + dbSettings?.streamGateMode === "enforce" + ? dbSettings.streamGateMode + : "enforce", + affinityIgnoreClientSessionId: dbSettings?.affinityIgnoreClientSessionId ?? true, createdAt: dbSettings?.createdAt ? new Date(dbSettings.createdAt) : new Date(), updatedAt: dbSettings?.updatedAt ? new Date(dbSettings.updatedAt) : new Date(), }; diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 370010496..66fa27d18 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -11,6 +11,10 @@ import { LEDGER_SUCCESS_RATE_COUNTABLE_CONDITION, LEDGER_SUCCESS_RATE_SUCCESS_CONDITION, } from "./_shared/ledger-conditions"; +import { + getProviderCacheCoefficients, + resolveLeaderboardWindow, +} from "./provider-cache-effectiveness"; import { getSystemSettings } from "./system-config"; const clampRatio01 = (value: number | null | undefined) => Math.min(Math.max(value ?? 0, 0), 1); @@ -68,6 +72,8 @@ export interface ProviderLeaderboardEntry { avgTokensPerSecond: number; // tok/s(仅统计流式且可计算的请求) avgCostPerRequest: number | null; // totalCost / totalRequests, null when totalRequests === 0 avgCostPerMillionTokens: number | null; // totalCost * 1_000_000 / totalTokens, null when totalTokens === 0 + /** F3b 缓存系数(万分比定点值,effectivenessBp 汇总口径);周期内无聚合数据时为 null */ + cacheCoefficientBp: number | null; /** * 可选:按模型拆分 * - undefined: 未请求 includeModelStats @@ -122,6 +128,8 @@ export interface ProviderCacheHitRateLeaderboardEntry { /** @deprecated Use totalInputTokens instead */ totalTokens: number; cacheHitRate: number; // 0-1 之间的小数,UI 层负责格式化为百分比 + /** F3b 缓存系数(万分比定点值,effectivenessBp 汇总口径);周期内无聚合数据时为 null */ + cacheCoefficientBp: number | null; modelStats: ModelCacheHitStat[]; } @@ -698,6 +706,11 @@ async function findProviderLeaderboardWithTimezone( .groupBy(usageLedger.finalProviderId, providers.name) .orderBy(desc(sql`COALESCE(sum(${usageLedger.costUsd}), 0)`)); + // F3b 缓存系数合并(只加列不改序:用量榜保持 cost DESC) + const cacheCoefficients = await getProviderCacheCoefficients( + resolveLeaderboardWindow(period, timezone, dateRange) + ); + const baseEntries: ProviderLeaderboardEntry[] = rankings.map((entry) => { const totalCost = parseFloat(entry.totalCost); const totalRequests = entry.totalRequests; @@ -712,6 +725,7 @@ async function findProviderLeaderboardWithTimezone( successRate: clampRatio01Nullable(entry.successRate), avgTtfbMs: entry.avgTtfbMs ?? 0, avgTokensPerSecond: entry.avgTokensPerSecond ?? 0, + cacheCoefficientBp: cacheCoefficients.get(entry.providerId)?.coefficientBp ?? null, ...avgCosts, }; }); @@ -846,6 +860,11 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( .groupBy(usageLedger.finalProviderId, providers.name) .orderBy(desc(cacheHitRateExpr), desc(sql`count(*)`)); + // F3b 缓存系数合并(合并后做最终排序) + const cacheCoefficients = await getProviderCacheCoefficients( + resolveLeaderboardWindow(period, timezone, dateRange) + ); + // Model-level cache hit breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; @@ -897,7 +916,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( modelStatsByProvider.set(row.providerId, stats); } - return rankings.map((entry) => ({ + const entries: ProviderCacheHitRateLeaderboardEntry[] = rankings.map((entry) => ({ providerId: entry.providerId, providerName: entry.providerName, totalRequests: entry.totalRequests, @@ -907,8 +926,20 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( totalInputTokens: entry.totalInputTokens, totalTokens: entry.totalInputTokens, // deprecated, for backward compatibility cacheHitRate: clampRatio01(entry.cacheHitRate), + cacheCoefficientBp: cacheCoefficients.get(entry.providerId)?.coefficientBp ?? null, modelStats: modelStatsByProvider.get(entry.providerId) ?? [], })); + + // 默认排序:缓存系数 DESC(无数据排最后),并列再按缓存命中率 DESC + entries.sort((a, b) => { + if (a.cacheCoefficientBp !== b.cacheCoefficientBp) { + if (a.cacheCoefficientBp == null) return 1; + if (b.cacheCoefficientBp == null) return -1; + return b.cacheCoefficientBp - a.cacheCoefficientBp; + } + return b.cacheHitRate - a.cacheHitRate; + }); + return entries; } /** diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 3e3d5ad0e..e16602b5d 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -40,6 +40,12 @@ export type MessageRequestUpdatePatch = { * null = clear the column explicitly. */ costBreakdown?: StoredCostBreakdown | null; + // F3b 缓存效果计费模拟(可空列) + cacheCompatibilityKey?: string | null; + cacheScoreEligible?: boolean | null; + cacheScoreExcludedReason?: string | null; + theoreticalCacheTokens?: number | null; + cacheTtlBucket?: string | null; }; export type MessageRequestUpdateRecord = { @@ -279,6 +285,11 @@ const COLUMN_MAP: Record = { swapCacheTtlApplied: "swap_cache_ttl_applied", specialSettings: "special_settings", costBreakdown: "cost_breakdown", + cacheCompatibilityKey: "cache_compatibility_key", + cacheScoreEligible: "cache_score_eligible", + cacheScoreExcludedReason: "cache_score_excluded_reason", + theoreticalCacheTokens: "theoretical_cache_tokens", + cacheTtlBucket: "cache_ttl_bucket", }; function loadWriterConfig(): WriterConfig { diff --git a/src/repository/message.ts b/src/repository/message.ts index 25483e3cc..41f0acbd3 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -510,6 +510,12 @@ export type MessageRequestDetailsUpdate = { context1mApplied?: boolean; // 是否应用了1M上下文窗口 swapCacheTtlApplied?: boolean; // Swap Cache TTL Billing active at request time specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) + // F3b 缓存效果计费模拟(可空列,仅指标聚合使用) + cacheCompatibilityKey?: string | null; + cacheScoreEligible?: boolean | null; + cacheScoreExcludedReason?: string | null; + theoreticalCacheTokens?: number | null; + cacheTtlBucket?: string | null; }; /** @@ -598,6 +604,21 @@ export async function updateMessageRequestDetails( if (details.specialSettings !== undefined) { updateData.specialSettings = details.specialSettings; } + if (details.cacheCompatibilityKey !== undefined) { + updateData.cacheCompatibilityKey = details.cacheCompatibilityKey; + } + if (details.cacheScoreEligible !== undefined) { + updateData.cacheScoreEligible = details.cacheScoreEligible; + } + if (details.cacheScoreExcludedReason !== undefined) { + updateData.cacheScoreExcludedReason = details.cacheScoreExcludedReason; + } + if (details.theoreticalCacheTokens !== undefined) { + updateData.theoreticalCacheTokens = details.theoreticalCacheTokens; + } + if (details.cacheTtlBucket !== undefined) { + updateData.cacheTtlBucket = details.cacheTtlBucket; + } if (options.onlyIfUnfinalized) { const terminalDb = diff --git a/src/repository/provider-cache-effectiveness.ts b/src/repository/provider-cache-effectiveness.ts new file mode 100644 index 000000000..9c6cfc001 --- /dev/null +++ b/src/repository/provider-cache-effectiveness.ts @@ -0,0 +1,166 @@ +import "server-only"; + +import { addDays, addMonths, addWeeks, startOfDay, startOfISOWeek, startOfMonth } from "date-fns"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; +import { and, desc, eq, gt, lte, sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { providerCacheEffectiveness } from "@/drizzle/schema"; +import type { ProviderCacheEffectivenessWindow } from "@/types/provider-cache-effectiveness"; +import type { DateRangeParams, LeaderboardPeriod } from "./leaderboard"; + +export interface ListProviderCacheEffectivenessOptions { + providerId?: number; + limit?: number; +} + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +export async function listProviderCacheEffectivenessWindows( + options: ListProviderCacheEffectivenessOptions = {} +): Promise { + const limit = Math.min(Math.max(options.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + const rows = await db + .select() + .from(providerCacheEffectiveness) + .where( + options.providerId === undefined + ? undefined + : eq(providerCacheEffectiveness.providerId, options.providerId) + ) + // 窗口首尾单调等价;用 windowStart 吻合既有索引 (provider_id, model, window_start DESC) + .orderBy(desc(providerCacheEffectiveness.windowStart), desc(providerCacheEffectiveness.id)) + .limit(limit); + return rows; +} + +/** + * 供应商缓存系数(排行榜展示用):跨 model/TTL 桶汇总后按 service.ts 同一套定点公式重算。 + */ +export interface ProviderCacheCoefficient { + providerId: number; + /** 万分比定点值:实际 x 理论综合的归一化缓存效果分 */ + coefficientBp: number; + sampleCount: number; +} + +// tsconfig target ES2017 禁 BigInt 字面量,统一用 BigInt() 构造 +const BIG_ZERO = BigInt(0); +const BP_SCALE = BigInt(10000); + +/** 在汇总值上重算 effectivenessBp(与 service.ts 单窗口 SQL 公式一致,全 BigInt 整数运算) */ +function computeCoefficientBp( + sample: bigint, + eligible: bigint, + theoretical: bigint, + observed: bigint +): number { + let rawBp = theoretical > BIG_ZERO ? (observed * BP_SCALE) / theoretical : BIG_ZERO; + if (rawBp > BP_SCALE) rawBp = BP_SCALE; + const sampleFactorBp = + eligible >= BigInt(100) + ? BigInt(10000) + : eligible >= BigInt(30) + ? BigInt(6000) + : eligible >= BigInt(5) + ? BigInt(3000) + : BigInt(1000); + const observableBp = sample > BIG_ZERO ? (eligible * BP_SCALE) / sample : BIG_ZERO; + const confidenceBp = (observableBp * sampleFactorBp) / BP_SCALE; + return Number((rawBp * confidenceBp) / BP_SCALE); +} + +/** + * 把排行榜周期解析成 [start, end] 时间窗(语义对齐 leaderboard.ts 的 buildDateCondition)。 + * daily/weekly/monthly 按系统时区取当期边界;custom 用 dateRange;allTime 从 epoch 起。 + */ +export function resolveLeaderboardWindow( + period: LeaderboardPeriod, + timezone: string, + dateRange?: DateRangeParams +): { start: Date; end: Date } { + const now = new Date(); + + if (period === "custom" && dateRange) { + const endExclusive = addDays(new Date(`${dateRange.endDate}T00:00:00Z`), 1); + return { + start: fromZonedTime(`${dateRange.startDate}T00:00:00`, timezone), + end: fromZonedTime(`${endExclusive.toISOString().slice(0, 10)}T00:00:00`, timezone), + }; + } + + switch (period) { + case "daily": { + const localStart = startOfDay(toZonedTime(now, timezone)); + return { + start: fromZonedTime(localStart, timezone), + end: fromZonedTime(addDays(localStart, 1), timezone), + }; + } + case "weekly": { + // DATE_TRUNC('week') 为 ISO 周(周一起始) + const localStart = startOfISOWeek(toZonedTime(now, timezone)); + return { + start: fromZonedTime(localStart, timezone), + end: fromZonedTime(addWeeks(localStart, 1), timezone), + }; + } + case "monthly": { + const localStart = startOfMonth(toZonedTime(now, timezone)); + return { + start: fromZonedTime(localStart, timezone), + end: fromZonedTime(addMonths(localStart, 1), timezone), + }; + } + case "last24h": + return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: now }; + default: + // allTime 及缺 dateRange 的 custom(对齐 buildDateCondition 的 1=1 兜底) + return { start: new Date(0), end: now }; + } +} + +/** + * 聚合 windowEnd 落在 (start, end] 内的缓存效果窗口,按 provider 求缓存系数。 + * 无数据的 provider 不出现在结果里。 + */ +export async function getProviderCacheCoefficients({ + start, + end, +}: { + start: Date; + end: Date; +}): Promise> { + const rows = await db + .select({ + providerId: providerCacheEffectiveness.providerId, + sampleCount: sql`COALESCE(sum(${providerCacheEffectiveness.sampleCount}), 0)::bigint`, + eligibleCount: sql`COALESCE(sum(${providerCacheEffectiveness.eligibleCount}), 0)::bigint`, + theoreticalCacheTokens: sql`COALESCE(sum(${providerCacheEffectiveness.theoreticalCacheTokens}), 0)::bigint`, + observedCacheReadTokens: sql`COALESCE(sum(${providerCacheEffectiveness.observedCacheReadTokens}), 0)::bigint`, + }) + .from(providerCacheEffectiveness) + .where( + and( + gt(providerCacheEffectiveness.windowEnd, start), + lte(providerCacheEffectiveness.windowEnd, end) + ) + ) + .groupBy(providerCacheEffectiveness.providerId); + + const coefficients = new Map(); + for (const row of rows) { + const sample = BigInt(row.sampleCount); + coefficients.set(row.providerId, { + providerId: row.providerId, + coefficientBp: computeCoefficientBp( + sample, + BigInt(row.eligibleCount), + BigInt(row.theoreticalCacheTokens), + BigInt(row.observedCacheReadTokens) + ), + sampleCount: Number(sample), + }); + } + return coefficients; +} diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 7eb2f4fec..2901e372f 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -1855,8 +1855,7 @@ export async function findProviderBatchUndoOperation(input: { return { status: "conflict" }; } if ( - !operation || - operation.status !== "applied" || + operation?.status !== "applied" || operation.undoConsumedAt !== null || operation.undoExpiresAt === null || operation.undoExpiresAt <= input.now || @@ -1937,8 +1936,7 @@ export async function undoProviderBatchOperation(input: { return { status: "conflict" }; } if ( - !operation || - operation.status !== "applied" || + operation?.status !== "applied" || operation.undoConsumedAt !== null || operation.undoExpiresAt === null || operation.undoExpiresAt <= input.revertedAt diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 1e6ba0943..a672d1a9d 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -200,6 +200,8 @@ function createFallbackSettings(): SystemSettings { stickyTimeoutCooldownMs: 300_000, ipExtractionConfig: null, ipGeoLookupEnabled: true, + streamGateMode: "enforce", + affinityIgnoreClientSessionId: true, createdAt: now, updatedAt: now, }; @@ -278,6 +280,19 @@ const RECENT_COLUMN_LADDER: ReadonlyArray<{ // 本层更新失败(仍有列缺失)时记录的告警 updateWarn: string; }> = [ + { + key: "affinityIgnoreClientSessionId", + column: systemSettings.affinityIgnoreClientSessionId, + selectWarn: + "system_settings 表除 affinityIgnoreClientSessionId 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: "system_settings 表除 affinityIgnoreClientSessionId 外仍有列缺失,继续降级更新。", + }, + { + key: "streamGateMode", + column: systemSettings.streamGateMode, + selectWarn: "system_settings 表除 streamGateMode 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: "system_settings 表除 streamGateMode 外仍有列缺失,继续降级更新。", + }, { key: "stickyTimeoutCooldownMs", column: systemSettings.stickyTimeoutCooldownMs, @@ -851,6 +866,16 @@ export async function updateSystemSettings( updates.fakeStreamingWhitelist = payload.fakeStreamingWhitelist; } + // F1 流式内容门控模式(如果提供) + if (payload.streamGateMode !== undefined) { + updates.streamGateMode = payload.streamGateMode; + } + + // 忽略客户端 Session ID 开关(如果提供) + if (payload.affinityIgnoreClientSessionId !== undefined) { + updates.affinityIgnoreClientSessionId = payload.affinityIgnoreClientSessionId; + } + let updated; try { [updated] = await executor diff --git a/src/types/message.ts b/src/types/message.ts index fb37b8d7b..64d19730c 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -48,14 +48,16 @@ export interface ProviderChainItem { | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消(未对输家计费) | "hedge_loser_billed" // 该供应商输掉 Hedge 竞速,但其上游响应被后台拿回并计费 - | "client_abort"; // 客户端在响应完成前断开连接 + | "client_abort" // 客户端在响应完成前断开连接 + | "affinity_hit"; // 最长前缀亲和命中(软提名,已通过全套硬校验) // === 选择方法(细化) === selectionMethod?: | "session_reuse" // 会话复用 | "weighted_random" // 加权随机 | "group_filtered" // 分组筛选后随机 - | "fail_open_fallback"; // Fail Open 降级 + | "fail_open_fallback" // Fail Open 降级 + | "prefix_affinity"; // 最长前缀亲和 // 供应商配置(决策依据) priority?: number; diff --git a/src/types/provider-cache-effectiveness.ts b/src/types/provider-cache-effectiveness.ts new file mode 100644 index 000000000..1644aa00c --- /dev/null +++ b/src/types/provider-cache-effectiveness.ts @@ -0,0 +1,17 @@ +// F3b 缓存效果窗口聚合行。bp = 万分比整数,仅指标展示。 +export interface ProviderCacheEffectivenessWindow { + id: number; + providerId: number; + model: string; + cacheTtlBucket: string; + windowStart: Date; + windowEnd: Date; + sampleCount: number; + eligibleCount: number; + theoreticalCacheTokens: number; + observedCacheReadTokens: number; + rawEffectivenessBp: number; + confidenceBp: number; + effectivenessBp: number; + createdAt: Date | null; +} diff --git a/src/types/system-config.ts b/src/types/system-config.ts index dd47b7b06..79441b62b 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -5,6 +5,9 @@ import type { IpExtractionConfig } from "@/types/ip-extraction"; export type BillingModelSource = "original" | "redirected"; export type CodexPriorityBillingSource = "requested" | "actual"; +// F1 流式内容门控模式: 'off' (关闭) | 'shadow' (仅旁路统计) | 'enforce' (启用) +export type StreamGateSettingMode = "off" | "shadow" | "enforce"; + export interface ResponseFixerConfig { fixTruncatedJson: boolean; fixSseFormat: boolean; @@ -146,6 +149,15 @@ export interface SystemSettings { publicStatusWindowHours: number; publicStatusAggregationIntervalMinutes: number; + // F1 流式内容门控模式(默认 enforce) + // enforce:首个有效内容帧前缓冲,错误/空流时自动切换供应商;shadow:仅旁路统计分歧 + streamGateMode: StreamGateSettingMode; + + // 忽略客户端 Session ID(默认开启) + // 开启后:可指纹化的请求强制使用最长前缀亲和做供应商粘性(跳过客户端 Session ID 绑定), + // 不可指纹化的请求仍走会话复用 + affinityIgnoreClientSessionId: boolean; + /** Bounded streaming Discovery settings. */ discoveryEnabled: boolean; discoveryConcurrency: number; @@ -266,4 +278,10 @@ export interface UpdateSystemSettingsInput { // Public Status 全局配置(可选) publicStatusWindowHours?: number; publicStatusAggregationIntervalMinutes?: number; + + // F1 流式内容门控模式(可选) + streamGateMode?: StreamGateSettingMode; + + // 忽略客户端 Session ID(可选) + affinityIgnoreClientSessionId?: boolean; } diff --git a/tests/api/action-adapter-openapi.unit.test.ts b/tests/api/action-adapter-openapi.unit.test.ts index 6603c85d1..57a61095d 100644 --- a/tests/api/action-adapter-openapi.unit.test.ts +++ b/tests/api/action-adapter-openapi.unit.test.ts @@ -14,9 +14,21 @@ import { logger } from "@/lib/logger"; * 说明: * - 这些测试只覆盖 adapter 的“通用执行器”逻辑 * - 不依赖 Next/Hono 的完整运行时 - * - 重点验证:参数映射、返回值包装、错误/异常处理、requiresAuth=false 分支 + * - 重点验证:参数映射、返回值包装、错误/异常处理、requiresAuth 认证链路 */ +const authMocks = vi.hoisted(() => ({ + validateAuthToken: vi.fn(), +})); + +vi.mock("@/lib/auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateAuthToken: authMocks.validateAuthToken, + }; +}); + function createMockContext(options?: { body?: unknown; jsonThrows?: boolean }) { const body = options?.body ?? {}; const jsonThrows = options?.jsonThrows ?? false; @@ -287,6 +299,197 @@ describe("Action Adapter:createActionRoute(单元测试)", () => { }); }); +function createAuthedMockContext(options?: { + body?: unknown; + headers?: Record; + rawCookieHeader?: string; +}) { + const headerMap = new Map( + Object.entries(options?.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]) + ); + const rawHeaders = new Headers(); + if (options?.rawCookieHeader) { + rawHeaders.set("Cookie", options.rawCookieHeader); + } + + return { + req: { + json: async () => options?.body ?? {}, + header: (name: string) => headerMap.get(name.toLowerCase()), + raw: { headers: rawHeaders }, + }, + json: (payload: unknown, status = 200) => + new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }), + } as const; +} + +describe("Action Adapter:requiresAuth 认证链路(单元测试)", () => { + const adminSession = { + user: { id: 1, role: "admin" }, + key: { canLoginWebUi: true }, + }; + + test("默认 requiresAuth:无任何凭证应返回 401 未认证", async () => { + const action = vi.fn(async () => "should-not-run"); + const { handler } = createActionRoute("test", "authMissing", action as any, {}); + + const response = (await handler(createAuthedMockContext() as any)) as Response; + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ ok: false, error: "未认证" }); + expect(action).not.toHaveBeenCalled(); + expect(authMocks.validateAuthToken).not.toHaveBeenCalled(); + }); + + test("Bearer 令牌无效:validateAuthToken 返回 null 时应返回 401", async () => { + authMocks.validateAuthToken.mockResolvedValue(null); + const action = vi.fn(async () => "should-not-run"); + const { handler } = createActionRoute("test", "authInvalid", action as any, { + allowReadOnlyAccess: true, + }); + + const response = (await handler( + createAuthedMockContext({ headers: { authorization: "Bearer bad-token" } }) as any + )) as Response; + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ ok: false, error: "认证无效或已过期" }); + expect(authMocks.validateAuthToken).toHaveBeenCalledWith("bad-token", { + allowReadOnlyAccess: true, + }); + expect(action).not.toHaveBeenCalled(); + }); + + test("requiredRole=admin:普通用户会话应返回 403", async () => { + authMocks.validateAuthToken.mockResolvedValue({ + user: { id: 2, role: "user" }, + key: { canLoginWebUi: true }, + }); + const action = vi.fn(async () => "should-not-run"); + const { handler } = createActionRoute("test", "authForbidden", action as any, { + requiredRole: "admin", + }); + + const response = (await handler( + createAuthedMockContext({ headers: { authorization: "Bearer user-token" } }) as any + )) as Response; + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ ok: false, error: "权限不足" }); + expect(action).not.toHaveBeenCalled(); + }); + + test("Bearer 管理员会话:应在会话作用域内执行 action 并返回 200", async () => { + authMocks.validateAuthToken.mockResolvedValue(adminSession); + const action = vi.fn(async () => ({ ok: true, data: "done" })); + const { handler } = createActionRoute("test", "authOk", action as any, { + requiredRole: "admin", + }); + + const response = (await handler( + createAuthedMockContext({ + headers: { authorization: "Bearer admin-token", "user-agent": "vitest-agent" }, + }) as any + )) as Response; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, data: "done" }); + expect(authMocks.validateAuthToken).toHaveBeenCalledWith("admin-token", { + allowReadOnlyAccess: false, + }); + expect(action).toHaveBeenCalledTimes(1); + }); + + test("Cookie 头解析:getCookie 缺失时应回退解析 cookie 请求头", async () => { + authMocks.validateAuthToken.mockResolvedValue(adminSession); + const action = vi.fn(async () => "ok"); + const { handler } = createActionRoute("test", "authCookieFallback", action as any, {}); + + const response = (await handler( + createAuthedMockContext({ + headers: { cookie: "other=1; auth-token=tok%20en" }, + }) as any + )) as Response; + + expect(response.status).toBe(200); + expect(authMocks.validateAuthToken).toHaveBeenCalledWith("tok en", { + allowReadOnlyAccess: false, + }); + }); + + test("Cookie 原始头:raw Cookie 请求头也应被识别", async () => { + authMocks.validateAuthToken.mockResolvedValue(adminSession); + const action = vi.fn(async () => "ok"); + const { handler } = createActionRoute("test", "authRawCookie", action as any, {}); + + const response = (await handler( + createAuthedMockContext({ rawCookieHeader: "auth-token=raw-tok" }) as any + )) as Response; + + expect(response.status).toBe(200); + expect(authMocks.validateAuthToken).toHaveBeenCalledWith("raw-tok", { + allowReadOnlyAccess: false, + }); + }); + + test("Cookie 值为空或编码非法:应视为未认证", async () => { + const action = vi.fn(async () => "should-not-run"); + const { handler } = createActionRoute("test", "authBadCookie", action as any, {}); + + const emptyValue = (await handler( + createAuthedMockContext({ headers: { cookie: "auth-token=" } }) as any + )) as Response; + expect(emptyValue.status).toBe(401); + + const badEncoding = (await handler( + createAuthedMockContext({ headers: { cookie: "auth-token=%E4%ZZ" } }) as any + )) as Response; + expect(badEncoding.status).toBe(401); + + expect(authMocks.validateAuthToken).not.toHaveBeenCalled(); + expect(action).not.toHaveBeenCalled(); + }); + + test("Authorization 头格式边界:空白或空 Bearer 令牌应返回 401", async () => { + const action = vi.fn(async () => "should-not-run"); + const { handler } = createActionRoute("test", "authBadBearer", action as any, {}); + + const blank = (await handler( + createAuthedMockContext({ headers: { authorization: " " } }) as any + )) as Response; + expect(blank.status).toBe(401); + + const emptyToken = (await handler( + createAuthedMockContext({ headers: { authorization: "Bearer " } }) as any + )) as Response; + expect(emptyToken.status).toBe(401); + + const nonBearer = (await handler( + createAuthedMockContext({ headers: { authorization: "Token abc" } }) as any + )) as Response; + expect(nonBearer.status).toBe(401); + + expect(authMocks.validateAuthToken).not.toHaveBeenCalled(); + expect(action).not.toHaveBeenCalled(); + }); + + test("非对象 requestSchema:应将整个 body 作为唯一参数传递", async () => { + const action = vi.fn(async (value: string) => value); + const { handler } = createActionRoute("test", "rawBodySchema", action as any, { + requiresAuth: false, + requestSchema: z.string(), + }); + + const response = (await handler(createAuthedMockContext({ body: "hello" }) as any)) as Response; + + expect(response.status).toBe(200); + expect(action).toHaveBeenCalledWith("hello"); + await expect(response.json()).resolves.toEqual({ ok: true, data: "hello" }); + }); +}); + describe("Action Adapter:辅助导出函数(单元测试)", () => { test("createActionRoutes:应批量生成 route/handler", () => { const routes = createActionRoutes( diff --git a/tests/api/v1/providers/providers.cache-effectiveness.test.ts b/tests/api/v1/providers/providers.cache-effectiveness.test.ts new file mode 100644 index 000000000..600d4433c --- /dev/null +++ b/tests/api/v1/providers/providers.cache-effectiveness.test.ts @@ -0,0 +1,161 @@ +import type { AuthSession } from "@/lib/auth"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const getWindowsMock = vi.hoisted(() => vi.fn()); +const validateAuthTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/actions/provider-cache-effectiveness", () => ({ + getProviderCacheEffectivenessWindows: getWindowsMock, +})); + +vi.mock("@/lib/auth", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, validateAuthToken: validateAuthTokenMock }; +}); + +const { callV1Route } = await import("../test-utils"); + +const adminSession = { + user: { id: 1, role: "admin", isEnabled: true }, + key: { id: 1, userId: 1, key: "admin-token", canLoginWebUi: true }, +} as AuthSession; + +const userSession = { + user: { id: 2, role: "user", isEnabled: true }, + key: { id: 2, userId: 2, key: "user-token", canLoginWebUi: true }, +} as AuthSession; + +function effectivenessWindow(overrides: Record = {}) { + return { + id: 5, + providerId: 7, + model: "claude-sonnet-4-5", + cacheTtlBucket: "5m", + windowStart: new Date("2026-07-20T00:00:00.000Z"), + windowEnd: new Date("2026-07-20T01:00:00.000Z"), + sampleCount: 120, + eligibleCount: 96, + theoreticalCacheTokens: 200000, + observedCacheReadTokens: 150000, + rawEffectivenessBp: 7500, + confidenceBp: 8000, + effectivenessBp: 6000, + createdAt: new Date("2026-07-20T01:00:05.000Z"), + ...overrides, + }; +} + +describe("v1 provider cache effectiveness endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + validateAuthTokenMock.mockResolvedValue(adminSession); + getWindowsMock.mockResolvedValue({ ok: true, data: [effectivenessWindow()] }); + }); + + test("lists cache effectiveness windows with serialized timestamps", async () => { + const { response, json } = await callV1Route({ + method: "GET", + pathname: "/api/v1/providers/cache-effectiveness", + headers: { Authorization: "Bearer admin-token" }, + }); + + expect(response.status).toBe(200); + expect(json).toMatchObject({ + items: [ + { + id: 5, + providerId: 7, + model: "claude-sonnet-4-5", + cacheTtlBucket: "5m", + windowStart: "2026-07-20T00:00:00.000Z", + windowEnd: "2026-07-20T01:00:00.000Z", + sampleCount: 120, + eligibleCount: 96, + theoreticalCacheTokens: 200000, + observedCacheReadTokens: 150000, + rawEffectivenessBp: 7500, + confidenceBp: 8000, + effectivenessBp: 6000, + }, + ], + }); + expect(getWindowsMock).toHaveBeenCalledWith({ limit: 50 }); + }); + + test("forwards providerId and limit query filters to the action layer", async () => { + const { response } = await callV1Route({ + method: "GET", + pathname: "/api/v1/providers/cache-effectiveness?providerId=7&limit=25", + headers: { Authorization: "Bearer admin-token" }, + }); + + expect(response.status).toBe(200); + expect(getWindowsMock).toHaveBeenCalledWith({ providerId: 7, limit: 25 }); + }); + + test("rejects invalid query parameters", async () => { + const overLimit = await callV1Route({ + method: "GET", + pathname: "/api/v1/providers/cache-effectiveness?limit=500", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(overLimit.response.status).toBe(400); + expect(overLimit.json).toMatchObject({ errorCode: "request.validation_failed" }); + + const badProvider = await callV1Route({ + method: "GET", + pathname: "/api/v1/providers/cache-effectiveness?providerId=abc", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(badProvider.response.status).toBe(400); + expect(getWindowsMock).not.toHaveBeenCalled(); + }); + + test("maps action errors to problem+json", async () => { + getWindowsMock.mockResolvedValueOnce({ + ok: false, + error: "Permission denied", + errorCode: "PERMISSION_DENIED", + }); + const forbidden = await callV1Route({ + method: "GET", + pathname: "/api/v1/providers/cache-effectiveness", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(forbidden.response.status).toBe(403); + expect(forbidden.json).toMatchObject({ errorCode: "PERMISSION_DENIED" }); + + getWindowsMock.mockResolvedValueOnce({ + ok: false, + error: "Operation failed", + errorCode: "OPERATION_FAILED", + }); + const failed = await callV1Route({ + method: "GET", + pathname: "/api/v1/providers/cache-effectiveness", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(failed.response.status).toBe(400); + expect(failed.json).toMatchObject({ errorCode: "OPERATION_FAILED" }); + }); + + test("requires admin access", async () => { + validateAuthTokenMock.mockResolvedValue(userSession); + const { response } = await callV1Route({ + method: "GET", + pathname: "/api/v1/providers/cache-effectiveness", + headers: { Authorization: "Bearer user-token" }, + }); + expect(response.status).toBe(403); + expect(getWindowsMock).not.toHaveBeenCalled(); + }); + + test("documents the cache effectiveness REST path", async () => { + const { json } = await callV1Route({ + method: "GET", + pathname: "/api/v1/openapi.json", + }); + const doc = json as { paths: Record }; + expect(doc.paths).toHaveProperty("/api/v1/providers/cache-effectiveness"); + }); +}); diff --git a/tests/configs/include-session-id-in-errors.config.ts b/tests/configs/include-session-id-in-errors.config.ts index 3a8b4eb42..651cbeb63 100644 --- a/tests/configs/include-session-id-in-errors.config.ts +++ b/tests/configs/include-session-id-in-errors.config.ts @@ -7,12 +7,7 @@ export default createCoverageConfig({ "tests/unit/proxy/responses-session-id.test.ts", "tests/unit/proxy/proxy-handler-session-id-error.test.ts", "tests/unit/proxy/error-handler-session-id-error.test.ts", - "tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts", - ], - sourceFiles: [ - "src/app/v1/_lib/proxy/error-session-id.ts", - "src/app/v1/_lib/proxy-handler.ts", - "src/app/v1/_lib/codex/chat-completions-handler.ts", ], + sourceFiles: ["src/app/v1/_lib/proxy/error-session-id.ts", "src/app/v1/_lib/proxy-handler.ts"], thresholds: { lines: 90, functions: 90, branches: 90, statements: 90 }, }); diff --git a/tests/configs/my-usage.config.ts b/tests/configs/my-usage.config.ts index b19d9e5d7..cee864008 100644 --- a/tests/configs/my-usage.config.ts +++ b/tests/configs/my-usage.config.ts @@ -8,6 +8,19 @@ export default createCoverageConfig({ "tests/api/api-actions-integrity.test.ts", "tests/integration/auth.test.ts", "tests/api/action-adapter-openapi.unit.test.ts", + // 无 DB 的单元测试:保证无 DSN 环境(集成用例被 skip)下覆盖率仍达标 + "tests/unit/actions/my-usage-actions-unit.test.ts", + "tests/unit/actions/my-usage-concurrent-inherit.test.ts", + "tests/unit/actions/my-usage-date-range-dst.test.ts", + "tests/unit/actions/my-usage-ip-geo.test.ts", + "tests/unit/actions/my-usage-readonly-provider-chain.test.ts", + "tests/unit/actions/my-usage-token-aggregation.test.ts", + "tests/unit/actions/my-usage-user-5h-reset-boundary.test.ts", + "tests/unit/auth/admin-token-opaque-fallback.test.ts", + "tests/unit/auth/auth-cookie-constant-sync.test.ts", + "tests/unit/auth/login-redirect-safety.test.ts", + "tests/unit/auth/opaque-admin-session.test.ts", + "tests/unit/auth/set-auth-cookie-options.test.ts", ], sourceFiles: [ "src/actions/my-usage.ts", diff --git a/tests/configs/proxy-guard-pipeline.config.ts b/tests/configs/proxy-guard-pipeline.config.ts index e956c550d..eb372eb8e 100644 --- a/tests/configs/proxy-guard-pipeline.config.ts +++ b/tests/configs/proxy-guard-pipeline.config.ts @@ -4,12 +4,9 @@ export default createCoverageConfig({ name: "proxy-guard-pipeline", environment: "happy-dom", testFiles: [ - "tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts", + "tests/unit/proxy/guard-pipeline-full-chain.test.ts", "tests/unit/proxy/guard-pipeline-warmup.test.ts", ], - sourceFiles: [ - "src/app/v1/_lib/codex/chat-completions-handler.ts", - "src/app/v1/_lib/proxy/guard-pipeline.ts", - ], + sourceFiles: ["src/app/v1/_lib/proxy/guard-pipeline.ts"], thresholds: { lines: 90, functions: 90, branches: 90, statements: 90 }, }); diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index e05966d5b..44f5f7bc7 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -1,15 +1,54 @@ -import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { inArray } from "drizzle-orm"; +import type { NextResponse } from "next/server"; import { db } from "@/drizzle/db"; import { keys, users } from "@/drizzle/schema"; import { + type AuthSession, clearAuthCookie, + detectSessionTokenKind, getAuthCookie, getLoginRedirectTarget, + getScopedAuthContext, + getScopedAuthSession, getSession, + getSessionTokenMigrationFlags, + getSessionWithDualRead, + isOpaqueSessionContract, + isSessionTokenAccepted, + isSessionTokenKindAccepted, + runWithAuthSession, + type ScopedAuthContext, setAuthCookie, validateKey, + validateSession, + withNoStoreHeaders, } from "@/lib/auth"; +import type { Key } from "@/types/key"; +import type { User } from "@/types/user"; + +/** + * 透传式仓储 mock: + * - 默认(override 为 undefined)转发到真实实现,DSN 集成用例语义不变 + * - 无 DB 用例通过设置 override 驱动 validateKey 的用户状态分支 + */ +const keyRepoOverride = vi.hoisted(() => ({ + validateApiKeyAndGetUser: undefined as + | ((keyString: string) => Promise<{ user: User; key: Key } | null>) + | undefined, +})); + +vi.mock("@/repository/key", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateApiKeyAndGetUser: (keyString: string) => + keyRepoOverride.validateApiKeyAndGetUser + ? keyRepoOverride.validateApiKeyAndGetUser(keyString) + : actual.validateApiKeyAndGetUser(keyString), + }; +}); /** * 说明: @@ -119,43 +158,49 @@ describe("auth.ts:validateKey / getSession(安全边界)", () => { expect(session?.key.canLoginWebUi).toBe(true); }); - test("不存在的 key:validateKey 应返回 null", async () => { + test.skipIf(!process.env.DSN)("不存在的 key:validateKey 应返回 null", async () => { const session = await validateKey(`non-existent-${Date.now()}`); expect(session).toBeNull(); }); - test("canLoginWebUi=false 且 allowReadOnlyAccess=false:应拒绝", async () => { - const unique = `auth-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const user = await createTestUser(`Test ${unique}`); - createdUserIds.push(user.id); - const key = await createTestKey({ - userId: user.id, - key: `test-key-${unique}`, - canLoginWebUi: false, - }); - createdKeyIds.push(key.id); - - const session = await validateKey(key.key, { allowReadOnlyAccess: false }); - expect(session).toBeNull(); - }); - - test("allowReadOnlyAccess=true:应允许只读 key 查询自己的数据", async () => { - const unique = `auth-ro-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const user = await createTestUser(`Test ${unique}`); - createdUserIds.push(user.id); - const key = await createTestKey({ - userId: user.id, - key: `test-ro-key-${unique}`, - canLoginWebUi: false, - }); - createdKeyIds.push(key.id); - - const session = await validateKey(key.key, { allowReadOnlyAccess: true }); - expect(session?.key.key).toBe(key.key); - expect(session?.key.canLoginWebUi).toBe(false); - }); + test.skipIf(!process.env.DSN)( + "canLoginWebUi=false 且 allowReadOnlyAccess=false:应拒绝", + async () => { + const unique = `auth-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const user = await createTestUser(`Test ${unique}`); + createdUserIds.push(user.id); + const key = await createTestKey({ + userId: user.id, + key: `test-key-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(key.id); + + const session = await validateKey(key.key, { allowReadOnlyAccess: false }); + expect(session).toBeNull(); + } + ); + + test.skipIf(!process.env.DSN)( + "allowReadOnlyAccess=true:应允许只读 key 查询自己的数据", + async () => { + const unique = `auth-ro-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const user = await createTestUser(`Test ${unique}`); + createdUserIds.push(user.id); + const key = await createTestKey({ + userId: user.id, + key: `test-ro-key-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(key.id); + + const session = await validateKey(key.key, { allowReadOnlyAccess: true }); + expect(session?.key.key).toBe(key.key); + expect(session?.key.canLoginWebUi).toBe(false); + } + ); - test("用户被软删除:validateKey 应返回 null", async () => { + test.skipIf(!process.env.DSN)("用户被软删除:validateKey 应返回 null", async () => { const unique = `auth-del-${Date.now()}-${Math.random().toString(16).slice(2)}`; const user = await createTestUser(`Test ${unique}`); createdUserIds.push(user.id); @@ -176,40 +221,46 @@ describe("auth.ts:validateKey / getSession(安全边界)", () => { expect(session).toBeNull(); }); - test("getSession:无 Cookie 时返回 null;有 Cookie 时返回 session", async () => { - const noCookie = await getSession({ allowReadOnlyAccess: true }); - expect(noCookie).toBeNull(); - - const unique = `auth-sess-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const user = await createTestUser(`Test ${unique}`); - createdUserIds.push(user.id); - const key = await createTestKey({ - userId: user.id, - key: `test-key-${unique}`, - canLoginWebUi: false, - }); - createdKeyIds.push(key.id); - - currentCookieValue = key.key; - const session = await getSession({ allowReadOnlyAccess: true }); - expect(session?.key.key).toBe(key.key); - }); - - test("getSession:仅 Authorization: Bearer 时也应返回 session", async () => { - const unique = `auth-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const user = await createTestUser(`Test ${unique}`); - createdUserIds.push(user.id); - const key = await createTestKey({ - userId: user.id, - key: `test-key-${unique}`, - canLoginWebUi: false, - }); - createdKeyIds.push(key.id); - - currentAuthorizationValue = `Bearer ${key.key}`; - const session = await getSession({ allowReadOnlyAccess: true }); - expect(session?.key.key).toBe(key.key); - }); + test.skipIf(!process.env.DSN)( + "getSession:无 Cookie 时返回 null;有 Cookie 时返回 session", + async () => { + const noCookie = await getSession({ allowReadOnlyAccess: true }); + expect(noCookie).toBeNull(); + + const unique = `auth-sess-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const user = await createTestUser(`Test ${unique}`); + createdUserIds.push(user.id); + const key = await createTestKey({ + userId: user.id, + key: `test-key-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(key.id); + + currentCookieValue = key.key; + const session = await getSession({ allowReadOnlyAccess: true }); + expect(session?.key.key).toBe(key.key); + } + ); + + test.skipIf(!process.env.DSN)( + "getSession:仅 Authorization: Bearer 时也应返回 session", + async () => { + const unique = `auth-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const user = await createTestUser(`Test ${unique}`); + createdUserIds.push(user.id); + const key = await createTestKey({ + userId: user.id, + key: `test-key-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(key.id); + + currentAuthorizationValue = `Bearer ${key.key}`; + const session = await getSession({ allowReadOnlyAccess: true }); + expect(session?.key.key).toBe(key.key); + } + ); }); describe("auth.ts:Cookie 工具函数与跳转目标", () => { @@ -252,3 +303,280 @@ describe("auth.ts:Cookie 工具函数与跳转目标", () => { expect(readonlyTarget).toBe("/my-usage"); }); }); + +function buildDbUser(overrides: Partial = {}): User { + const now = new Date(); + return { + id: 101, + name: "Mock User", + description: "unit mock user", + role: "user", + rpm: 0, + dailyQuota: 0, + providerGroup: null, + isEnabled: true, + expiresAt: null, + limit5hResetMode: "rolling", + dailyResetMode: "fixed", + dailyResetTime: "00:00", + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function buildDbKey(overrides: Partial = {}): Key { + const now = new Date(); + return { + id: 201, + userId: 101, + name: "mock-key", + key: "sk-mock-key", + isEnabled: true, + canLoginWebUi: true, + providerGroup: null, + limit5hUsd: null, + limit5hResetMode: "rolling", + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitConcurrentSessions: 0, + cacheTtlPreference: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe("auth.ts:令牌格式与迁移开关(无需 DB)", () => { + test("detectSessionTokenKind:空白与 sid_ 前缀判定", () => { + expect(detectSessionTokenKind("")).toBe("legacy"); + expect(detectSessionTokenKind(" ")).toBe("legacy"); + expect(detectSessionTokenKind("sid_abc123")).toBe("opaque"); + expect(detectSessionTokenKind(" sid_abc123 ")).toBe("opaque"); + expect(detectSessionTokenKind("sk-plain-key")).toBe("legacy"); + }); + + test("isSessionTokenKindAccepted:三种模式与两种 kind 的组合", () => { + expect(isSessionTokenKindAccepted("dual", "legacy")).toBe(true); + expect(isSessionTokenKindAccepted("dual", "opaque")).toBe(true); + expect(isSessionTokenKindAccepted("legacy", "legacy")).toBe(true); + expect(isSessionTokenKindAccepted("legacy", "opaque")).toBe(false); + expect(isSessionTokenKindAccepted("opaque", "opaque")).toBe(true); + expect(isSessionTokenKindAccepted("opaque", "legacy")).toBe(false); + }); + + test("isSessionTokenAccepted:显式模式与默认模式(测试环境默认 opaque)", () => { + expect(isSessionTokenAccepted("sk-legacy", "legacy")).toBe(true); + expect(isSessionTokenAccepted("sid_x", "legacy")).toBe(false); + expect(isSessionTokenAccepted("sid_x")).toBe(true); + expect(isSessionTokenAccepted("sk-legacy")).toBe(false); + }); + + test("getSessionTokenMigrationFlags:模式与迁移开关一一对应", () => { + expect(getSessionTokenMigrationFlags("legacy")).toEqual({ + dualReadWindowEnabled: false, + hardCutoverEnabled: false, + emergencyRollbackEnabled: true, + }); + expect(getSessionTokenMigrationFlags("dual")).toEqual({ + dualReadWindowEnabled: true, + hardCutoverEnabled: false, + emergencyRollbackEnabled: false, + }); + expect(getSessionTokenMigrationFlags("opaque")).toEqual({ + dualReadWindowEnabled: false, + hardCutoverEnabled: true, + emergencyRollbackEnabled: false, + }); + expect(getSessionTokenMigrationFlags()).toEqual(getSessionTokenMigrationFlags("opaque")); + }); + + test("isOpaqueSessionContract:合法契约与字段级拒绝", () => { + const valid = { + sessionId: "sid_1", + keyFingerprint: "sha256:ab", + createdAt: 1000, + expiresAt: 2000, + userId: 7, + userRole: "user", + }; + + expect(isOpaqueSessionContract(valid)).toBe(true); + expect(isOpaqueSessionContract({ ...valid, credentialType: "session" })).toBe(true); + expect(isOpaqueSessionContract({ ...valid, credentialType: "admin-token" })).toBe(true); + expect(isOpaqueSessionContract({ ...valid, credentialType: "user-api-key" })).toBe(true); + + expect(isOpaqueSessionContract(null)).toBe(false); + expect(isOpaqueSessionContract(undefined)).toBe(false); + expect(isOpaqueSessionContract("sid_1")).toBe(false); + expect(isOpaqueSessionContract(42)).toBe(false); + expect(isOpaqueSessionContract({ ...valid, sessionId: 5 })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, sessionId: "" })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, keyFingerprint: 9 })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, keyFingerprint: "" })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, createdAt: "1000" })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, createdAt: Number.NaN })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, expiresAt: "2000" })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, expiresAt: Number.NaN })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, expiresAt: 1000 })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, userId: "7" })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, userId: 7.5 })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, userRole: 1 })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, userRole: "" })).toBe(false); + expect(isOpaqueSessionContract({ ...valid, credentialType: "bogus" })).toBe(false); + }); + + test("withNoStoreHeaders:应设置禁止缓存响应头并返回原响应", () => { + const response = { headers: new Headers() } as unknown as NextResponse; + const result = withNoStoreHeaders(response); + expect(result).toBe(response); + expect(result.headers.get("Cache-Control")).toBe("no-store, no-cache, must-revalidate"); + expect(result.headers.get("Pragma")).toBe("no-cache"); + }); +}); + +describe("auth.ts:validateKey 用户状态边界(mock 仓储层,无需 DB)", () => { + afterEach(() => { + keyRepoOverride.validateApiKeyAndGetUser = undefined; + }); + + test("key 不存在:仓储返回 null 时应返回 null", async () => { + keyRepoOverride.validateApiKeyAndGetUser = async () => null; + await expect(validateKey("sk-mock-missing")).resolves.toBeNull(); + }); + + test("用户被禁用:应返回 null", async () => { + keyRepoOverride.validateApiKeyAndGetUser = async () => ({ + user: buildDbUser({ isEnabled: false }), + key: buildDbKey(), + }); + await expect(validateKey("sk-mock-key")).resolves.toBeNull(); + }); + + test("用户已过期:应返回 null", async () => { + keyRepoOverride.validateApiKeyAndGetUser = async () => ({ + user: buildDbUser({ expiresAt: new Date(Date.now() - 60_000) }), + key: buildDbKey(), + }); + await expect(validateKey("sk-mock-key")).resolves.toBeNull(); + }); + + test("canLoginWebUi=false:默认拒绝,allowReadOnlyAccess=true 放行", async () => { + keyRepoOverride.validateApiKeyAndGetUser = async () => ({ + user: buildDbUser(), + key: buildDbKey({ canLoginWebUi: false }), + }); + + await expect(validateKey("sk-mock-key")).resolves.toBeNull(); + + const readonlySession = await validateKey("sk-mock-key", { allowReadOnlyAccess: true }); + expect(readonlySession?.key.canLoginWebUi).toBe(false); + expect(readonlySession?.user.id).toBe(101); + }); + + test("正常 key:应透传仓储返回的 user/key", async () => { + const user = buildDbUser({ expiresAt: new Date(Date.now() + 3_600_000) }); + const key = buildDbKey(); + keyRepoOverride.validateApiKeyAndGetUser = async () => ({ user, key }); + + const session = await validateKey("sk-mock-key"); + expect(session?.user).toBe(user); + expect(session?.key).toBe(key); + }); +}); + +describe("auth.ts:scoped 会话与 Bearer 解析(无需 DB)", () => { + const storage = new AsyncLocalStorage(); + + function buildReadonlySession(overrides: Partial = {}): AuthSession { + return { + user: buildDbUser(), + key: buildDbKey({ canLoginWebUi: false, ...overrides }), + }; + } + + beforeEach(() => { + currentCookieValue = undefined; + currentAuthorizationValue = undefined; + globalThis.__cchAuthSessionStorage = { + run: (store, callback) => storage.run(store, callback), + getStore: () => storage.getStore(), + }; + }); + + afterEach(() => { + globalThis.__cchAuthSessionStorage = undefined; + }); + + test("runWithAuthSession:无 storage 时直接执行回调且无 scoped 会话", () => { + globalThis.__cchAuthSessionStorage = undefined; + const session = buildReadonlySession(); + + expect(runWithAuthSession(session, () => 42)).toBe(42); + expect(getScopedAuthSession()).toBeNull(); + expect(getScopedAuthContext()).toBeNull(); + }); + + test("runWithAuthSession:storage 内可读取 scoped 会话与 allowReadOnlyAccess 语义", () => { + const session = buildReadonlySession(); + + const observed = runWithAuthSession( + session, + () => ({ session: getScopedAuthSession(), ctx: getScopedAuthContext() }), + { allowReadOnlyAccess: true } + ); + expect(observed.session).toBe(session); + expect(observed.ctx?.allowReadOnlyAccess).toBe(true); + + const defaultCtx = runWithAuthSession(session, () => getScopedAuthContext()); + expect(defaultCtx?.allowReadOnlyAccess).toBe(false); + }); + + test("getSession:scoped 只读会话遵循创建时语义,仅允许内部降权", async () => { + const readonlySession = buildReadonlySession(); + + // 只读作用域 + 默认选项:放行 + await expect( + runWithAuthSession(readonlySession, () => getSession(), { allowReadOnlyAccess: true }) + ).resolves.toBe(readonlySession); + + // 只读作用域 + 显式降权:拒绝 + await expect( + runWithAuthSession(readonlySession, () => getSession({ allowReadOnlyAccess: false }), { + allowReadOnlyAccess: true, + }) + ).resolves.toBeNull(); + + // 非只读作用域创建的会话不允许提权为只读访问 + await expect( + runWithAuthSession(readonlySession, () => getSession({ allowReadOnlyAccess: true }), { + allowReadOnlyAccess: false, + }) + ).resolves.toBeNull(); + + // canLoginWebUi=true 的 scoped 会话不受只读语义限制 + const webUiSession = buildReadonlySession({ canLoginWebUi: true }); + await expect( + runWithAuthSession(webUiSession, () => getSession(), { allowReadOnlyAccess: false }) + ).resolves.toBe(webUiSession); + }); + + test("validateSession / getSessionWithDualRead:无任何凭证时返回 null", async () => { + await expect(validateSession({ allowReadOnlyAccess: true })).resolves.toBeNull(); + await expect(getSessionWithDualRead()).resolves.toBeNull(); + }); + + test("Authorization 头格式边界:空白或非 Bearer 时不产生会话", async () => { + currentAuthorizationValue = " "; + await expect(getSession({ allowReadOnlyAccess: true })).resolves.toBeNull(); + + currentAuthorizationValue = "Token sk-not-bearer"; + await expect(getSession({ allowReadOnlyAccess: true })).resolves.toBeNull(); + + currentAuthorizationValue = "Bearer "; + await expect(getSession({ allowReadOnlyAccess: true })).resolves.toBeNull(); + }); +}); diff --git a/tests/unit/actions/my-usage-actions-unit.test.ts b/tests/unit/actions/my-usage-actions-unit.test.ts new file mode 100644 index 000000000..7528d4075 --- /dev/null +++ b/tests/unit/actions/my-usage-actions-unit.test.ts @@ -0,0 +1,1023 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +// 禁用 tests/setup.ts 中基于 DSN/Redis 的默认同步与清理协调,避免无关依赖引入。 +process.env.DSN = ""; +process.env.AUTO_CLEANUP_TEST_DATA = "false"; + +/** + * 说明: + * - 本文件通过 mock 仓储层/基础设施为 my-usage actions 提供无 DB 的行为覆盖 + * - 与 tests/api/my-usage-readonly.test.ts(真实 PG 集成)互补: + * 集成文件在无 DSN 环境会整体跳过,这里保证核心分支仍被验证 + */ + +function createThenableQuery(result: T) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.where = vi.fn(() => query); + query.groupBy = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + + return query; +} + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getSystemSettings: vi.fn(), + resolveSystemTimezone: vi.fn(), + getTranslations: vi.fn(), + findUsageLogsForKeySlim: vi.fn(), + findUsageLogsForKeyBatch: vi.fn(), + findReadonlyUsageLogsBatchForKey: vi.fn(), + getDistinctModelsForKey: vi.fn(), + getDistinctEndpointsForKey: vi.fn(), + lookupIp: vi.fn(), + select: vi.fn(), + getTimeRangeForPeriodWithMode: vi.fn(), + getTimeRangeForPeriod: vi.fn(), + sumKeyQuotaCostsById: vi.fn(), + sumUserQuotaCosts: vi.fn(), + getCurrentCost: vi.fn(), + getKeySessionCount: vi.fn(), + getUserSessionCount: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: mocks.getSession, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: mocks.resolveSystemTimezone, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mocks.getTranslations, +})); + +vi.mock("@/repository/usage-logs", () => ({ + findUsageLogsForKeySlim: mocks.findUsageLogsForKeySlim, + findUsageLogsForKeyBatch: mocks.findUsageLogsForKeyBatch, + findReadonlyUsageLogsBatchForKey: mocks.findReadonlyUsageLogsBatchForKey, + getDistinctModelsForKey: mocks.getDistinctModelsForKey, + getDistinctEndpointsForKey: mocks.getDistinctEndpointsForKey, +})); + +vi.mock("@/lib/ip-geo/client", () => ({ + lookupIp: mocks.lookupIp, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: mocks.select, + }, +})); + +vi.mock("@/lib/rate-limit/time-utils", () => ({ + getTimeRangeForPeriodWithMode: mocks.getTimeRangeForPeriodWithMode, + getTimeRangeForPeriod: mocks.getTimeRangeForPeriod, +})); + +vi.mock("@/repository/statistics", () => ({ + sumKeyQuotaCostsById: mocks.sumKeyQuotaCostsById, + sumUserQuotaCosts: mocks.sumUserQuotaCosts, +})); + +vi.mock("@/lib/rate-limit/service", () => ({ + RateLimitService: { + getCurrentCost: mocks.getCurrentCost, + }, +})); + +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + getKeySessionCount: mocks.getKeySessionCount, + getUserSessionCount: mocks.getUserSessionCount, + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +function buildSessionKey(overrides: Record = {}) { + return { + id: 31, + userId: 11, + name: "unit-key", + key: "sk-unit-key", + isEnabled: true, + canLoginWebUi: false, + providerGroup: "key-group", + expiresAt: new Date("2030-01-02T00:00:00.000Z"), + dailyResetMode: "rolling", + dailyResetTime: "08:00", + ...overrides, + }; +} + +function buildSessionUser(overrides: Record = {}) { + return { + id: 11, + name: "unit-user", + role: "user", + providerGroup: "user-group", + expiresAt: new Date("2031-01-02T00:00:00.000Z"), + isEnabled: true, + ...overrides, + }; +} + +function buildSession(overrides?: { + key?: Record; + user?: Record; +}) { + return { + key: buildSessionKey(overrides?.key), + user: buildSessionUser(overrides?.user), + }; +} + +function buildSlimRow(overrides: Record = {}) { + return { + id: 1, + createdAt: new Date("2024-06-01T10:00:00.000Z"), + model: "claude-3-5", + originalModel: "claude-3", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 100, + outputTokens: 50, + costUsd: "1.25", + durationMs: 800, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 20, + cacheCreation5mInputTokens: 5, + cacheCreation1hInputTokens: 5, + cacheTtlApplied: "5m", + anthropicEffort: "high", + ...overrides, + }; +} + +beforeEach(() => { + mocks.getSession.mockResolvedValue(buildSession()); + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + billingModelSource: "redirected", + ipGeoLookupEnabled: true, + }); + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getTranslations.mockImplementation(async () => (key: string) => key); + mocks.select.mockImplementation(() => createThenableQuery([])); +}); + +async function importMyUsage() { + vi.resetModules(); + return import("@/actions/my-usage"); +} + +describe("getMyUsageMetadata", () => { + test("未授权:应返回 Unauthorized", async () => { + mocks.getSession.mockResolvedValue(null); + const { getMyUsageMetadata } = await importMyUsage(); + await expect(getMyUsageMetadata()).resolves.toEqual({ ok: false, error: "Unauthorized" }); + }); + + test("成功:应返回 key/user 元数据与系统设置", async () => { + const { getMyUsageMetadata } = await importMyUsage(); + const result = await getMyUsageMetadata(); + + expect(result).toEqual({ + ok: true, + data: { + keyName: "unit-key", + keyProviderGroup: "key-group", + keyExpiresAt: new Date("2030-01-02T00:00:00.000Z"), + keyIsEnabled: true, + userName: "unit-user", + userProviderGroup: "user-group", + userExpiresAt: new Date("2031-01-02T00:00:00.000Z"), + userIsEnabled: true, + dailyResetMode: "rolling", + dailyResetTime: "08:00", + currencyCode: "USD", + billingModelSource: "redirected", + }, + }); + }); + + test("缺省字段:应回退到默认值", async () => { + mocks.getSession.mockResolvedValue( + buildSession({ + key: { + providerGroup: undefined, + expiresAt: undefined, + isEnabled: undefined, + dailyResetMode: undefined, + dailyResetTime: undefined, + }, + user: { providerGroup: undefined, expiresAt: undefined, isEnabled: undefined }, + }) + ); + + const { getMyUsageMetadata } = await importMyUsage(); + const result = await getMyUsageMetadata(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toMatchObject({ + keyProviderGroup: null, + keyExpiresAt: null, + keyIsEnabled: true, + userProviderGroup: null, + userExpiresAt: null, + userIsEnabled: true, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + }); + }); + + test("设置读取失败:应返回通用错误", async () => { + mocks.getSystemSettings.mockRejectedValue(new Error("settings down")); + const { getMyUsageMetadata } = await importMyUsage(); + await expect(getMyUsageMetadata()).resolves.toEqual({ + ok: false, + error: "Failed to get metadata", + }); + }); +}); + +describe("getMyAvailableModels / getMyAvailableEndpoints", () => { + test("成功:应按当前 key 查询去重列表", async () => { + mocks.getDistinctModelsForKey.mockResolvedValue(["claude-3", "gpt-4"]); + mocks.getDistinctEndpointsForKey.mockResolvedValue(["/v1/messages"]); + + const { getMyAvailableModels, getMyAvailableEndpoints } = await importMyUsage(); + + await expect(getMyAvailableModels()).resolves.toEqual({ + ok: true, + data: ["claude-3", "gpt-4"], + }); + expect(mocks.getDistinctModelsForKey).toHaveBeenCalledWith("sk-unit-key"); + + await expect(getMyAvailableEndpoints()).resolves.toEqual({ + ok: true, + data: ["/v1/messages"], + }); + expect(mocks.getDistinctEndpointsForKey).toHaveBeenCalledWith("sk-unit-key"); + }); + + test("未授权:应返回 UNAUTHORIZED 错误码", async () => { + mocks.getSession.mockResolvedValue(null); + const { getMyAvailableModels, getMyAvailableEndpoints } = await importMyUsage(); + + await expect(getMyAvailableModels()).resolves.toEqual({ + ok: false, + error: "UNAUTHORIZED", + errorCode: ERROR_CODES.UNAUTHORIZED, + }); + await expect(getMyAvailableEndpoints()).resolves.toEqual({ + ok: false, + error: "UNAUTHORIZED", + errorCode: ERROR_CODES.UNAUTHORIZED, + }); + }); + + test("仓储异常:应返回 OPERATION_FAILED 错误码", async () => { + mocks.getDistinctModelsForKey.mockRejectedValue(new Error("db down")); + mocks.getDistinctEndpointsForKey.mockRejectedValue(new Error("db down")); + const { getMyAvailableModels, getMyAvailableEndpoints } = await importMyUsage(); + + await expect(getMyAvailableModels()).resolves.toEqual({ + ok: false, + error: "OPERATION_FAILED", + errorCode: ERROR_CODES.OPERATION_FAILED, + }); + await expect(getMyAvailableEndpoints()).resolves.toEqual({ + ok: false, + error: "OPERATION_FAILED", + errorCode: ERROR_CODES.OPERATION_FAILED, + }); + }); +}); + +describe("getMyUsageLogs", () => { + test("billingModelSource=original:应映射重定向标记与计费模型", async () => { + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "CNY", + billingModelSource: "original", + }); + mocks.findUsageLogsForKeySlim.mockResolvedValue({ + logs: [ + buildSlimRow(), + buildSlimRow({ + id: 2, + createdAt: null, + model: "claude-3-5", + originalModel: null, + endpoint: null, + statusCode: null, + inputTokens: null, + outputTokens: null, + costUsd: null, + durationMs: null, + cacheCreationInputTokens: null, + cacheReadInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheTtlApplied: null, + anthropicEffort: null, + }), + ], + total: 2, + }); + + const { getMyUsageLogs } = await importMyUsage(); + const result = await getMyUsageLogs(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.total).toBe(2); + expect(result.data.page).toBe(1); + expect(result.data.pageSize).toBe(20); + expect(result.data.currencyCode).toBe("CNY"); + expect(result.data.billingModelSource).toBe("original"); + expect(result.data.logs[0]).toMatchObject({ + id: 1, + model: "claude-3-5", + billingModel: "claude-3", + modelRedirect: "claude-3 → claude-3-5", + anthropicEffort: "high", + inputTokens: 100, + outputTokens: 50, + cost: 1.25, + statusCode: 200, + duration: 800, + endpoint: "/v1/messages", + cacheCreationInputTokens: 10, + cacheReadInputTokens: 20, + cacheCreation5mInputTokens: 5, + cacheCreation1hInputTokens: 5, + cacheTtlApplied: "5m", + }); + expect(result.data.logs[1]).toMatchObject({ + id: 2, + billingModel: null, + modelRedirect: null, + anthropicEffort: null, + inputTokens: 0, + outputTokens: 0, + cost: 0, + statusCode: null, + duration: null, + endpoint: null, + cacheCreationInputTokens: null, + cacheTtlApplied: null, + }); + }); + + test("billingModelSource=redirected:计费模型应取重定向后的 model", async () => { + mocks.findUsageLogsForKeySlim.mockResolvedValue({ + logs: [ + buildSlimRow({ model: "m-redirect", originalModel: "m-orig" }), + buildSlimRow({ id: 2, model: null, originalModel: "m-orig" }), + ], + total: 2, + }); + + const { getMyUsageLogs } = await importMyUsage(); + const result = await getMyUsageLogs(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.logs[0]?.billingModel).toBe("m-redirect"); + expect(result.data.logs[0]?.modelRedirect).toBe("m-orig → m-redirect"); + expect(result.data.logs[1]?.billingModel).toBeNull(); + expect(result.data.logs[1]?.modelRedirect).toBeNull(); + }); + + test("分页参数:应截断小数并钳制在 1..100", async () => { + mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 }); + const { getMyUsageLogs } = await importMyUsage(); + + await getMyUsageLogs({ page: 2.9, pageSize: 500 }); + expect(mocks.findUsageLogsForKeySlim).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 2, pageSize: 100 }) + ); + + await getMyUsageLogs({ page: 0, pageSize: 0 }); + expect(mocks.findUsageLogsForKeySlim).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 1, pageSize: 20 }) + ); + }); + + test("startTime/endTime:应优先于 startDate/endDate", async () => { + mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 }); + const { getMyUsageLogs } = await importMyUsage(); + + await getMyUsageLogs({ + startTime: 1111, + endTime: 2222, + startDate: "2024-01-01", + endDate: "2024-01-02", + }); + expect(mocks.findUsageLogsForKeySlim).toHaveBeenLastCalledWith( + expect.objectContaining({ startTime: 1111, endTime: 2222 }) + ); + }); + + test("startDate/endDate:应按服务器时区解析为当日与次日零点", async () => { + mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 }); + const { getMyUsageLogs } = await importMyUsage(); + + await getMyUsageLogs({ startDate: "2024-01-01", endDate: "2024-01-02" }); + expect(mocks.findUsageLogsForKeySlim).toHaveBeenLastCalledWith( + expect.objectContaining({ + startTime: Date.UTC(2024, 0, 1), + endTime: Date.UTC(2024, 0, 3), + }) + ); + }); + + test("时区缺失与非法日期:应回退 UTC 并忽略非法输入", async () => { + mocks.resolveSystemTimezone.mockResolvedValue(undefined); + mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 }); + const { getMyUsageLogs } = await importMyUsage(); + + await getMyUsageLogs({ startDate: "01/01/2024", endDate: "2024-01-02" }); + expect(mocks.findUsageLogsForKeySlim).toHaveBeenLastCalledWith( + expect.objectContaining({ + startTime: undefined, + endTime: Date.UTC(2024, 0, 3), + }) + ); + }); + + test("未授权与仓储异常:应返回错误结果", async () => { + mocks.getSession.mockResolvedValueOnce(null); + const { getMyUsageLogs } = await importMyUsage(); + await expect(getMyUsageLogs()).resolves.toEqual({ ok: false, error: "Unauthorized" }); + + mocks.findUsageLogsForKeySlim.mockRejectedValue(new Error("db down")); + await expect(getMyUsageLogs()).resolves.toEqual({ + ok: false, + error: "Failed to get usage logs", + }); + }); +}); + +describe("getMyUsageLogsBatch", () => { + test("成功:应透传 cursor 并钳制 limit", async () => { + const cursor = { createdAt: "2024-06-01T00:00:00.000Z", id: 9 }; + mocks.findUsageLogsForKeyBatch.mockResolvedValue({ + logs: [buildSlimRow()], + nextCursor: { createdAt: "2024-06-01T10:00:00.000Z", id: 1 }, + hasMore: true, + }); + + const { getMyUsageLogsBatch } = await importMyUsage(); + const result = await getMyUsageLogsBatch({ cursor, limit: 500 }); + + expect(mocks.findUsageLogsForKeyBatch).toHaveBeenCalledWith( + expect.objectContaining({ keyString: "sk-unit-key", cursor, limit: 100 }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.hasMore).toBe(true); + expect(result.data.nextCursor).toEqual({ createdAt: "2024-06-01T10:00:00.000Z", id: 1 }); + expect(result.data.logs[0]?.id).toBe(1); + }); + + test("limit<=0:应回退默认 20", async () => { + mocks.findUsageLogsForKeyBatch.mockResolvedValue({ + logs: [], + nextCursor: null, + hasMore: false, + }); + const { getMyUsageLogsBatch } = await importMyUsage(); + + await getMyUsageLogsBatch({ limit: 0 }); + expect(mocks.findUsageLogsForKeyBatch).toHaveBeenLastCalledWith( + expect.objectContaining({ limit: 20 }) + ); + }); + + test("未授权与仓储异常:应返回错误结果", async () => { + mocks.getSession.mockResolvedValueOnce(null); + const { getMyUsageLogsBatch } = await importMyUsage(); + await expect(getMyUsageLogsBatch()).resolves.toEqual({ ok: false, error: "Unauthorized" }); + + mocks.findUsageLogsForKeyBatch.mockRejectedValue(new Error("db down")); + await expect(getMyUsageLogsBatch()).resolves.toEqual({ + ok: false, + error: "Failed to get usage logs", + }); + }); +}); + +describe("getMyUsageLogsBatchFull", () => { + test("未授权:应返回 UNAUTHORIZED 错误码", async () => { + mocks.getSession.mockResolvedValue(null); + const { getMyUsageLogsBatchFull } = await importMyUsage(); + await expect(getMyUsageLogsBatchFull()).resolves.toEqual({ + ok: false, + error: "UNAUTHORIZED", + errorCode: ERROR_CODES.UNAUTHORIZED, + }); + }); + + test("仓储异常:应返回 OPERATION_FAILED 错误码", async () => { + mocks.findReadonlyUsageLogsBatchForKey.mockRejectedValue(new Error("db down")); + const { getMyUsageLogsBatchFull } = await importMyUsage(); + await expect(getMyUsageLogsBatchFull()).resolves.toEqual({ + ok: false, + error: "OPERATION_FAILED", + errorCode: ERROR_CODES.OPERATION_FAILED, + }); + }); + + test("脱敏:providerChain 为空/无 provider 详情/非拦截 specialSettings 的边界", async () => { + mocks.findReadonlyUsageLogsBatchForKey.mockResolvedValue({ + logs: [ + { + id: 1, + userName: "admin", + keyName: "some-key", + providerName: "provider-x", + errorMessage: "boom", + blockedReason: "blocked", + userAgent: "ua", + messagesCount: 3, + _liveChain: { chain: [], phase: "provider", updatedAt: 1 }, + providerChain: null, + costMultiplier: 2, + groupCostMultiplier: 3, + costBreakdown: { input: { usd: "0.1" } }, + specialSettings: [{ type: "cache_ttl", ttl: "5m" }], + }, + { + id: 2, + userName: "admin", + keyName: "some-key", + providerName: null, + errorMessage: null, + blockedReason: null, + userAgent: null, + messagesCount: null, + _liveChain: null, + providerChain: [ + { + id: 9, + name: "no-provider-details", + errorDetails: { clientError: "client saw this" }, + }, + ], + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + specialSettings: null, + }, + ], + nextCursor: null, + hasMore: false, + }); + + const { getMyUsageLogsBatchFull } = await importMyUsage(); + const result = await getMyUsageLogsBatchFull({ limit: 10 }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.logs[0]).toMatchObject({ + id: 1, + userName: "", + keyName: "", + providerName: null, + errorMessage: null, + blockedReason: null, + userAgent: null, + messagesCount: null, + _liveChain: null, + providerChain: null, + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + specialSettings: [{ type: "cache_ttl", ttl: "5m" }], + }); + const scrubbedChain = result.data.logs[1]?.providerChain; + expect(scrubbedChain?.[0]?.errorDetails?.clientError).toBe("client saw this"); + expect(scrubbedChain?.[0]?.errorDetails?.provider).toBeUndefined(); + expect(result.data.logs[1]?.specialSettings).toBeNull(); + }); +}); + +describe("getMyTodayStats", () => { + beforeEach(() => { + mocks.getTimeRangeForPeriodWithMode.mockResolvedValue({ + startTime: new Date("2024-06-01T00:00:00.000Z"), + endTime: new Date("2024-06-02T00:00:00.000Z"), + }); + }); + + test("聚合:应按 billingModelSource=original 计算合计与分模型明细", async () => { + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + billingModelSource: "original", + }); + mocks.select.mockImplementation(() => + createThenableQuery([ + { + model: "m1", + originalModel: "m0", + calls: 2, + costUsd: "3.25", + inputTokens: 100, + outputTokens: 40, + }, + { + model: "m2", + originalModel: null, + calls: null, + costUsd: null, + inputTokens: null, + outputTokens: null, + }, + { + model: "m3", + originalModel: "m3o", + calls: 1, + costUsd: "not-a-number", + inputTokens: 1, + outputTokens: 1, + }, + ]) + ); + + const { getMyTodayStats } = await importMyUsage(); + const result = await getMyTodayStats(); + + expect(mocks.getTimeRangeForPeriodWithMode).toHaveBeenCalledWith("daily", "08:00", "rolling"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.calls).toBe(3); + expect(result.data.inputTokens).toBe(101); + expect(result.data.outputTokens).toBe(41); + expect(result.data.costUsd).toBe(3.25); + expect(result.data.modelBreakdown).toEqual([ + { + model: "m1", + billingModel: "m0", + calls: 2, + costUsd: 3.25, + inputTokens: 100, + outputTokens: 40, + }, + { + model: "m2", + billingModel: null, + calls: null, + costUsd: 0, + inputTokens: null, + outputTokens: null, + }, + { model: "m3", billingModel: "m3o", calls: 1, costUsd: 0, inputTokens: 1, outputTokens: 1 }, + ]); + }); + + test("key 缺省重置配置:应回退 00:00/fixed,redirected 计费模型取 model", async () => { + mocks.getSession.mockResolvedValue( + buildSession({ key: { dailyResetTime: undefined, dailyResetMode: undefined } }) + ); + mocks.select.mockImplementation(() => + createThenableQuery([ + { + model: "m1", + originalModel: "m0", + calls: 1, + costUsd: "1", + inputTokens: 1, + outputTokens: 1, + }, + ]) + ); + + const { getMyTodayStats } = await importMyUsage(); + const result = await getMyTodayStats(); + + expect(mocks.getTimeRangeForPeriodWithMode).toHaveBeenCalledWith("daily", "00:00", "fixed"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.modelBreakdown[0]?.billingModel).toBe("m1"); + }); + + test("未授权与查询异常:应返回错误结果", async () => { + mocks.getSession.mockResolvedValueOnce(null); + const { getMyTodayStats } = await importMyUsage(); + await expect(getMyTodayStats()).resolves.toEqual({ ok: false, error: "Unauthorized" }); + + mocks.select.mockImplementation(() => { + throw new Error("db down"); + }); + await expect(getMyTodayStats()).resolves.toEqual({ + ok: false, + error: "Failed to get today's usage", + }); + }); +}); + +describe("getMyStatsSummary", () => { + function buildSummaryRow(overrides: Record = {}) { + return { + model: "mA", + userRequests: 5, + userCost: "9", + userInputTokens: 500, + userOutputTokens: 200, + userCacheCreationTokens: 10, + userCacheReadTokens: 20, + userCacheCreation5mTokens: 1, + userCacheCreation1hTokens: 2, + keyRequests: 2, + keyCost: "1.5", + keyInputTokens: 100, + keyOutputTokens: 50, + keyCacheCreationTokens: 5, + keyCacheReadTokens: 6, + keyCacheCreation5mTokens: 1, + keyCacheCreation1hTokens: 1, + ...overrides, + }; + } + + test("聚合:Key 维度过滤零请求行并按成本排序,User 维度保留全部", async () => { + mocks.select.mockImplementation(() => + createThenableQuery([ + buildSummaryRow(), + buildSummaryRow({ + model: "mB", + userRequests: 3, + userCost: null, + keyRequests: 3, + keyCost: "4.5", + keyInputTokens: null, + keyOutputTokens: null, + keyCacheCreationTokens: null, + keyCacheReadTokens: null, + keyCacheCreation5mTokens: null, + keyCacheCreation1hTokens: null, + }), + buildSummaryRow({ + model: "mC", + userRequests: 1, + userCost: "2", + keyRequests: 0, + keyCost: null, + }), + ]) + ); + + const { getMyStatsSummary } = await importMyUsage(); + const result = await getMyStatsSummary({ startDate: "2024-01-01", endDate: "2024-01-31" }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.totalRequests).toBe(5); + expect(result.data.totalCost).toBe(6); + expect(result.data.totalInputTokens).toBe(100); + expect(result.data.totalOutputTokens).toBe(50); + expect(result.data.totalCacheCreationTokens).toBe(5); + expect(result.data.totalCacheReadTokens).toBe(6); + expect(result.data.totalTokens).toBe(161); + expect(result.data.currencyCode).toBe("USD"); + + expect(result.data.keyModelBreakdown.map((item) => item.model)).toEqual(["mB", "mA"]); + expect(result.data.keyModelBreakdown[0]).toMatchObject({ + model: "mB", + requests: 3, + cost: 4.5, + inputTokens: null, + cacheCreationTokens: null, + }); + expect(result.data.userModelBreakdown).toHaveLength(3); + expect(result.data.userModelBreakdown[1]).toMatchObject({ model: "mB", cost: 0 }); + }); + + test("无日期过滤:应查询全量并返回空聚合", async () => { + const { getMyStatsSummary } = await importMyUsage(); + const result = await getMyStatsSummary(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.totalRequests).toBe(0); + expect(result.data.totalCost).toBe(0); + expect(result.data.keyModelBreakdown).toEqual([]); + expect(result.data.userModelBreakdown).toEqual([]); + }); + + test("未授权与查询异常:应返回错误结果", async () => { + mocks.getSession.mockResolvedValueOnce(null); + const { getMyStatsSummary } = await importMyUsage(); + await expect(getMyStatsSummary()).resolves.toEqual({ ok: false, error: "Unauthorized" }); + + mocks.select.mockImplementation(() => { + throw new Error("db down"); + }); + await expect(getMyStatsSummary()).resolves.toEqual({ + ok: false, + error: "Failed to get statistics summary", + }); + }); +}); + +describe("getMyQuota", () => { + function mockQuotaDependencies() { + mocks.getTimeRangeForPeriodWithMode.mockResolvedValue({ + startTime: new Date("2024-06-01T00:00:00.000Z"), + endTime: new Date("2024-06-02T00:00:00.000Z"), + }); + mocks.getTimeRangeForPeriod.mockResolvedValue({ + startTime: new Date("2024-06-01T00:00:00.000Z"), + endTime: new Date("2024-06-02T00:00:00.000Z"), + }); + mocks.sumKeyQuotaCostsById.mockResolvedValue({ + cost5h: 1, + costDaily: 2, + costWeekly: 3, + costMonthly: 4, + costTotal: 5, + }); + mocks.sumUserQuotaCosts.mockResolvedValue({ + cost5h: 6, + costDaily: 7, + costWeekly: 8, + costMonthly: 9, + costTotal: 10, + }); + mocks.getCurrentCost.mockResolvedValue(0.5); + mocks.getKeySessionCount.mockResolvedValue(1); + mocks.getUserSessionCount.mockResolvedValue(2); + } + + test("fixed 5h 模式:应使用 RateLimitService 的固定窗口消费", async () => { + mockQuotaDependencies(); + mocks.getSession.mockResolvedValue( + buildSession({ + key: { + limit5hResetMode: "fixed", + limit5hUsd: 20, + limitDailyUsd: 30, + limitWeeklyUsd: 40, + limitMonthlyUsd: 50, + limitTotalUsd: 60, + limitConcurrentSessions: 3, + costResetAt: null, + }, + user: { + limit5hResetMode: "fixed", + limit5hUsd: 21, + limitWeeklyUsd: 41, + limitMonthlyUsd: 51, + limitTotalUsd: 61, + limitConcurrentSessions: 4, + rpm: 60, + dailyQuota: 31, + costResetAt: null, + limit5hCostResetAt: null, + allowedModels: ["claude-3"], + allowedClients: ["claude-cli"], + }, + }) + ); + + const { getMyQuota } = await importMyUsage(); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toMatchObject({ + keyLimit5hUsd: 20, + keyCurrent5hUsd: 0.5, + keyCurrentDailyUsd: 2, + keyCurrentTotalUsd: 5, + keyCurrentConcurrentSessions: 1, + keyLimitConcurrentSessions: 3, + userLimit5hUsd: 21, + userCurrent5hUsd: 0.5, + userCurrentDailyUsd: 7, + userCurrentTotalUsd: 10, + userCurrentConcurrentSessions: 2, + userLimitConcurrentSessions: 4, + userRpmLimit: 60, + userLimitDailyUsd: 31, + userAllowedModels: ["claude-3"], + userAllowedClients: ["claude-cli"], + }); + expect(mocks.getCurrentCost).toHaveBeenCalledTimes(2); + }); + + test("rolling 5h 与缺省字段:应回退 DB 聚合与默认值", async () => { + mockQuotaDependencies(); + mocks.getSession.mockResolvedValue( + buildSession({ + key: { + dailyResetTime: undefined, + dailyResetMode: undefined, + limit5hResetMode: undefined, + limit5hUsd: undefined, + limitDailyUsd: undefined, + limitWeeklyUsd: undefined, + limitMonthlyUsd: undefined, + limitTotalUsd: undefined, + limitConcurrentSessions: undefined, + expiresAt: undefined, + providerGroup: undefined, + isEnabled: undefined, + }, + user: { + dailyResetTime: undefined, + dailyResetMode: undefined, + limit5hResetMode: undefined, + limit5hUsd: undefined, + limitWeeklyUsd: undefined, + limitMonthlyUsd: undefined, + limitTotalUsd: undefined, + limitConcurrentSessions: undefined, + rpm: undefined, + dailyQuota: undefined, + expiresAt: undefined, + providerGroup: undefined, + isEnabled: undefined, + allowedModels: undefined, + allowedClients: undefined, + }, + }) + ); + + const { getMyQuota } = await importMyUsage(); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toMatchObject({ + keyLimit5hUsd: null, + keyCurrent5hUsd: 1, + userCurrent5hUsd: 6, + userLimitConcurrentSessions: null, + userRpmLimit: null, + userLimitDailyUsd: null, + userAllowedModels: [], + userAllowedClients: [], + keyIsEnabled: true, + userIsEnabled: true, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + expiresAt: null, + }); + expect(mocks.getCurrentCost).not.toHaveBeenCalled(); + }); + + test("未授权与依赖异常:应返回错误结果", async () => { + const { getMyQuota } = await importMyUsage(); + + mocks.getSession.mockResolvedValueOnce(null); + await expect(getMyQuota()).resolves.toEqual({ ok: false, error: "Unauthorized" }); + + mockQuotaDependencies(); + mocks.getTimeRangeForPeriodWithMode.mockRejectedValue(new Error("time utils down")); + await expect(getMyQuota()).resolves.toEqual({ + ok: false, + error: "Failed to get quota information", + }); + }); +}); + +describe("getMyIpGeoDetails", () => { + test("查询异常:应返回 OPERATION_FAILED 错误码", async () => { + mocks.select.mockImplementation(() => { + throw new Error("db down"); + }); + + const { getMyIpGeoDetails } = await importMyUsage(); + await expect(getMyIpGeoDetails({ ip: "1.2.3.4" })).resolves.toEqual({ + ok: false, + error: "OPERATION_FAILED", + errorCode: ERROR_CODES.OPERATION_FAILED, + }); + }); +}); diff --git a/tests/unit/actions/providers-patch-actions-contract.test.ts b/tests/unit/actions/providers-patch-actions-contract.test.ts index dc4799a67..23da56913 100644 --- a/tests/unit/actions/providers-patch-actions-contract.test.ts +++ b/tests/unit/actions/providers-patch-actions-contract.test.ts @@ -188,7 +188,7 @@ describe("Provider Batch Patch Action Contracts", () => { const entry = [...applyLedger.values()].find( (candidate) => candidate.result.applyResult.undoToken === undoToken ); - if (!entry || !entry.undoAvailable) return { status: "expired" }; + if (!entry?.undoAvailable) return { status: "expired" }; if (entry.result.applyResult.operationId !== operationId) return { status: "conflict" }; let revertedCount = 0; for (const group of groups) { diff --git a/tests/unit/actions/providers-undo-engine.test.ts b/tests/unit/actions/providers-undo-engine.test.ts index 88e5745d9..ba7ee9b41 100644 --- a/tests/unit/actions/providers-undo-engine.test.ts +++ b/tests/unit/actions/providers-undo-engine.test.ts @@ -191,7 +191,7 @@ describe("Undo Provider Batch Patch Engine", () => { const entry = [...applyLedger.values()].find( (candidate) => candidate.result.applyResult.undoToken === undoToken ); - if (!entry || !entry.undoAvailable) return { status: "expired" }; + if (!entry?.undoAvailable) return { status: "expired" }; if (entry.result.applyResult.operationId !== operationId) return { status: "conflict" }; let revertedCount = 0; for (const group of groups) { diff --git a/tests/unit/actions/system-config-stream-gate-affinity-settings.test.ts b/tests/unit/actions/system-config-stream-gate-affinity-settings.test.ts new file mode 100644 index 000000000..74870c9dc --- /dev/null +++ b/tests/unit/actions/system-config-stream-gate-affinity-settings.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +describe("streamGateMode / affinityIgnoreClientSessionId system settings", () => { + test("default to enforce / enabled in the DB-row transformer", async () => { + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + expect(toSystemSettings(undefined).streamGateMode).toBe("enforce"); + expect(toSystemSettings(undefined).affinityIgnoreClientSessionId).toBe(true); + expect(toSystemSettings({ id: 1, siteTitle: "Claude Code Hub" }).streamGateMode).toBe( + "enforce" + ); + expect( + toSystemSettings({ id: 1, siteTitle: "Claude Code Hub" }).affinityIgnoreClientSessionId + ).toBe(true); + expect(toSystemSettings({ id: 1, streamGateMode: "shadow" }).streamGateMode).toBe("shadow"); + // varchar 脏值回落产品默认 + expect(toSystemSettings({ id: 1, streamGateMode: "bogus" }).streamGateMode).toBe("enforce"); + expect( + toSystemSettings({ id: 1, affinityIgnoreClientSessionId: false }) + .affinityIgnoreClientSessionId + ).toBe(false); + }); + + test("are accepted by the settings update validation schema", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const parsed = UpdateSystemSettingsSchema.parse({ + streamGateMode: "shadow", + affinityIgnoreClientSessionId: false, + }); + expect(parsed.streamGateMode).toBe("shadow"); + expect(parsed.affinityIgnoreClientSessionId).toBe(false); + + expect(() => UpdateSystemSettingsSchema.parse({ streamGateMode: "bogus" })).toThrow(); + + const empty = UpdateSystemSettingsSchema.parse({}); + expect(empty.streamGateMode).toBeUndefined(); + expect(empty.affinityIgnoreClientSessionId).toBeUndefined(); + }); + + test("are exposed by the v1 system settings response schema", async () => { + const { SystemSettingsSchema } = await import("@/lib/api/v1/schemas/system-config"); + + expect(Object.keys(SystemSettingsSchema.shape)).toContain("streamGateMode"); + expect(Object.keys(SystemSettingsSchema.shape)).toContain("affinityIgnoreClientSessionId"); + }); +}); diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index d8b488f28..748ecc650 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -115,6 +115,7 @@ describe("GET /api/leaderboard", () => { avgTokensPerSecond: 50, avgCostPerRequest: 0.05, avgCostPerMillionTokens: 10.0, + cacheCoefficientBp: 8600, }, ]); @@ -130,6 +131,7 @@ describe("GET /api/leaderboard", () => { // Additive fields must be present expect(entry).toHaveProperty("avgCostPerRequest", 0.05); expect(entry).toHaveProperty("avgCostPerMillionTokens", 10.0); + expect(entry).toHaveProperty("cacheCoefficientBp", 8600); // Formatted variants should exist expect(entry).toHaveProperty("avgCostPerRequestFormatted"); expect(entry).toHaveProperty("avgCostPerMillionTokensFormatted"); @@ -214,6 +216,7 @@ describe("GET /api/leaderboard", () => { totalInputTokens: 20000, totalTokens: 20000, cacheHitRate: 0.5, + cacheCoefficientBp: 6450, modelStats: [ { model: "claude-3-opus", @@ -242,6 +245,7 @@ describe("GET /api/leaderboard", () => { expect(body).toHaveLength(1); const entry = body[0]; + expect(entry).toHaveProperty("cacheCoefficientBp", 6450); expect(entry).toHaveProperty("modelStats"); expect(entry.modelStats).toHaveLength(2); expect(entry.modelStats[0]).toHaveProperty("model", "claude-3-opus"); diff --git a/tests/unit/api/v1/action-migration-matrix.test.ts b/tests/unit/api/v1/action-migration-matrix.test.ts index d5d202641..942a9f782 100644 --- a/tests/unit/api/v1/action-migration-matrix.test.ts +++ b/tests/unit/api/v1/action-migration-matrix.test.ts @@ -96,6 +96,7 @@ describe("v1 action migration matrix", () => { "keys", "key-quota", "providers", + "provider-cache-effectiveness", "provider-endpoints", "provider-groups", "model-prices", diff --git a/tests/unit/dashboard-logs-query-utils.test.ts b/tests/unit/dashboard-logs-query-utils.test.ts index 415dfe6b2..154758221 100644 --- a/tests/unit/dashboard-logs-query-utils.test.ts +++ b/tests/unit/dashboard-logs-query-utils.test.ts @@ -87,4 +87,23 @@ describe("dashboard logs url query utils", () => { const query = buildLogsUrlQuery({ minRetryCount: 0 }); expect(query.get("minRetry")).toBe("0"); }); + + test("parseLogsUrlFilters only maps actualResponseModelMismatch for 'true'", () => { + expect(parseLogsUrlFilters({ actualResponseModelMismatch: "true" })).toEqual( + expect.objectContaining({ actualResponseModelMismatch: true }) + ); + expect( + parseLogsUrlFilters({ actualResponseModelMismatch: "false" }).actualResponseModelMismatch + ).toBeUndefined(); + }); + + test("buildLogsUrlQuery prefers '!200' over an explicit statusCode", () => { + const query = buildLogsUrlQuery({ excludeStatusCode200: true, statusCode: 500 }); + expect(query.get("statusCode")).toBe("!200"); + }); + + test("buildLogsUrlQuery serializes actualResponseModelMismatch flag", () => { + const query = buildLogsUrlQuery({ actualResponseModelMismatch: true }); + expect(query.get("actualResponseModelMismatch")).toBe("true"); + }); }); diff --git a/tests/unit/dashboard-logs-time-range-utils.test.ts b/tests/unit/dashboard-logs-time-range-utils.test.ts index 33ae17a60..f9e4738dc 100644 --- a/tests/unit/dashboard-logs-time-range-utils.test.ts +++ b/tests/unit/dashboard-logs-time-range-utils.test.ts @@ -1,3 +1,4 @@ +import { format } from "date-fns"; import { describe, expect, test } from "vitest"; import { dateStringWithClockToTimestamp, @@ -5,6 +6,7 @@ import { getQuickDateRange, inclusiveEndTimestampFromExclusive, parseClockString, + type QuickPeriod, } from "@/app/[locale]/dashboard/logs/_utils/time-range"; describe("dashboard logs time range utils", () => { @@ -68,4 +70,49 @@ describe("dashboard logs time range utils", () => { endDate: "2024-01-02", }); }); + + test("formatClockFromTimestamp renders the clock in the given timezone", () => { + const ts = Date.UTC(2024, 0, 1, 12, 34, 56); + expect(formatClockFromTimestamp(ts, "UTC")).toBe("12:34:56"); + expect(formatClockFromTimestamp(ts, "Asia/Shanghai")).toBe("20:34:56"); + }); + + test("dateStringWithClockToTimestamp interprets date + clock in the given timezone", () => { + const ts = dateStringWithClockToTimestamp("2024-01-01", "08:00:00", "Asia/Shanghai"); + expect(ts).toBe(Date.UTC(2024, 0, 1, 0, 0, 0)); + }); + + test("dateStringWithClockToTimestamp rejects month/day overflow", () => { + expect(dateStringWithClockToTimestamp("2024-02-30", "00:00:00")).toBeUndefined(); + expect(dateStringWithClockToTimestamp("2024-01-01", "24:00:00")).toBeUndefined(); + }); + + test("getQuickDateRange computes last7days/last30days windows", () => { + const now = new Date("2024-01-31T12:00:00Z"); + const tz = "UTC"; + + expect(getQuickDateRange("last7days", tz, now)).toEqual({ + startDate: "2024-01-25", + endDate: "2024-01-31", + }); + expect(getQuickDateRange("last30days", tz, now)).toEqual({ + startDate: "2024-01-02", + endDate: "2024-01-31", + }); + }); + + test("getQuickDateRange falls back to today for unknown periods without timezone", () => { + const now = new Date(2024, 0, 15, 12, 0, 0); + const range = getQuickDateRange("unknown" as unknown as QuickPeriod, undefined, now); + expect(range).toEqual({ startDate: "2024-01-15", endDate: "2024-01-15" }); + }); + + test("getQuickDateRange defaults to the current time", () => { + const before = format(new Date(), "yyyy-MM-dd"); + const range = getQuickDateRange("today"); + const after = format(new Date(), "yyyy-MM-dd"); + + expect([before, after]).toContain(range.startDate); + expect(range.endDate).toBe(range.startDate); + }); }); diff --git a/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx new file mode 100644 index 000000000..59e659059 --- /dev/null +++ b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx @@ -0,0 +1,164 @@ +/** + * @vitest-environment happy-dom + */ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LeaderboardView } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-view"; + +const fetchMock = vi.fn(); +const { getAllUserTagsMock, getAllUserKeyGroupsMock } = vi.hoisted(() => ({ + getAllUserTagsMock: vi.fn(), + getAllUserKeyGroupsMock: vi.fn(), +})); +const searchParamsState = vi.hoisted(() => ({ + value: new URLSearchParams(), +})); +const tMock = vi.hoisted(() => vi.fn((key: string) => key)); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParamsState.value, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => tMock, + useTimeZone: () => "Asia/Shanghai", +})); + +vi.mock("@/lib/api-client/v1/actions/users", () => ({ + getAllUserTags: getAllUserTagsMock, + getAllUserKeyGroups: getAllUserKeyGroupsMock, +})); + +vi.mock("@/app/[locale]/settings/providers/_components/provider-type-filter", () => ({ + ProviderTypeFilter: ({ value }: { value: string }) => ( +
{value}
+ ), +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, href, ...props }: any) => ( + + {children} + + ), +})); + +const globalFetch = global.fetch; + +function cacheHitEntry(overrides: Record) { + return { + providerId: 1, + providerName: "provider-a", + totalRequests: 10, + totalCost: 2.5, + totalCostFormatted: "$2.50", + cacheReadTokens: 500, + cacheCreationCost: 0.2, + totalInputTokens: 1000, + totalTokens: 1000, + cacheHitRate: 0.5, + cacheCoefficientBp: null, + modelStats: [], + ...overrides, + }; +} + +describe("LeaderboardView cache coefficient column", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + searchParamsState.value = new URLSearchParams("scope=providerCacheHitRate"); + getAllUserTagsMock.mockResolvedValue({ ok: true, data: [] }); + getAllUserKeyGroupsMock.mockResolvedValue({ ok: true, data: [] }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + global.fetch = fetchMock as typeof fetch; + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + global.fetch = globalFetch; + }); + + it("renders the coefficient as bp/10000 on the provider cache hit rate board", async () => { + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=providerCacheHitRate")) { + return { + ok: true, + json: async () => [ + cacheHitEntry({ + providerId: 1, + providerName: "with-coefficient", + cacheCoefficientBp: 8600, + }), + cacheHitEntry({ + providerId: 2, + providerName: "without-coefficient", + cacheCoefficientBp: null, + }), + ], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const text = container!.textContent ?? ""; + expect(text).toContain("columns.cacheCoefficient"); + expect(text).toContain("0.86"); + expect(text).toContain("–"); + }); + + it("renders the coefficient column on the provider usage board too", async () => { + searchParamsState.value = new URLSearchParams("scope=provider"); + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=provider") && !url.includes("providerCacheHitRate")) { + return { + ok: true, + json: async () => [ + { + providerId: 3, + providerName: "usage-provider", + totalRequests: 12, + totalCost: 4.2, + totalCostFormatted: "$4.20", + totalTokens: 2400, + successRate: 0.9, + avgTtfbMs: 150, + avgTokensPerSecond: 42, + avgCostPerRequest: 0.35, + avgCostPerMillionTokens: 1750, + cacheCoefficientBp: 1234, + modelStats: [], + }, + ], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const text = container!.textContent ?? ""; + expect(text).toContain("columns.cacheCoefficient"); + expect(text).toContain("0.12"); + }); +}); diff --git a/tests/unit/lib/cache-effectiveness-gate.test.ts b/tests/unit/lib/cache-effectiveness-gate.test.ts new file mode 100644 index 000000000..f0ba9c62a --- /dev/null +++ b/tests/unit/lib/cache-effectiveness-gate.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { FingerprintBoundary } from "@/app/v1/_lib/proxy/affinity/fingerprint"; +import type { SessionAffinityState } from "@/app/v1/_lib/proxy/session"; +import { + CACHE_SCORE_EXCLUDED, + type CacheScoreInput, + computeCacheScoreFields, +} from "@/lib/cache-effectiveness/gate"; + +function boundary(depth: number, fp: string, prefixBytes: number): FingerprintBoundary { + return { depth, fp, prefixBytes }; +} + +function makeAffinity(overrides: Partial = {}): SessionAffinityState { + return { + scopeTag: "k42", + chain: { + sys: boundary(0, "sysfp", 41), + tail: [boundary(1, "tailfp1", 80), boundary(2, "tipfp", 103)], + }, + nominatedProviderId: null, + matchedFp: null, + matchedTier: null, + ...overrides, + }; +} + +function makeInput(overrides: Partial = {}): CacheScoreInput { + return { + affinity: makeAffinity(), + succeeded: true, + usageObservable: true, + streamTruncated: false, + cacheTtl: null, + ...overrides, + }; +} + +describe("computeCacheScoreFields", () => { + it("returns no_affinity_key with all-null fields when affinity is missing", () => { + expect(computeCacheScoreFields(makeInput({ affinity: null }))).toEqual({ + cacheCompatibilityKey: null, + cacheScoreEligible: false, + cacheScoreExcludedReason: CACHE_SCORE_EXCLUDED.noAffinityKey, + theoreticalCacheTokens: null, + cacheTtlBucket: null, + }); + }); + + it("returns no_affinity_key when no boundary yields a fingerprint", () => { + const affinity = makeAffinity({ + matchedFp: null, + chain: { sys: boundary(0, "", 41), tail: [] }, + }); + const result = computeCacheScoreFields(makeInput({ affinity })); + expect(result.cacheScoreEligible).toBe(false); + expect(result.cacheScoreExcludedReason).toBe(CACHE_SCORE_EXCLUDED.noAffinityKey); + expect(result.cacheCompatibilityKey).toBeNull(); + }); + + it("excludes failed attempts but keeps key, theoretical tokens and ttl bucket", () => { + const result = computeCacheScoreFields(makeInput({ succeeded: false })); + expect(result).toEqual({ + cacheCompatibilityKey: "k42:tipfp", + cacheScoreEligible: false, + cacheScoreExcludedReason: CACHE_SCORE_EXCLUDED.attemptFailed, + theoreticalCacheTokens: 25, + cacheTtlBucket: "5m", + }); + }); + + it("excludes attempts without observable usage", () => { + const result = computeCacheScoreFields(makeInput({ usageObservable: false })); + expect(result.cacheScoreEligible).toBe(false); + expect(result.cacheScoreExcludedReason).toBe(CACHE_SCORE_EXCLUDED.notObservable); + }); + + it("excludes truncated streams", () => { + const result = computeCacheScoreFields(makeInput({ streamTruncated: true })); + expect(result.cacheScoreEligible).toBe(false); + expect(result.cacheScoreExcludedReason).toBe(CACHE_SCORE_EXCLUDED.streamTruncated); + }); + + it("short-circuits in gate order: attempt_failed wins over later exclusions", () => { + const result = computeCacheScoreFields( + makeInput({ succeeded: false, usageObservable: false, streamTruncated: true }) + ); + expect(result.cacheScoreExcludedReason).toBe(CACHE_SCORE_EXCLUDED.attemptFailed); + }); + + it("marks fully passing attempts eligible with scopeTag:fp key and floored tokens", () => { + const result = computeCacheScoreFields(makeInput()); + expect(result).toEqual({ + cacheCompatibilityKey: "k42:tipfp", + cacheScoreEligible: true, + cacheScoreExcludedReason: null, + // floor(103 / 4) + theoreticalCacheTokens: 25, + cacheTtlBucket: "5m", + }); + }); + + it("passes through a concrete ttl and defaults empty string to 5m", () => { + expect(computeCacheScoreFields(makeInput({ cacheTtl: "1h" })).cacheTtlBucket).toBe("1h"); + expect(computeCacheScoreFields(makeInput({ cacheTtl: "" })).cacheTtlBucket).toBe("5m"); + }); + + it("prefers matchedFp over tip for the key while tokens still follow the tip", () => { + const result = computeCacheScoreFields( + makeInput({ affinity: makeAffinity({ matchedFp: "matchedfp" }) }) + ); + expect(result.cacheCompatibilityKey).toBe("k42:matchedfp"); + expect(result.theoreticalCacheTokens).toBe(25); + }); + + it("falls back from tip to sys when the tail is empty", () => { + const affinity = makeAffinity({ chain: { sys: boundary(0, "sysfp", 41), tail: [] } }); + const result = computeCacheScoreFields(makeInput({ affinity })); + expect(result.cacheCompatibilityKey).toBe("k42:sysfp"); + // tip falls back to sys: floor(41 / 4) + expect(result.theoreticalCacheTokens).toBe(10); + }); +}); diff --git a/tests/unit/lib/redis-list-store.test.ts b/tests/unit/lib/redis-list-store.test.ts new file mode 100644 index 000000000..bf17fab36 --- /dev/null +++ b/tests/unit/lib/redis-list-store.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { RedisListStore } from "@/lib/redis/redis-list-store"; + +function createMockClient() { + return { + status: "ready", + eval: vi.fn().mockResolvedValue(3), + lrange: vi.fn().mockResolvedValue(["a", "b"]), + llen: vi.fn().mockResolvedValue(2), + expire: vi.fn().mockResolvedValue(1), + del: vi.fn().mockResolvedValue(1), + }; +} + +describe("RedisListStore", () => { + it("rpushBatch atomically appends values with prefix and TTL in a single Lua eval", async () => { + const client = createMockClient(); + const store = new RedisListStore({ prefix: "cch:replay:", redisClient: client as never }); + const length = await store.rpushBatch("k1:chunks", ["c1", "c2"], 600); + expect(length).toBe(3); + expect(client.eval).toHaveBeenCalledTimes(1); + expect(client.eval).toHaveBeenCalledWith( + expect.stringContaining("RPUSH"), + 1, + "cch:replay:k1:chunks", + 600, + "c1", + "c2" + ); + const script = client.eval.mock.calls[0][0] as string; + expect(script).toContain("EXPIRE"); + }); + + it("rpushBatch passes ttl 0 (no expire branch) when ttlSeconds is absent or non-positive", async () => { + const client = createMockClient(); + const store = new RedisListStore({ prefix: "p:", redisClient: client as never }); + await store.rpushBatch("k", ["v"]); + expect(client.eval).toHaveBeenLastCalledWith(expect.any(String), 1, "p:k", 0, "v"); + await store.rpushBatch("k", ["v"], 0); + expect(client.eval).toHaveBeenLastCalledWith(expect.any(String), 1, "p:k", 0, "v"); + }); + + it("rpushBatch skips empty batches", async () => { + const client = createMockClient(); + const store = new RedisListStore({ prefix: "p:", redisClient: client as never }); + expect(await store.rpushBatch("k", [])).toBeNull(); + expect(client.eval).not.toHaveBeenCalled(); + }); + + it("lrangeFrom reads from offset to end", async () => { + const client = createMockClient(); + const store = new RedisListStore({ prefix: "p:", redisClient: client as never }); + expect(await store.lrangeFrom("k", 5)).toEqual(["a", "b"]); + expect(client.lrange).toHaveBeenCalledWith("p:k", 5, -1); + }); + + it("fails open (null/false) when redis is unavailable", async () => { + const store = new RedisListStore({ prefix: "p:", redisClient: null }); + expect(await store.rpushBatch("k", ["v"])).toBeNull(); + expect(await store.lrangeFrom("k", 0)).toBeNull(); + expect(await store.llen("k")).toBeNull(); + expect(await store.expire("k", 60)).toBe(false); + expect(await store.delete("k")).toBe(false); + }); + + it("fails open when redis client is not ready", async () => { + const client = { ...createMockClient(), status: "connecting" }; + const store = new RedisListStore({ prefix: "p:", redisClient: client as never }); + expect(await store.llen("k")).toBeNull(); + expect(client.llen).not.toHaveBeenCalled(); + }); + + it("fails open (null) when a redis command throws", async () => { + const client = createMockClient(); + client.eval.mockRejectedValue(new Error("boom")); + client.lrange.mockRejectedValue(new Error("boom")); + const store = new RedisListStore({ prefix: "p:", redisClient: client as never }); + expect(await store.rpushBatch("k", ["v"], 600)).toBeNull(); + expect(await store.lrangeFrom("k", 0)).toBeNull(); + }); +}); diff --git a/tests/unit/lib/redis/session-binding.test.ts b/tests/unit/lib/redis/session-binding.test.ts index 2831116b9..15ecd8a69 100644 --- a/tests/unit/lib/redis/session-binding.test.ts +++ b/tests/unit/lib/redis/session-binding.test.ts @@ -441,6 +441,31 @@ describe("session Discovery lease operations", () => { }); }); + it("parses Buffer and null lease mutation flags from Lua results", async () => { + const mock = createMockRedis({ + operationResponses: { + [RENEW_SESSION_DISCOVERY_LEASE]: [Buffer.from("1"), null], + [RELEASE_SESSION_DISCOVERY_LEASE]: [Buffer.from("0")], + }, + }); + const identity = { sessionId: "sid", keyId: 4, ownerToken: "owner-a", redis: mock.redis }; + + await expect(renewSessionDiscoveryLease({ ...identity, ttlSeconds: 45 })).resolves.toEqual({ + status: "renewed", + legacyFallbackAllowed: false, + }); + await expect(renewSessionDiscoveryLease({ ...identity, ttlSeconds: 45 })).resolves.toEqual({ + status: "lost", + reason: "not_owner_or_missing", + legacyFallbackAllowed: false, + }); + await expect(releaseSessionDiscoveryLease(identity)).resolves.toEqual({ + status: "lost", + reason: "not_owner_or_missing", + legacyFallbackAllowed: false, + }); + }); + it("rejects invalid identities before touching Redis", async () => { const mock = createMockRedis(); @@ -617,6 +642,46 @@ describe("versioned session binding operations", () => { expect(getVersionedBindingCapabilityState()).toBe("available"); }); + it("normalizes numeric Lua values into string binding fields", async () => { + const mock = createMockRedis({ + operationResponses: { + [READ_OR_RECONCILE_SESSION_BINDING]: [["ok", "existing", 1710000, 8]], + }, + }); + + const result = await readOrReconcileSessionBinding({ + sessionId: "sid", + keyId: 2, + redis: mock.redis, + }); + + expect(result).toMatchObject({ + status: "ok", + source: "existing", + snapshot: { sessionId: "sid", keyId: 2, generation: "1710000", providerId: 8 }, + }); + }); + + it("fails closed when the Lua result is truncated or lacks a generation", async () => { + const mock = createMockRedis({ + operationResponses: { + [READ_OR_RECONCILE_SESSION_BINDING]: [["ok"], ["ok", "existing", "", "8"]], + }, + }); + const identity = { sessionId: "sid", keyId: 2, redis: mock.redis }; + + await expect(readOrReconcileSessionBinding(identity)).resolves.toEqual({ + status: "conflict", + reason: "canonical_corrupt", + legacyFallbackAllowed: false, + }); + await expect(readOrReconcileSessionBinding(identity)).resolves.toEqual({ + status: "conflict", + reason: "canonical_corrupt", + legacyFallbackAllowed: false, + }); + }); + it("CAS updates the provider and rotates generation", async () => { const mock = createMockRedis({ operationResponses: { diff --git a/tests/unit/lib/request-identity.test.ts b/tests/unit/lib/request-identity.test.ts new file mode 100644 index 000000000..400aef5bb --- /dev/null +++ b/tests/unit/lib/request-identity.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + buildScopeTag, + canonicalRequestBytes, + sha256Hex, + stableStringify, +} from "@/lib/request-identity"; + +describe("sha256Hex", () => { + it("is deterministic and accepts string or bytes", () => { + expect(sha256Hex("abc")).toBe(sha256Hex("abc")); + expect(sha256Hex("abc")).toBe(sha256Hex(new TextEncoder().encode("abc"))); + expect(sha256Hex("abc")).toMatch(/^[0-9a-f]{64}$/); + expect(sha256Hex("abc")).not.toBe(sha256Hex("abd")); + }); +}); + +describe("stableStringify", () => { + it("sorts object keys recursively", () => { + expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}'); + }); + + it("preserves array order", () => { + expect(stableStringify([3, 1, 2])).toBe("[3,1,2]"); + expect(stableStringify({ arr: [{ b: 1, a: 2 }] })).toBe('{"arr":[{"a":2,"b":1}]}'); + }); + + it("drops undefined object members and handles null", () => { + expect(stableStringify({ a: undefined, b: null })).toBe('{"b":null}'); + expect(stableStringify(null)).toBe("null"); + }); + + it("is insertion-order independent", () => { + const first = JSON.parse('{"x":1,"y":{"p":true,"q":"s"}}'); + const second = JSON.parse('{"y":{"q":"s","p":true},"x":1}'); + expect(stableStringify(first)).toBe(stableStringify(second)); + }); +}); + +describe("canonicalRequestBytes", () => { + it("prefers the raw body buffer byte-for-byte", () => { + const raw = new TextEncoder().encode('{"model":"m","messages":[]}'); + const buffer = raw.buffer.slice(0) as ArrayBuffer; + const bytes = canonicalRequestBytes({ buffer, message: { different: true } }); + expect(new TextDecoder().decode(bytes)).toBe('{"model":"m","messages":[]}'); + }); + + it("falls back to stable serialization of the parsed message", () => { + const bytesA = canonicalRequestBytes({ message: { b: 1, a: 2 } }); + const bytesB = canonicalRequestBytes({ message: JSON.parse('{"a":2,"b":1}') }); + expect(new TextDecoder().decode(bytesA)).toBe(new TextDecoder().decode(bytesB)); + }); +}); + +describe("buildScopeTag", () => { + it("returns 16 hex chars and separates tenants / formats / models", () => { + const tag = buildScopeTag(1, "claude", "sonnet"); + expect(tag).toMatch(/^[0-9a-f]{16}$/); + expect(buildScopeTag(2, "claude", "sonnet")).not.toBe(tag); + expect(buildScopeTag(1, "openai", "sonnet")).not.toBe(tag); + expect(buildScopeTag(1, "claude", "opus")).not.toBe(tag); + expect(buildScopeTag(1, "claude", "sonnet")).toBe(tag); + }); + + it("treats null and undefined model identically", () => { + expect(buildScopeTag(1, "claude", null)).toBe(buildScopeTag(1, "claude", undefined)); + }); +}); diff --git a/tests/unit/lib/utils/clipboard.test.ts b/tests/unit/lib/utils/clipboard.test.ts index 443710f91..11cc2f854 100644 --- a/tests/unit/lib/utils/clipboard.test.ts +++ b/tests/unit/lib/utils/clipboard.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { copyTextToClipboard, copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; +import { + copyTextToClipboard, + copyToClipboard, + isClipboardReadSupported, + isClipboardSupported, + readFromClipboard, +} from "@/lib/utils/clipboard"; function stubSecureContext(value: boolean) { Object.defineProperty(window, "isSecureContext", { @@ -16,6 +22,13 @@ function stubClipboard(writeText: (text: string) => Promise | void) { }); } +function stubClipboardRead(readText: () => Promise | string) { + Object.defineProperty(navigator, "clipboard", { + value: { readText }, + configurable: true, + }); +} + function stubExecCommand(impl: (command: string) => boolean) { Object.defineProperty(document, "execCommand", { value: impl, @@ -116,4 +129,50 @@ describe("clipboard utils", () => { await expect(copyTextToClipboard("abc")).resolves.toBe(false); }); + + test("SSR 环境:isClipboardReadSupported/readFromClipboard 应返回不支持", async () => { + vi.stubGlobal("window", undefined as unknown as Window); + + expect(isClipboardReadSupported()).toBe(false); + await expect(readFromClipboard()).resolves.toBeNull(); + }); + + test("isClipboardReadSupported: 仅在安全上下文且 readText 可用时为 true", () => { + stubSecureContext(false); + stubClipboardRead(vi.fn()); + expect(isClipboardReadSupported()).toBe(false); + + stubSecureContext(true); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + expect(isClipboardReadSupported()).toBe(false); + + stubSecureContext(true); + stubClipboardRead(vi.fn()); + expect(isClipboardReadSupported()).toBe(true); + }); + + test("readFromClipboard: readText 成功时返回剪贴板文本", async () => { + stubSecureContext(true); + const readText = vi.fn().mockResolvedValue("hello"); + stubClipboardRead(readText); + + await expect(readFromClipboard()).resolves.toBe("hello"); + expect(readText).toHaveBeenCalledTimes(1); + }); + + test("readFromClipboard: readText 抛错时返回 null", async () => { + stubSecureContext(true); + const readText = vi.fn().mockRejectedValue(new Error("denied")); + stubClipboardRead(readText); + + await expect(readFromClipboard()).resolves.toBeNull(); + expect(readText).toHaveBeenCalledTimes(1); + }); + + test("readFromClipboard: 不支持读取时返回 null", async () => { + stubSecureContext(false); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + + await expect(readFromClipboard()).resolves.toBeNull(); + }); }); diff --git a/tests/unit/proxy/affinity-fingerprint.test.ts b/tests/unit/proxy/affinity-fingerprint.test.ts new file mode 100644 index 000000000..377ebc144 --- /dev/null +++ b/tests/unit/proxy/affinity-fingerprint.test.ts @@ -0,0 +1,597 @@ +import { describe, expect, it } from "vitest"; +import { + computeFingerprintChain, + DEFAULT_AFFINITY_WINDOW, + type FingerprintChain, + fingerprintsDeepestFirst, + fingerprintTip, + MAX_AFFINITY_WINDOW, +} from "@/app/v1/_lib/proxy/affinity/fingerprint"; + +const HEX32 = /^[0-9a-f]{32}$/; + +function claudeBody( + messages: unknown[], + overrides: Record = {} +): Record { + return { + model: "claude-sonnet-4-5", + system: "You are a helpful assistant.", + tools: [ + { + name: "read_file", + description: "Read a file", + input_schema: { type: "object", properties: { path: { type: "string" } } }, + }, + { + name: "bash", + description: "Run a command", + input_schema: { type: "object", properties: { cmd: { type: "string" } } }, + }, + ], + messages, + ...overrides, + }; +} + +const U1 = { role: "user", content: "hello" }; +const A1 = { role: "assistant", content: [{ type: "text", text: "hi there" }] }; +const U2 = { role: "user", content: "next question" }; + +function mustChain(body: Record, format = "claude", window?: number) { + const chain = computeFingerprintChain( + body, + format as Parameters[1], + window + ); + expect(chain).not.toBeNull(); + return chain as FingerprintChain; +} + +function allFps(chain: FingerprintChain): string[] { + return [chain.sys.fp, ...chain.tail.map((b) => b.fp)]; +} + +describe("computeFingerprintChain - determinism", () => { + it("produces identical chains for identical input", () => { + const a = mustChain(claudeBody([U1, A1, U2])); + const b = mustChain(claudeBody([U1, A1, U2])); + expect(a).toEqual(b); + }); + + it("emits 32-hex fingerprints and monotonically increasing prefixBytes", () => { + const chain = mustChain(claudeBody([U1, A1])); + expect(chain.sys.fp).toMatch(HEX32); + expect(chain.sys.depth).toBe(0); + let prevBytes = chain.sys.prefixBytes; + for (const [i, boundary] of chain.tail.entries()) { + expect(boundary.fp).toMatch(HEX32); + expect(boundary.depth).toBe(i + 1); + expect(boundary.prefixBytes).toBeGreaterThan(prevBytes); + prevBytes = boundary.prefixBytes; + } + }); +}); + +describe("computeFingerprintChain - prefix extension", () => { + it("appending a message extends the chain at the tail without changing prior boundaries", () => { + const base = mustChain(claudeBody([U1, A1])); + const extended = mustChain(claudeBody([U1, A1, U2])); + + expect(extended.sys).toEqual(base.sys); + expect(extended.tail).toHaveLength(3); + expect(extended.tail.slice(0, 2)).toEqual(base.tail); + expect(fingerprintTip(base).fp).toBe(extended.tail[1].fp); + expect(fingerprintTip(extended).fp).toBe(extended.tail[2].fp); + }); + + it("editing an early message changes that boundary and all deeper ones", () => { + const a = mustChain(claudeBody([U1, A1, U2])); + const b = mustChain(claudeBody([{ role: "user", content: "hello!" }, A1, U2])); + expect(b.sys.fp).toBe(a.sys.fp); + expect(b.tail[0].fp).not.toBe(a.tail[0].fp); + expect(b.tail[1].fp).not.toBe(a.tail[1].fp); + expect(b.tail[2].fp).not.toBe(a.tail[2].fp); + }); +}); + +describe("computeFingerprintChain - volatile field stripping", () => { + it("claude tool_use id and tool_result tool_use_id do not affect fingerprints", () => { + const withIds = (toolUseId: string) => [ + U1, + { + role: "assistant", + content: [{ type: "tool_use", id: toolUseId, name: "read_file", input: { path: "a.ts" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: "file body" }], + }, + ]; + const a = mustChain(claudeBody(withIds("toolu_aaa"))); + const b = mustChain(claudeBody(withIds("toolu_bbb"))); + expect(a).toEqual(b); + }); + + it("claude tool_use input changes DO affect fingerprints", () => { + const withInput = (path: string) => [ + U1, + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "read_file", input: { path } }], + }, + ]; + const a = mustChain(claudeBody(withInput("a.ts"))); + const b = mustChain(claudeBody(withInput("b.ts"))); + expect(b.tail[1].fp).not.toBe(a.tail[1].fp); + }); + + it("claude thinking signature is not hashed", () => { + const withSig = (signature: string) => [ + U1, + { + role: "assistant", + content: [{ type: "thinking", thinking: "step by step", signature }], + }, + ]; + const a = mustChain(claudeBody(withSig("sig-one"))); + const b = mustChain(claudeBody(withSig("sig-two"))); + expect(a).toEqual(b); + }); + + it("unknown block types strip volatile keys via default branch", () => { + const withId = (id: string) => [ + U1, + { + role: "assistant", + content: [{ type: "server_tool_use", id, name: "web_search", payload: { q: "x" } }], + }, + ]; + const a = mustChain(claudeBody(withId("srvtoolu_1"))); + const b = mustChain(claudeBody(withId("srvtoolu_2"))); + expect(a).toEqual(b); + }); + + it("openai tool_call id and tool message tool_call_id do not affect fingerprints", () => { + const body = (callId: string) => ({ + model: "gpt-4o", + messages: [ + { role: "user", content: "run it" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: callId, type: "function", function: { name: "bash", arguments: '{"cmd":"ls"}' } }, + ], + }, + { role: "tool", tool_call_id: callId, content: "ok" }, + ], + }); + const a = mustChain(body("call_x"), "openai"); + const b = mustChain(body("call_y"), "openai"); + expect(a).toEqual(b); + }); +}); + +describe("computeFingerprintChain - cache_control boundaries", () => { + it("marks hasCacheControl on the message boundary carrying an explicit breakpoint", () => { + const chain = mustChain( + claudeBody([ + U1, + { + role: "user", + content: [{ type: "text", text: "long context", cache_control: { type: "ephemeral" } }], + }, + ]) + ); + expect(chain.tail[0].hasCacheControl).toBeUndefined(); + expect(chain.tail[1].hasCacheControl).toBe(true); + }); + + it("cache_control marks the boundary without changing the fingerprint value", () => { + const plain = mustChain( + claudeBody([U1, { role: "user", content: [{ type: "text", text: "ctx" }] }]) + ); + const marked = mustChain( + claudeBody([ + U1, + { + role: "user", + content: [{ type: "text", text: "ctx", cache_control: { type: "ephemeral" } }], + }, + ]) + ); + expect(marked.tail[1].fp).toBe(plain.tail[1].fp); + expect(marked.tail[1].hasCacheControl).toBe(true); + }); +}); + +describe("computeFingerprintChain - window truncation", () => { + const manyMessages = Array.from({ length: 10 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `message ${i}`, + })); + + it("keeps only the deepest `window` boundaries and always retains sys", () => { + const full = mustChain(claudeBody(manyMessages), "claude", MAX_AFFINITY_WINDOW); + const cut = mustChain(claudeBody(manyMessages), "claude", 4); + + expect(full.tail).toHaveLength(10); + expect(cut.tail).toHaveLength(4); + expect(cut.tail).toEqual(full.tail.slice(-4)); + expect(cut.tail.map((b) => b.depth)).toEqual([7, 8, 9, 10]); + expect(cut.sys).toEqual(full.sys); + }); + + it("falls back to the default window for non-positive or non-finite values", () => { + for (const bad of [0, -3, Number.NaN, Number.POSITIVE_INFINITY]) { + const chain = mustChain(claudeBody(manyMessages), "claude", bad); + expect(chain.tail).toHaveLength(Math.min(10, DEFAULT_AFFINITY_WINDOW)); + } + }); + + it("caps the window at MAX_AFFINITY_WINDOW", () => { + const long = Array.from({ length: MAX_AFFINITY_WINDOW + 10 }, (_, i) => ({ + role: "user", + content: `m${i}`, + })); + const chain = mustChain(claudeBody(long), "claude", 1000); + expect(chain.tail).toHaveLength(MAX_AFFINITY_WINDOW); + }); +}); + +describe("computeFingerprintChain - system/tools sensitivity", () => { + it("system prompt change invalidates the whole chain", () => { + const a = mustChain(claudeBody([U1, A1])); + const b = mustChain(claudeBody([U1, A1], { system: "You are terse." })); + for (const [fa, fb] of allFps(a).map((f, i) => [f, allFps(b)[i]])) { + expect(fb).not.toBe(fa); + } + }); + + it("tool definition change invalidates the whole chain", () => { + const a = mustChain(claudeBody([U1, A1])); + const b = mustChain( + claudeBody([U1, A1], { + tools: [ + { name: "read_file", description: "Read file v2", input_schema: { type: "object" } }, + ], + }) + ); + for (const [fa, fb] of allFps(a).map((f, i) => [f, allFps(b)[i]])) { + expect(fb).not.toBe(fa); + } + }); + + it("tool ordering does not affect F_sys (sorted by name)", () => { + const base = claudeBody([U1]); + const reversed = claudeBody([U1], { + tools: [...(base.tools as unknown[])].reverse(), + }); + expect(mustChain(reversed)).toEqual(mustChain(base)); + }); +}); + +describe("computeFingerprintChain - media digest", () => { + const imageMessage = (data: string) => [ + { + role: "user", + content: [ + { type: "image", source: { type: "base64", media_type: "image/png", data } }, + { type: "text", text: "describe" }, + ], + }, + ]; + + it("same-length different image bytes produce different fingerprints", () => { + const a = mustChain(claudeBody(imageMessage("AAAABBBB"))); + const b = mustChain(claudeBody(imageMessage("AAAACCCC"))); + expect(a.tail[0].fp).not.toBe(b.tail[0].fp); + }); + + it("identical image bytes produce identical fingerprints", () => { + const a = mustChain(claudeBody(imageMessage("AAAABBBB"))); + const b = mustChain(claudeBody(imageMessage("AAAABBBB"))); + expect(a).toEqual(b); + }); + + it("url-based document sources hash the url", () => { + const doc = (url: string) => [ + { + role: "user", + content: [{ type: "document", source: { media_type: "application/pdf", url } }], + }, + ]; + const a = mustChain(claudeBody(doc("https://a.example/x.pdf"))); + const b = mustChain(claudeBody(doc("https://b.example/y.pdf"))); + expect(a.tail[0].fp).not.toBe(b.tail[0].fp); + }); +}); + +describe("computeFingerprintChain - edge cases", () => { + it("empty messages array yields sys-only chain without throwing", () => { + const chain = mustChain(claudeBody([])); + expect(chain.tail).toHaveLength(0); + expect(chain.sys.fp).toMatch(HEX32); + expect(fingerprintTip(chain)).toBe(chain.sys); + expect(fingerprintsDeepestFirst(chain)).toEqual([chain.sys.fp]); + }); + + it("missing system and tools still produce a valid chain", () => { + const chain = mustChain({ messages: [U1] }); + expect(chain.tail).toHaveLength(1); + }); + + it("messages with empty content arrays are skipped as empty boundaries", () => { + const chain = mustChain(claudeBody([{ role: "user", content: [] }, U1])); + expect(chain.tail).toHaveLength(1); + expect(chain.tail[0].depth).toBe(1); + }); + + it("returns null for missing messages, unknown formats and invalid input shape", () => { + expect(computeFingerprintChain({}, "claude")).toBeNull(); + expect(computeFingerprintChain({ messages: "nope" }, "claude")).toBeNull(); + expect( + computeFingerprintChain( + claudeBody([U1]), + "unknown" as Parameters[1] + ) + ).toBeNull(); + }); + + it("fails open (null) on pathological input instead of throwing", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const body = claudeBody([ + { role: "user", content: [{ type: "weird_block", payload: cyclic }] }, + ]); + expect(computeFingerprintChain(body, "claude")).toBeNull(); + }); +}); + +describe("computeFingerprintChain - openai leading system merge", () => { + it("leading system/developer messages fold into F_sys", () => { + const body = { + messages: [ + { role: "system", content: "sys prompt" }, + { role: "developer", content: "dev prompt" }, + { role: "user", content: "hi" }, + ], + }; + const chain = mustChain(body, "openai"); + expect(chain.tail).toHaveLength(1); + + const changed = mustChain( + { + messages: [ + { role: "system", content: "sys prompt CHANGED" }, + { role: "developer", content: "dev prompt" }, + { role: "user", content: "hi" }, + ], + }, + "openai" + ); + expect(changed.sys.fp).not.toBe(chain.sys.fp); + expect(changed.tail[0].fp).not.toBe(chain.tail[0].fp); + }); + + it("non-leading system messages stay in the conversation tail", () => { + const chain = mustChain( + { + messages: [ + { role: "user", content: "hi" }, + { role: "system", content: "late instruction" }, + ], + }, + "openai" + ); + expect(chain.tail).toHaveLength(2); + }); +}); + +describe("computeFingerprintChain - responses format", () => { + it("string input becomes a single user boundary", () => { + const chain = mustChain({ instructions: "be brief", input: "hello" }, "response"); + expect(chain.tail).toHaveLength(1); + }); + + it("function_call ids are stripped while name/arguments are hashed", () => { + const body = (callId: string) => ({ + instructions: "be brief", + input: [ + { type: "message", role: "user", content: "run" }, + { type: "function_call", call_id: callId, name: "bash", arguments: '{"cmd":"ls"}' }, + { type: "function_call_output", call_id: callId, output: "ok" }, + { type: "reasoning", summary: [{ type: "summary_text", text: "thought" }] }, + ], + }); + expect(mustChain(body("c1"), "response")).toEqual(mustChain(body("c2"), "response")); + }); + + it("instructions change invalidates the whole chain", () => { + const a = mustChain({ instructions: "be brief", input: "hello" }, "response"); + const b = mustChain({ instructions: "be verbose", input: "hello" }, "response"); + expect(b.sys.fp).not.toBe(a.sys.fp); + expect(b.tail[0].fp).not.toBe(a.tail[0].fp); + }); + + it("returns null when input is neither string nor array", () => { + expect(computeFingerprintChain({ input: 42 }, "response")).toBeNull(); + }); +}); + +describe("computeFingerprintChain - gemini formats", () => { + const geminiBody = { + systemInstruction: { parts: [{ text: "sys" }] }, + tools: [ + { + functionDeclarations: [ + { name: "search", description: "Search", parameters: { type: "object" } }, + ], + }, + ], + contents: [ + { role: "user", parts: [{ text: "hi" }] }, + { + role: "model", + parts: [{ functionCall: { name: "search", args: { q: "x" } } }], + }, + { + role: "user", + parts: [ + { functionResponse: { name: "search", response: { hits: 1 }, id: "resp-1" } }, + { inlineData: { mimeType: "image/png", data: "AAAABBBB" } }, + ], + }, + ], + }; + + it("hashes system instruction, flattened tools, parts and media digests", () => { + const chain = mustChain(geminiBody, "gemini"); + expect(chain.tail).toHaveLength(3); + + const otherImage = structuredClone(geminiBody); + ( + (otherImage.contents[2].parts as Record[])[1].inlineData as Record< + string, + unknown + > + ).data = "AAAACCCC"; + expect(mustChain(otherImage, "gemini").tail[2].fp).not.toBe(chain.tail[2].fp); + }); + + it("gemini-cli wrapped request produces the same chain as bare gemini", () => { + const wrapped = mustChain({ request: geminiBody }, "gemini-cli"); + expect(wrapped).toEqual(mustChain(geminiBody, "gemini")); + }); + + it("returns null when contents is missing", () => { + expect(computeFingerprintChain({ systemInstruction: {} }, "gemini")).toBeNull(); + }); +}); + +describe("fingerprintsDeepestFirst", () => { + it("orders tail deepest-first with sys always last", () => { + const chain = mustChain(claudeBody([U1, A1, U2])); + const fps = fingerprintsDeepestFirst(chain); + expect(fps).toEqual([chain.tail[2].fp, chain.tail[1].fp, chain.tail[0].fp, chain.sys.fp]); + }); +}); + +describe("computeFingerprintChain - remaining normalization branches", () => { + it("supports claude system block arrays and skips non-object entries", () => { + const chain = mustChain( + claudeBody([null, U1, { role: "assistant", content: ["raw string block", null] }], { + system: [{ type: "text", text: "block system" }, "loose text"], + }) + ); + expect(chain.tail).toHaveLength(2); + + const otherSystem = mustChain( + claudeBody([null, U1, { role: "assistant", content: ["raw string block", null] }], { + system: [{ type: "text", text: "block system CHANGED" }, "loose text"], + }) + ); + expect(otherSystem.sys.fp).not.toBe(chain.sys.fp); + }); + + it("hashes redacted_thinking and media without a source", () => { + const chain = mustChain( + claudeBody([ + { + role: "assistant", + content: [ + { type: "redacted_thinking", data: "opaque" }, + { type: "image", source: null }, + ], + }, + ]) + ); + expect(chain.tail).toHaveLength(1); + }); + + it("hashes openai tools sorted by name and skips malformed entries", () => { + const tools = [ + { type: "function", function: { name: "b_tool", description: "B", parameters: {} } }, + { type: "function", function: { name: "a_tool", description: "A", parameters: {} } }, + null, + { type: "function", function: {} }, + ]; + const body = (order: unknown[]) => ({ + tools: order, + messages: [{ role: "user", content: "hi" }, null], + }); + const a = mustChain(body(tools), "openai"); + const b = mustChain(body([...tools].reverse()), "openai"); + expect(a).toEqual(b); + + const noTools = mustChain({ messages: [{ role: "user", content: "hi" }] }, "openai"); + expect(noTools.sys.fp).not.toBe(a.sys.fp); + }); + + it("skips non-object openai tool_calls entries", () => { + const chain = mustChain( + { + messages: [ + { role: "user", content: "run" }, + { role: "assistant", content: null, tool_calls: [null, "junk"] }, + ], + }, + "openai" + ); + expect(chain.tail).toHaveLength(2); + }); + + it("hashes responses tools and unknown input item types", () => { + const body = { + instructions: "sys", + tools: [{ name: "shell", description: "Run", parameters: { type: "object" } }], + input: [ + null, + { type: "custom_item", id: "vol-1", payload: { a: 1 } }, + { role: "user", content: "hi" }, + ], + }; + const a = mustChain(body, "response"); + expect(a.tail).toHaveLength(2); + + const otherId = structuredClone(body); + (otherId.input[1] as Record).id = "vol-2"; + expect(mustChain(otherId, "response")).toEqual(a); + }); + + it("covers gemini bare tool declarations, fileData, unknown parts and null entries", () => { + const body = { + tools: [{ name: "bare_tool", description: "Bare", parameters: { type: "object" } }, null], + contents: [ + null, + { + role: "user", + parts: [ + null, + { fileData: { mimeType: "video/mp4", fileUri: "gs://bucket/a.mp4" } }, + { unknownShape: true, id: "vol" }, + ], + }, + ], + }; + const a = mustChain(body, "gemini"); + expect(a.tail).toHaveLength(1); + + const otherUri = structuredClone(body); + const otherParts = (otherUri.contents[1] as { parts: Record[] }).parts; + (otherParts[1].fileData as Record).fileUri = "gs://bucket/b.mp4"; + expect(mustChain(otherUri, "gemini").tail[0].fp).not.toBe(a.tail[0].fp); + + const noTools = mustChain({ contents: body.contents }, "gemini"); + expect(noTools.sys.fp).not.toBe(a.sys.fp); + }); + + it("gemini-cli falls back to the top-level body when request is not an object", () => { + const chain = mustChain( + { request: "not-an-object", contents: [{ role: "user", parts: [{ text: "hi" }] }] }, + "gemini-cli" + ); + expect(chain.tail).toHaveLength(1); + }); +}); diff --git a/tests/unit/proxy/affinity-recorder.test.ts b/tests/unit/proxy/affinity-recorder.test.ts new file mode 100644 index 000000000..5605f4208 --- /dev/null +++ b/tests/unit/proxy/affinity-recorder.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FingerprintChain } from "@/app/v1/_lib/proxy/affinity/fingerprint"; +import type { ProxySession, SessionAffinityState } from "@/app/v1/_lib/proxy/session"; + +const envControl = vi.hoisted(() => ({ + enabled: true, + ttlSeconds: 3600, +})); + +const settingsControl = vi.hoisted(() => ({ ignoreClientSessionId: false })); + +const storeMocks = vi.hoisted(() => ({ + put: vi.fn(async () => {}), + tombstone: vi.fn(async () => {}), + lookup: vi.fn(async () => null), +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ + ENABLE_PREFIX_AFFINITY: envControl.enabled, + PREFIX_AFFINITY_TTL_SECONDS: envControl.ttlSeconds, + }), +})); + +vi.mock("@/app/v1/_lib/proxy/affinity/affinity-store", () => ({ + getAffinityStore: () => storeMocks, +})); + +vi.mock("@/lib/system-settings/proxy-runtime", () => ({ + getProxyRuntimeSettings: vi.fn(async () => ({ + streamGateMode: "off" as const, + affinityIgnoreClientSessionId: settingsControl.ignoreClientSessionId, + })), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { + recordAffinityWinner, + tombstoneAffinityOnFailure, +} from "@/app/v1/_lib/proxy/affinity/affinity-recorder"; + +function makeChain(tailDepth = 2): FingerprintChain { + return { + sys: { depth: 0, fp: "sysfp", prefixBytes: 10 }, + tail: Array.from({ length: tailDepth }, (_, i) => ({ + depth: i + 1, + fp: `fp${i + 1}`, + prefixBytes: 10 * (i + 2), + })), + }; +} + +function makeAffinity(overrides: Partial = {}): SessionAffinityState { + return { + scopeTag: "scope123", + chain: makeChain(), + nominatedProviderId: null, + matchedFp: null, + matchedTier: null, + ...overrides, + }; +} + +function makeSession(affinity: SessionAffinityState | null): ProxySession { + return { affinity } as unknown as ProxySession; +} + +beforeEach(() => { + envControl.enabled = true; + envControl.ttlSeconds = 3600; + settingsControl.ignoreClientSessionId = false; +}); + +describe("recordAffinityWinner", () => { + it("writes tip + sys bindings for the winning provider with the configured TTL", async () => { + await recordAffinityWinner(makeSession(makeAffinity()), 42); + expect(storeMocks.put).toHaveBeenCalledTimes(1); + expect(storeMocks.put).toHaveBeenCalledWith("scope123", "fp2", "sysfp", 42, 3600); + }); + + it("uses sys as tip when the chain has no conversation boundaries", async () => { + await recordAffinityWinner(makeSession(makeAffinity({ chain: makeChain(0) })), 7); + expect(storeMocks.put).toHaveBeenCalledWith("scope123", "sysfp", "sysfp", 7, 3600); + }); + + it("is a no-op when both the env flag and the ignore-session setting are off", async () => { + envControl.enabled = false; + settingsControl.ignoreClientSessionId = false; + await recordAffinityWinner(makeSession(makeAffinity()), 42); + expect(storeMocks.put).not.toHaveBeenCalled(); + }); + + it("writes when the env flag is off but the ignore-session setting is on", async () => { + envControl.enabled = false; + settingsControl.ignoreClientSessionId = true; + await recordAffinityWinner(makeSession(makeAffinity()), 42); + expect(storeMocks.put).toHaveBeenCalledWith("scope123", "fp2", "sysfp", 42, 3600); + }); + + it("is a no-op without affinity state or with a non-positive provider id", async () => { + await recordAffinityWinner(makeSession(null), 42); + await recordAffinityWinner(makeSession(makeAffinity()), 0); + await recordAffinityWinner(makeSession(makeAffinity()), -1); + expect(storeMocks.put).not.toHaveBeenCalled(); + }); + + it("swallows store failures (fire-and-forget)", async () => { + storeMocks.put.mockRejectedValueOnce(new Error("redis down")); + await expect(recordAffinityWinner(makeSession(makeAffinity()), 42)).resolves.toBeUndefined(); + storeMocks.put.mockRejectedValueOnce("non-error failure"); + await expect(recordAffinityWinner(makeSession(makeAffinity()), 42)).resolves.toBeUndefined(); + }); +}); + +describe("tombstoneAffinityOnFailure", () => { + it("tombstones the matched boundary when the failed provider is the nominated one", async () => { + const session = makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: "fp2" })); + await tombstoneAffinityOnFailure(session, 42); + expect(storeMocks.tombstone).toHaveBeenCalledTimes(1); + expect(storeMocks.tombstone).toHaveBeenCalledWith("scope123", "fp2", "failover"); + }); + + it("is a no-op when the failed provider differs from the nominated one", async () => { + const session = makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: "fp2" })); + await tombstoneAffinityOnFailure(session, 99); + expect(storeMocks.tombstone).not.toHaveBeenCalled(); + }); + + it("is a no-op without a nomination or a matched fingerprint", async () => { + await tombstoneAffinityOnFailure( + makeSession(makeAffinity({ nominatedProviderId: null, matchedFp: "fp2" })), + 42 + ); + await tombstoneAffinityOnFailure( + makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: null })), + 42 + ); + expect(storeMocks.tombstone).not.toHaveBeenCalled(); + }); + + it("is a no-op without affinity state (flag off never populates it)", async () => { + await tombstoneAffinityOnFailure(makeSession(null), 42); + expect(storeMocks.tombstone).not.toHaveBeenCalled(); + }); + + it("is a no-op when affinity routing is fully disabled", async () => { + envControl.enabled = false; + settingsControl.ignoreClientSessionId = false; + const session = makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: "fp2" })); + await tombstoneAffinityOnFailure(session, 42); + expect(storeMocks.tombstone).not.toHaveBeenCalled(); + }); + + it("tombstones when the env flag is off but the ignore-session setting is on", async () => { + envControl.enabled = false; + settingsControl.ignoreClientSessionId = true; + const session = makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: "fp2" })); + await tombstoneAffinityOnFailure(session, 42); + expect(storeMocks.tombstone).toHaveBeenCalledWith("scope123", "fp2", "failover"); + }); + + it("swallows store failures", async () => { + storeMocks.tombstone.mockRejectedValueOnce(new Error("redis down")); + const session = makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: "fp2" })); + await expect(tombstoneAffinityOnFailure(session, 42)).resolves.toBeUndefined(); + storeMocks.tombstone.mockRejectedValueOnce("non-error failure"); + await expect(tombstoneAffinityOnFailure(session, 42)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/proxy/affinity-store.test.ts b/tests/unit/proxy/affinity-store.test.ts new file mode 100644 index 000000000..2a9254ebd --- /dev/null +++ b/tests/unit/proxy/affinity-store.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from "vitest"; +import { AffinityStore, getAffinityStore } from "@/app/v1/_lib/proxy/affinity/affinity-store"; + +/** + * Fake redis that executes the lookup Lua semantics in JS against an in-memory + * map: scan KEYS in order, return the first value with an active "1|" prefix, + * sliding-expire it when ttl > 0. This keeps the value encoding written by + * put()/tombstone() and the prefix matched by the Lua script under one test. + */ +function createLuaFakeRedis(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + const expired: Array<{ key: string; ttl: number }> = []; + const client = { + status: "ready", + set: vi.fn(async (key: string, value: string, _ex: string, ttl: number) => { + data.set(key, value); + expired.push({ key, ttl }); + return "OK"; + }), + del: vi.fn(async () => 1), + eval: vi.fn(async (_script: string, numkeys: number, ...rest: (string | number)[]) => { + const keys = rest.slice(0, numkeys) as string[]; + const ttl = Number(rest[numkeys]); + for (let i = 0; i < keys.length; i++) { + const value = data.get(keys[i]); + if (value?.startsWith("1|")) { + if (ttl > 0) expired.push({ key: keys[i], ttl }); + return [i + 1, value]; + } + } + return null; + }), + }; + return { client, data, expired }; +} + +function makeStore(client: unknown) { + return new AffinityStore({ redisClient: client as never }); +} + +const key = (scope: string, fp: string) => `cch:pfx:{${scope}}:fp:${fp}`; + +describe("AffinityStore.lookup", () => { + it("returns the deepest active binding (MGET-style deepest-first scan)", async () => { + const { client } = createLuaFakeRedis({ + [key("s1", "deep")]: "1|42", + [key("s1", "mid")]: "1|7", + [key("s1", "sysf")]: "1|7", + }); + const hint = await makeStore(client).lookup("s1", ["deep", "mid", "sysf"], 600); + expect(hint).toEqual({ + providerId: 42, + matchedIndex: 0, + matchedFp: "deep", + tier: "conversation", + }); + }); + + it("passes keys deepest-first with the scope hash-tag key format and sliding ttl", async () => { + const { client } = createLuaFakeRedis(); + await makeStore(client).lookup("tag", ["deep", "mid", "sysf"], 300.9); + expect(client.eval).toHaveBeenCalledWith( + expect.stringContaining("GET"), + 3, + key("tag", "deep"), + key("tag", "mid"), + key("tag", "sysf"), + "300" + ); + }); + + it("skips a tombstoned deepest boundary and falls back to a shallower active one", async () => { + const { client } = createLuaFakeRedis({ + [key("s1", "deep")]: "0|failover", + [key("s1", "mid")]: "1|7", + }); + const hint = await makeStore(client).lookup("s1", ["deep", "mid", "sysf"], 600); + expect(hint).toEqual({ + providerId: 7, + matchedIndex: 1, + matchedFp: "mid", + tier: "conversation", + }); + }); + + it("maps a match on the last (sys) fingerprint to the system tier", async () => { + const { client } = createLuaFakeRedis({ [key("s1", "sysf")]: "1|9" }); + const hint = await makeStore(client).lookup("s1", ["deep", "mid", "sysf"], 600); + expect(hint?.tier).toBe("system"); + expect(hint?.matchedIndex).toBe(2); + }); + + it("returns null when all boundaries are tombstoned or absent", async () => { + const { client } = createLuaFakeRedis({ + [key("s1", "deep")]: "0|failover", + [key("s1", "sysf")]: "0|failover", + }); + const store = makeStore(client); + expect(await store.lookup("s1", ["deep", "sysf"], 600)).toBeNull(); + expect(await store.lookup("s1", ["missing-a", "missing-b"], 600)).toBeNull(); + }); + + it("slides the TTL on hit and skips renewal when ttl is not positive", async () => { + const { client, expired } = createLuaFakeRedis({ [key("s1", "deep")]: "1|3" }); + const store = makeStore(client); + await store.lookup("s1", ["deep"], 900); + expect(expired).toEqual([{ key: key("s1", "deep"), ttl: 900 }]); + + await store.lookup("s1", ["deep"], -10); + expect(client.eval).toHaveBeenLastCalledWith(expect.any(String), 1, key("s1", "deep"), "0"); + expect(expired).toHaveLength(1); + }); + + it("rejects malformed or non-positive provider ids", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + for (const value of ["1|abc", "1|0", "1|-5"]) { + client.eval.mockResolvedValueOnce([1, value]); + expect(await store.lookup("s1", ["deep"], 600)).toBeNull(); + } + client.eval.mockResolvedValueOnce("garbage"); + expect(await store.lookup("s1", ["deep"], 600)).toBeNull(); + }); + + it("returns null without touching redis for empty scope or fingerprints", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + expect(await store.lookup("", ["deep"], 600)).toBeNull(); + expect(await store.lookup("s1", [], 600)).toBeNull(); + expect(await store.lookup("s1", ["", ""], 600)).toBeNull(); + expect(client.eval).not.toHaveBeenCalled(); + }); +}); + +describe("AffinityStore.put", () => { + it("writes only tip + sys boundaries with the active encoding and TTL", async () => { + const { client } = createLuaFakeRedis(); + await makeStore(client).put("s1", "tipfp", "sysfp", 42, 900); + expect(client.set).toHaveBeenCalledTimes(2); + expect(client.set).toHaveBeenNthCalledWith(1, key("s1", "tipfp"), "1|42", "EX", 900); + expect(client.set).toHaveBeenNthCalledWith(2, key("s1", "sysfp"), "1|42", "EX", 900); + }); + + it("writes a single key when tip and sys collide or sys is empty", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + await store.put("s1", "same", "same", 42, 900); + await store.put("s1", "solo", "", 42, 900); + expect(client.set).toHaveBeenCalledTimes(2); + expect(client.set).toHaveBeenNthCalledWith(1, key("s1", "same"), "1|42", "EX", 900); + expect(client.set).toHaveBeenNthCalledWith(2, key("s1", "solo"), "1|42", "EX", 900); + }); + + it("ignores invalid arguments", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + await store.put("", "tip", "sys", 42, 900); + await store.put("s1", "", "sys", 42, 900); + await store.put("s1", "tip", "sys", 0, 900); + await store.put("s1", "tip", "sys", 42, 0); + expect(client.set).not.toHaveBeenCalled(); + }); +}); + +describe("AffinityStore.tombstone", () => { + it("writes a short-TTL tombstone with a truncated reason", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + await store.tombstone("s1", "deadfp", "failover"); + expect(client.set).toHaveBeenCalledWith(key("s1", "deadfp"), "0|failover", "EX", 60); + + await store.tombstone("s1", "deadfp", "x".repeat(50)); + expect(client.set).toHaveBeenLastCalledWith( + key("s1", "deadfp"), + `0|${"x".repeat(32)}`, + "EX", + 60 + ); + }); + + it("ignores empty scope or fingerprint", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + await store.tombstone("", "fp", "r"); + await store.tombstone("s1", "", "r"); + expect(client.set).not.toHaveBeenCalled(); + }); +}); + +describe("AffinityStore round-trip through the fake Lua", () => { + it("put -> lookup hits, tombstone on tip falls back to sys, tombstone on sys misses", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + + await store.put("s1", "tip", "sysf", 42, 600); + expect(await store.lookup("s1", ["tip", "sysf"], 600)).toEqual({ + providerId: 42, + matchedIndex: 0, + matchedFp: "tip", + tier: "conversation", + }); + + await store.tombstone("s1", "tip", "failover"); + expect(await store.lookup("s1", ["tip", "sysf"], 600)).toEqual({ + providerId: 42, + matchedIndex: 1, + matchedFp: "sysf", + tier: "system", + }); + + await store.tombstone("s1", "sysf", "failover"); + expect(await store.lookup("s1", ["tip", "sysf"], 600)).toBeNull(); + }); +}); + +describe("AffinityStore fail-open behavior", () => { + it("fails open when redis is unavailable or not ready", async () => { + const nullStore = makeStore(null); + expect(await nullStore.lookup("s1", ["fp"], 600)).toBeNull(); + await expect(nullStore.put("s1", "tip", "sys", 42, 600)).resolves.toBeUndefined(); + await expect(nullStore.tombstone("s1", "fp", "r")).resolves.toBeUndefined(); + + const { client } = createLuaFakeRedis(); + client.status = "connecting"; + const store = makeStore(client); + expect(await store.lookup("s1", ["fp"], 600)).toBeNull(); + await store.put("s1", "tip", "sys", 42, 600); + await store.tombstone("s1", "fp", "r"); + expect(client.eval).not.toHaveBeenCalled(); + expect(client.set).not.toHaveBeenCalled(); + }); + + it("fails open when redis commands throw", async () => { + const { client } = createLuaFakeRedis(); + client.eval.mockRejectedValue(new Error("boom")); + client.set.mockRejectedValue(new Error("boom")); + const store = makeStore(client); + expect(await store.lookup("s1", ["fp"], 600)).toBeNull(); + await expect(store.put("s1", "tip", "sys", 42, 600)).resolves.toBeUndefined(); + await expect(store.tombstone("s1", "fp", "r")).resolves.toBeUndefined(); + }); + + it("fails open when redis rejects with a non-Error value", async () => { + const { client } = createLuaFakeRedis(); + client.eval.mockRejectedValue("string failure"); + client.set.mockRejectedValue("string failure"); + const store = makeStore(client); + expect(await store.lookup("s1", ["fp"], 600)).toBeNull(); + await expect(store.put("s1", "tip", "sys", 42, 600)).resolves.toBeUndefined(); + await expect(store.tombstone("s1", "fp", "r")).resolves.toBeUndefined(); + }); +}); + +describe("getAffinityStore", () => { + it("returns a shared singleton instance", () => { + const a = getAffinityStore(); + expect(a).toBeInstanceOf(AffinityStore); + expect(getAffinityStore()).toBe(a); + }); +}); diff --git a/tests/unit/proxy/guard-pipeline-full-chain.test.ts b/tests/unit/proxy/guard-pipeline-full-chain.test.ts new file mode 100644 index 000000000..1fb1e0bd8 --- /dev/null +++ b/tests/unit/proxy/guard-pipeline-full-chain.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test, vi } from "vitest"; + +const callOrder: string[] = []; +let replayResult: Response | null = null; + +vi.mock("@/app/v1/_lib/proxy/auth-guard", () => ({ + ProxyAuthenticator: { + ensure: async () => { + callOrder.push("auth"); + return null; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/client-guard", () => ({ + ProxyClientGuard: { + ensure: async () => { + callOrder.push("client"); + return null; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/model-guard", () => ({ + ProxyModelGuard: { + ensure: async () => { + callOrder.push("model"); + return null; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/version-guard", () => ({ + ProxyVersionGuard: { + ensure: async () => { + callOrder.push("version"); + return null; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/session-guard", () => ({ + ProxySessionGuard: { + ensure: async () => { + callOrder.push("session"); + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/warmup-guard", () => ({ + ProxyWarmupGuard: { + ensure: async () => { + callOrder.push("warmup"); + return null; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/request-filter", () => ({ + ProxyRequestFilter: { + ensure: async () => { + callOrder.push("requestFilter"); + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/sensitive-word-guard", () => ({ + ProxySensitiveWordGuard: { + ensure: async () => { + callOrder.push("sensitive"); + return null; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/replay/replay-guard", () => ({ + ProxyReplayGuard: { + ensure: async () => { + callOrder.push("replayAttach"); + return replayResult; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/rate-limit-guard", () => ({ + ProxyRateLimitGuard: { + ensure: async () => { + callOrder.push("rateLimit"); + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ + ProxyProviderResolver: { + ensure: async () => { + callOrder.push("provider"); + return null; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/provider-request-filter", () => ({ + ProxyProviderRequestFilter: { + ensure: async () => { + callOrder.push("providerRequestFilter"); + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/message-service", () => ({ + ProxyMessageService: { + ensureContext: async () => { + callOrder.push("messageContext"); + }, + }, +})); + +describe("GuardPipeline:全链路放行与 replay 短路", () => { + test("warmup 未命中时 CHAT pipeline 应按序执行全部步骤并返回 null 交给 forwarder", async () => { + callOrder.length = 0; + replayResult = null; + + const { GuardPipelineBuilder, RequestType } = await import( + "@/app/v1/_lib/proxy/guard-pipeline" + ); + + const pipeline = GuardPipelineBuilder.fromRequestType(RequestType.CHAT); + + const session = { + isProbeRequest: () => { + callOrder.push("probe"); + return false; + }, + } as any; + + const res = await pipeline.run(session); + + expect(res).toBeNull(); + expect(callOrder).toEqual([ + "auth", + "sensitive", + "client", + "model", + "version", + "probe", + "session", + "warmup", + "requestFilter", + "replayAttach", + "rateLimit", + "provider", + "providerRequestFilter", + "messageContext", + ]); + }); + + test("replayAttach 命中缓存时应在 rateLimit 之前短路返回缓存响应", async () => { + callOrder.length = 0; + replayResult = new Response("cached", { status: 200 }); + + const { GuardPipelineBuilder, RequestType } = await import( + "@/app/v1/_lib/proxy/guard-pipeline" + ); + + const pipeline = GuardPipelineBuilder.fromRequestType(RequestType.CHAT); + + const session = { + isProbeRequest: () => { + callOrder.push("probe"); + return false; + }, + } as any; + + const res = await pipeline.run(session); + + expect(res).not.toBeNull(); + expect(res?.status).toBe(200); + await expect(res?.text()).resolves.toBe("cached"); + expect(callOrder).toEqual([ + "auth", + "sensitive", + "client", + "model", + "version", + "probe", + "session", + "warmup", + "requestFilter", + "replayAttach", + ]); + expect(callOrder).not.toContain("rateLimit"); + expect(callOrder).not.toContain("provider"); + expect(callOrder).not.toContain("messageContext"); + }); + + test("fromSession 应优先采用 isRawCrossProviderFallbackEnabled 的返回值,false 时退回 raw passthrough preset", async () => { + callOrder.length = 0; + replayResult = null; + + const { GuardPipelineBuilder } = await import("@/app/v1/_lib/proxy/guard-pipeline"); + + let flagCalls = 0; + const session = { + getEndpointPolicy: () => ({ + guardPreset: "raw_passthrough", + allowRawCrossProviderFallback: true, + }), + isRawCrossProviderFallbackEnabled: () => { + flagCalls += 1; + return false; + }, + isProbeRequest: () => { + callOrder.push("probe"); + return false; + }, + } as any; + + const pipeline = GuardPipelineBuilder.fromSession(session); + const res = await pipeline.run(session); + + expect(flagCalls).toBe(1); + expect(res).toBeNull(); + expect(callOrder).toEqual(["auth", "client", "model", "version", "probe", "provider"]); + expect(callOrder).not.toContain("session"); + expect(callOrder).not.toContain("messageContext"); + }); +}); diff --git a/tests/unit/proxy/provider-selector-affinity-ignore-session.test.ts b/tests/unit/proxy/provider-selector-affinity-ignore-session.test.ts new file mode 100644 index 000000000..dc11811e1 --- /dev/null +++ b/tests/unit/proxy/provider-selector-affinity-ignore-session.test.ts @@ -0,0 +1,322 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Provider } from "@/types/provider"; + +/** + * F3a "ignore client session id" semantics and review fixes in ensure(): + * - affinity candidates must not bypass cost limits (windowed + total); + * - ignore on + fingerprintable request skips the session binding read; + * - ignore on + non-fingerprintable body keeps legacy session reuse; + * - metrics-only mode (ENABLE_CACHE_EFFECTIVENESS) fingerprints without nominating; + * - non-default endpoint policies never build affinity state. + */ + +const envControl = vi.hoisted(() => ({ + affinityEnabled: true, + cacheEffectiveness: false, +})); + +const settingsControl = vi.hoisted(() => ({ ignoreClientSessionId: true })); + +const storeMocks = vi.hoisted(() => ({ + lookup: vi.fn(async () => null as unknown), + put: vi.fn(async () => {}), + tombstone: vi.fn(async () => {}), +})); + +const circuitBreakerMocks = vi.hoisted(() => ({ + isCircuitOpen: vi.fn(async (_providerId: number) => false), + getCircuitState: vi.fn(() => "closed"), +})); + +const vendorTypeCircuitMocks = vi.hoisted(() => ({ + isVendorTypeCircuitOpen: vi.fn(async () => false), +})); + +const sessionManagerMocks = vi.hoisted(() => ({ + SessionManager: { + getSessionProvider: vi.fn(async () => null as number | null), + clearSessionProvider: vi.fn(async () => undefined), + // 版本化绑定读不可用 -> findReusable 走 legacy getSessionProvider 回退,测试意图不变 + getSessionBindingSnapshot: vi.fn(async () => ({ + status: "unavailable" as const, + reason: "redis_unavailable", + capabilityState: "unknown", + legacyFallbackAllowed: true, + })), + isSessionProviderCoolingDown: vi.fn(async () => ({ + status: "ok" as const, + coolingDown: false, + legacyFallbackAllowed: false as const, + })), + }, +})); + +const providerRepositoryMocks = vi.hoisted(() => ({ + findProviderById: vi.fn(async () => null as Provider | null), + findAllProviders: vi.fn(async () => [] as Provider[]), +})); + +const rateLimitMocks = vi.hoisted(() => ({ + RateLimitService: { + checkCostLimitsWithLease: vi.fn(async (_providerId: number) => ({ allowed: true })), + checkTotalCostLimit: vi.fn(async (_providerId: number) => ({ allowed: true, current: 0 })), + checkAndTrackProviderSession: vi.fn(async () => ({ + allowed: true, + count: 1, + tracked: true, + referenced: false, + })), + }, +})); + +vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks); +vi.mock("@/lib/vendor-type-circuit-breaker", () => vendorTypeCircuitMocks); +vi.mock("@/lib/session-manager", () => sessionManagerMocks); +vi.mock("@/repository/provider", () => providerRepositoryMocks); +vi.mock("@/lib/rate-limit", () => rateLimitMocks); +vi.mock("@/repository/provider-groups", () => ({ + getGroupCostMultiplier: vi.fn(async () => 1), +})); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), +})); +vi.mock("@/app/v1/_lib/proxy/provider-selector-settings-cache", () => ({ + getVerboseProviderErrorCached: vi.fn(async () => false), +})); +vi.mock("@/app/v1/_lib/proxy/affinity/affinity-store", () => ({ + getAffinityStore: () => storeMocks, +})); +vi.mock("@/lib/system-settings/proxy-runtime", () => ({ + getProxyRuntimeSettings: vi.fn(async () => ({ + streamGateMode: "off" as const, + affinityIgnoreClientSessionId: settingsControl.ignoreClientSessionId, + })), +})); +vi.mock("@/lib/config/env.schema", async (importOriginal) => { + const actual = await importOriginal(); + const baseEnv = actual.EnvSchema.parse({}); + return { + ...actual, + getEnvConfig: () => ({ + ...baseEnv, + ENABLE_PREFIX_AFFINITY: envControl.affinityEnabled, + ENABLE_CACHE_EFFECTIVENESS: envControl.cacheEffectiveness, + PREFIX_AFFINITY_WINDOW: 8, + PREFIX_AFFINITY_TTL_SECONDS: 3600, + }), + }; +}); + +import { ProxyProviderResolver } from "@/app/v1/_lib/proxy/provider-selector"; + +function makeProvider(id: number, overrides: Partial = {}): Provider { + return { + id, + name: `provider_${id}`, + isEnabled: true, + providerType: "claude", + groupTag: null, + weight: 1, + priority: 0, + costMultiplier: 1, + disableSessionReuse: false, + allowedModels: null, + allowedClients: [], + blockedClients: [], + providerVendorId: null, + activeTimeStart: null, + activeTimeEnd: 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; +} + +const claudeMessage = { + model: "claude-sonnet-4-5", + system: "You are helpful.", + messages: [{ role: "user", content: "hello" }], +}; + +// Minimal ProxySession stub; loose typing matches sibling selector tests. +function makeSession(overrides: Record = {}): any { + const session: any = { + sessionId: null, + provider: null, + affinity: null, + originalFormat: "claude", + userAgent: "claude-cli/2.0.0", + authState: { key: { id: 5, providerGroup: "default" }, user: null }, + request: { message: claudeMessage }, + getEndpointPolicy: () => ({ kind: "default" }), + shouldReuseProvider: () => false, + getOriginalModel: () => "claude-sonnet-4-5", + getCurrentModel: () => null, + setProvider(p: Provider) { + session.provider = p; + }, + addProviderToChain: vi.fn(), + setLastSelectionContext: vi.fn((ctx: unknown) => { + session._ctx = ctx; + }), + getLastSelectionContext: vi.fn(() => session._ctx ?? null), + setGroupCostMultiplier: vi.fn(), + getProvidersSnapshot: vi.fn(async () => [makeProvider(55)]), + recordProviderSessionRef: vi.fn(), + }; + return Object.assign(session, overrides); +} + +const affinityHint = { + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation" as const, +}; + +beforeEach(() => { + vi.clearAllMocks(); + envControl.affinityEnabled = true; + envControl.cacheEffectiveness = false; + settingsControl.ignoreClientSessionId = true; + storeMocks.lookup.mockResolvedValue(null); + circuitBreakerMocks.isCircuitOpen.mockResolvedValue(false); + circuitBreakerMocks.getCircuitState.mockReturnValue("closed"); + rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValue({ allowed: true }); + rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValue({ + allowed: true, + current: 0, + }); + rateLimitMocks.RateLimitService.checkAndTrackProviderSession.mockResolvedValue({ + allowed: true, + count: 1, + tracked: true, + referenced: false, + }); +}); + +describe("affinity candidate cost limits", () => { + test("candidate over windowed cost limits is rejected and falls back to weighted random", async () => { + storeMocks.lookup.mockResolvedValue(affinityHint); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); + rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockImplementation( + async (providerId: number) => ({ allowed: providerId !== 42 }) + ); + + const session = makeSession(); + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.provider?.id).toBe(55); + expect(session.affinity?.matchedFp).toBe("deepfp"); + expect(session.affinity?.nominatedProviderId).toBeNull(); + expect(rateLimitMocks.RateLimitService.checkCostLimitsWithLease).toHaveBeenCalledWith( + 42, + "provider", + expect.any(Object) + ); + }); + + test("candidate over total cost limit is rejected and falls back to weighted random", async () => { + storeMocks.lookup.mockResolvedValue(affinityHint); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); + rateLimitMocks.RateLimitService.checkTotalCostLimit.mockImplementation( + async (providerId: number) => ({ allowed: providerId !== 42, current: 0 }) + ); + + const session = makeSession(); + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.provider?.id).toBe(55); + expect(session.affinity?.nominatedProviderId).toBeNull(); + expect(rateLimitMocks.RateLimitService.checkTotalCostLimit).toHaveBeenCalledWith( + 42, + "provider", + null, + expect.any(Object) + ); + }); +}); + +describe("ignore client session id semantics", () => { + test("ignore on + fingerprintable request never reads the session binding", async () => { + sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValue(91); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(91)); + + const session = makeSession({ + sessionId: "sess_bound", + shouldReuseProvider: () => true, + }); + + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(sessionManagerMocks.SessionManager.getSessionProvider).not.toHaveBeenCalled(); + expect(storeMocks.lookup).toHaveBeenCalledTimes(1); + expect(session.affinity).not.toBeNull(); + // affinity miss: weighted random, not the stale session binding + expect(session.provider?.id).toBe(55); + }); + + test("ignore on + non-fingerprintable body still uses legacy session reuse", async () => { + sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValue(91); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(91)); + + const session = makeSession({ + sessionId: "sess_bound", + shouldReuseProvider: () => true, + // 无 messages 数组:不可指纹化(如 Codex 非 chat 体),保住既有粘性 + request: { message: { model: "claude-sonnet-4-5" } }, + }); + + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(sessionManagerMocks.SessionManager.getSessionProvider).toHaveBeenCalledTimes(1); + expect(session.provider?.id).toBe(91); + expect(session.affinity).toBeNull(); + expect(storeMocks.lookup).not.toHaveBeenCalled(); + expect(session.addProviderToChain).toHaveBeenCalledWith( + expect.objectContaining({ id: 91 }), + expect.objectContaining({ reason: "session_reuse" }) + ); + }); +}); + +describe("metrics-only and endpoint policy gating", () => { + test("cache-effectiveness only: fingerprints the request but never looks up or nominates", async () => { + envControl.affinityEnabled = false; + envControl.cacheEffectiveness = true; + settingsControl.ignoreClientSessionId = false; + + const session = makeSession(); + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.affinity).not.toBeNull(); + expect(session.affinity?.nominatedProviderId).toBeNull(); + expect(storeMocks.lookup).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(55); + }); + + test("non-default endpoint policy never builds affinity state", async () => { + const session = makeSession({ + getEndpointPolicy: () => ({ kind: "raw_passthrough" }), + }); + + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.affinity).toBeNull(); + expect(storeMocks.lookup).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(55); + }); +}); diff --git a/tests/unit/proxy/provider-selector-affinity-priority.test.ts b/tests/unit/proxy/provider-selector-affinity-priority.test.ts new file mode 100644 index 000000000..b1d81963d --- /dev/null +++ b/tests/unit/proxy/provider-selector-affinity-priority.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { Provider } from "@/types/provider"; + +/** + * F3a nomination priority inside ProxyProviderResolver.ensure(): + * with "ignore client session id" off: explicit session binding > affinity hint > weighted random; + * with it on (product default) fingerprintable requests skip the session binding read entirely. + * An affinity hint must still pass the full hard validation either way. + */ + +const envControl = vi.hoisted(() => ({ affinityEnabled: true })); + +const settingsControl = vi.hoisted(() => ({ ignoreClientSessionId: true })); + +const storeMocks = vi.hoisted(() => ({ + lookup: vi.fn(async () => null as unknown), + put: vi.fn(async () => {}), + tombstone: vi.fn(async () => {}), +})); + +const circuitBreakerMocks = vi.hoisted(() => ({ + isCircuitOpen: vi.fn(async (_providerId: number) => false), + getCircuitState: vi.fn(() => "closed"), +})); + +const vendorTypeCircuitMocks = vi.hoisted(() => ({ + isVendorTypeCircuitOpen: vi.fn(async () => false), +})); + +const sessionManagerMocks = vi.hoisted(() => ({ + SessionManager: { + getSessionProvider: vi.fn(async () => null as number | null), + clearSessionProvider: vi.fn(async () => undefined), + // 版本化绑定读不可用 -> findReusable 走 legacy getSessionProvider 回退,测试意图不变 + getSessionBindingSnapshot: vi.fn(async () => ({ + status: "unavailable" as const, + reason: "redis_unavailable", + capabilityState: "unknown", + legacyFallbackAllowed: true, + })), + isSessionProviderCoolingDown: vi.fn(async () => ({ + status: "ok" as const, + coolingDown: false, + legacyFallbackAllowed: false as const, + })), + }, +})); + +const providerRepositoryMocks = vi.hoisted(() => ({ + findProviderById: vi.fn(async () => null as Provider | null), + findAllProviders: vi.fn(async () => [] as Provider[]), +})); + +const rateLimitMocks = vi.hoisted(() => ({ + RateLimitService: { + checkCostLimitsWithLease: vi.fn(async () => ({ allowed: true })), + checkTotalCostLimit: vi.fn(async () => ({ allowed: true, current: 0 })), + checkAndTrackProviderSession: vi.fn(async () => ({ + allowed: true, + count: 1, + tracked: true, + referenced: false, + })), + }, +})); + +vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks); +vi.mock("@/lib/vendor-type-circuit-breaker", () => vendorTypeCircuitMocks); +vi.mock("@/lib/session-manager", () => sessionManagerMocks); +vi.mock("@/repository/provider", () => providerRepositoryMocks); +vi.mock("@/lib/rate-limit", () => rateLimitMocks); +vi.mock("@/repository/provider-groups", () => ({ + getGroupCostMultiplier: vi.fn(async () => 1), +})); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), +})); +vi.mock("@/app/v1/_lib/proxy/provider-selector-settings-cache", () => ({ + getVerboseProviderErrorCached: vi.fn(async () => false), +})); +vi.mock("@/app/v1/_lib/proxy/affinity/affinity-store", () => ({ + getAffinityStore: () => storeMocks, +})); +vi.mock("@/lib/system-settings/proxy-runtime", () => ({ + getProxyRuntimeSettings: vi.fn(async () => ({ + streamGateMode: "off" as const, + affinityIgnoreClientSessionId: settingsControl.ignoreClientSessionId, + })), +})); +vi.mock("@/lib/config/env.schema", async (importOriginal) => { + const actual = await importOriginal(); + const baseEnv = actual.EnvSchema.parse({}); + return { + ...actual, + getEnvConfig: () => ({ + ...baseEnv, + ENABLE_PREFIX_AFFINITY: envControl.affinityEnabled, + // 指标模式(F3b)默认开启会独立建指纹状态;本文件聚焦提名优先级,显式关闭 + ENABLE_CACHE_EFFECTIVENESS: false, + PREFIX_AFFINITY_WINDOW: 8, + PREFIX_AFFINITY_TTL_SECONDS: 3600, + }), + }; +}); + +import { ProxyProviderResolver } from "@/app/v1/_lib/proxy/provider-selector"; + +function makeProvider(id: number, overrides: Partial = {}): Provider { + return { + id, + name: `provider_${id}`, + isEnabled: true, + providerType: "claude", + groupTag: null, + weight: 1, + priority: 0, + costMultiplier: 1, + disableSessionReuse: false, + allowedModels: null, + allowedClients: [], + blockedClients: [], + providerVendorId: null, + activeTimeStart: null, + activeTimeEnd: 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; +} + +const claudeMessage = { + model: "claude-sonnet-4-5", + system: "You are helpful.", + messages: [{ role: "user", content: "hello" }], +}; + +// Minimal ProxySession stub; loose typing matches sibling selector tests. +function makeSession(overrides: Record = {}): any { + const session: any = { + sessionId: null, + provider: null, + affinity: null, + originalFormat: "claude", + userAgent: "claude-cli/2.0.0", + authState: { key: { id: 5, providerGroup: "default" }, user: null }, + request: { message: claudeMessage }, + getEndpointPolicy: () => ({ kind: "default" }), + shouldReuseProvider: () => false, + getOriginalModel: () => "claude-sonnet-4-5", + getCurrentModel: () => null, + setProvider(p: Provider) { + session.provider = p; + }, + addProviderToChain: vi.fn(), + setLastSelectionContext: vi.fn((ctx: unknown) => { + session._ctx = ctx; + }), + getLastSelectionContext: vi.fn(() => session._ctx ?? null), + setGroupCostMultiplier: vi.fn(), + getProvidersSnapshot: vi.fn(async () => [makeProvider(55)]), + recordProviderSessionRef: vi.fn(), + }; + return Object.assign(session, overrides); +} + +beforeEach(() => { + vi.clearAllMocks(); + envControl.affinityEnabled = true; + settingsControl.ignoreClientSessionId = true; + storeMocks.lookup.mockResolvedValue(null); + circuitBreakerMocks.isCircuitOpen.mockResolvedValue(false); + circuitBreakerMocks.getCircuitState.mockReturnValue("closed"); + rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValue({ allowed: true }); + rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValue({ + allowed: true, + current: 0, + }); + rateLimitMocks.RateLimitService.checkAndTrackProviderSession.mockResolvedValue({ + allowed: true, + count: 1, + tracked: true, + referenced: false, + }); +}); + +describe("ensure() nomination priority", () => { + test("ignore-session off: explicit session binding wins and affinity lookup is never consulted", async () => { + settingsControl.ignoreClientSessionId = false; + sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValue(91); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(91)); + + const session = makeSession({ + sessionId: "sess_bound", + shouldReuseProvider: () => true, + }); + + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.provider?.id).toBe(91); + expect(storeMocks.lookup).not.toHaveBeenCalled(); + // 复用命中轮次仍要指纹状态:供终态写回加深前缀与 F3b 落值 + expect(session.affinity).not.toBeNull(); + expect(session.affinity?.nominatedProviderId).toBeNull(); + }); + + test("affinity hit wins over weighted random and records affinity_hit in the chain", async () => { + storeMocks.lookup.mockResolvedValue({ + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation", + }); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); + + const session = makeSession(); + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.provider?.id).toBe(42); + expect(session.affinity?.nominatedProviderId).toBe(42); + expect(session.affinity?.matchedFp).toBe("deepfp"); + expect(session.getProvidersSnapshot).not.toHaveBeenCalled(); + expect(session.addProviderToChain).toHaveBeenCalledWith( + expect.objectContaining({ id: 42 }), + expect.objectContaining({ reason: "affinity_hit", selectionMethod: "prefix_affinity" }) + ); + + const [, luaKeysCount] = storeMocks.lookup.mock.calls[0] as unknown as [string, string[]]; + expect(Array.isArray(luaKeysCount)).toBe(true); + }); + + test("affinity miss falls back to weighted random selection", async () => { + const session = makeSession(); + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(storeMocks.lookup).toHaveBeenCalledTimes(1); + expect(session.provider?.id).toBe(55); + expect(session.affinity).not.toBeNull(); + expect(session.affinity?.nominatedProviderId).toBeNull(); + }); + + test("affinity hit that fails hard validation falls back without nomination", async () => { + storeMocks.lookup.mockResolvedValue({ + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation", + }); + providerRepositoryMocks.findProviderById.mockResolvedValue( + makeProvider(42, { isEnabled: false }) + ); + + const session = makeSession(); + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.provider?.id).toBe(55); + expect(session.affinity?.matchedFp).toBe("deepfp"); + expect(session.affinity?.nominatedProviderId).toBeNull(); + }); + + test("circuit-open affinity candidate is rejected by hard validation", async () => { + storeMocks.lookup.mockResolvedValue({ + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation", + }); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); + circuitBreakerMocks.isCircuitOpen.mockImplementation(async (id: number) => id === 42); + + const session = makeSession(); + await ProxyProviderResolver.ensure(session); + + expect(session.provider?.id).toBe(55); + expect(session.affinity?.nominatedProviderId).toBeNull(); + }); + + test("env flag off and ignore-session setting off disable affinity entirely", async () => { + envControl.affinityEnabled = false; + settingsControl.ignoreClientSessionId = false; + + const session = makeSession(); + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(storeMocks.lookup).not.toHaveBeenCalled(); + expect(session.affinity).toBeNull(); + expect(session.provider?.id).toBe(55); + }); +}); diff --git a/tests/unit/proxy/proxy-handler-session-id-error.test.ts b/tests/unit/proxy/proxy-handler-session-id-error.test.ts index 2572dbfe4..25885f496 100644 --- a/tests/unit/proxy/proxy-handler-session-id-error.test.ts +++ b/tests/unit/proxy/proxy-handler-session-id-error.test.ts @@ -3,6 +3,7 @@ import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths"; import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { DbPoolAdmissionError } from "@/drizzle/admitted-client"; const h = vi.hoisted(() => ({ session: { @@ -17,7 +18,9 @@ const h = vi.hoisted(() => ({ isCountTokensRequest: () => false, getProviderChain: () => [], setOriginalFormat: () => {}, - setHighConcurrencyModeEnabled: () => {}, + setHighConcurrencyModeEnabled(enabled: boolean) { + h.session.highConcurrencyModeEnabled = enabled; + }, setRawCrossProviderFallbackEnabled(enabled: boolean) { h.session.rawCrossProviderFallbackEnabled = enabled; }, @@ -25,6 +28,7 @@ const h = vi.hoisted(() => ({ recordForwardStart: () => {}, messageContext: null, provider: null, + highConcurrencyModeEnabled: false, rawCrossProviderFallbackEnabled: false, } as any, @@ -34,10 +38,42 @@ const h = vi.hoisted(() => ({ forwardResponse: new Response("ok", { status: 200 }), dispatchedResponse: null as Response | null, + settingsError: null as unknown, + systemSettings: {} as Record, + fakeStreamingResponse: null as Response | null, + normalizeInputCalls: 0, + endpointFormat: null as string | null, + clientFormat: "openai" as string, trackerCalls: [] as string[], })); +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedSystemSettings: async () => { + if (h.settingsError) throw h.settingsError; + return h.systemSettings; + }, + }; +}); + +vi.mock("@/app/v1/_lib/proxy/fake-streaming/proxy-integration", () => ({ + tryFakeStreamingPath: async () => h.fakeStreamingResponse, +})); + +vi.mock("@/app/v1/_lib/proxy/response-input-rectifier", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + normalizeResponseInput: async () => { + h.normalizeInputCalls += 1; + }, + }; +}); + vi.mock("@/app/v1/_lib/proxy/session", () => ({ ProxySession: { fromContext: async () => { @@ -66,7 +102,7 @@ vi.mock("@/app/v1/_lib/proxy/guard-pipeline", () => ({ })); vi.mock("@/app/v1/_lib/proxy/format-mapper", () => ({ - detectClientFormat: () => "openai", + detectClientFormat: () => h.clientFormat, detectFormatByEndpoint: () => h.endpointFormat, })); @@ -256,4 +292,178 @@ describe("handleProxyRequest - session id on errors", async () => { const body = await res.json(); expect(body.error.message).toBe("代理请求发生未知错误"); }); + + test.each([ + { label: "Error", settingsError: new Error("settings backend down") as unknown }, + { label: "非 Error 值", settingsError: "settings string failure" as unknown }, + { label: "数据库准入错误", settingsError: new DbPoolAdmissionError("app", 16) as unknown }, + ])("settings 加载抛出 $label 时降级关闭开关并继续请求", async ({ settingsError }) => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.forwardResponse = new Response("ok", { status: 200 }); + h.dispatchedResponse = null; + h.settingsError = settingsError; + h.systemSettings = {}; + // settings 失败时 cachedSystemSettings 为 null,fake streaming 必须被跳过 + h.fakeStreamingResponse = ProxyResponses.buildError(500, "fake streaming should be skipped"); + h.session.requestUrl = new URL("http://localhost/v1/messages"); + h.session.getEndpointPolicy = () => resolveEndpointPolicy(h.session.requestUrl.pathname); + h.session.request = { model: "gpt", message: {} }; + h.session.sessionId = "s_123"; + h.session.messageContext = null; + h.session.provider = null; + h.session.highConcurrencyModeEnabled = null; + h.session.rawCrossProviderFallbackEnabled = null; + + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + expect(h.session.highConcurrencyModeEnabled).toBe(false); + expect(h.session.rawCrossProviderFallbackEnabled).toBe(false); + + h.settingsError = null; + h.fakeStreamingResponse = null; + }); + + test("response 格式请求在 guard pipeline 前执行 input 规范化", async () => { + h.fromContextError = null; + h.session.originalFormat = "response"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.settingsError = null; + h.systemSettings = {}; + h.fakeStreamingResponse = null; + h.normalizeInputCalls = 0; + h.earlyResponse = ProxyResponses.buildError(400, "invalid input"); + h.session.requestUrl = new URL("http://localhost/v1/responses"); + h.session.getEndpointPolicy = () => resolveEndpointPolicy(h.session.requestUrl.pathname); + h.session.request = { model: "gpt", message: {} }; + h.session.sessionId = "s_123"; + h.session.messageContext = null; + h.session.provider = null; + + const res = await handleProxyRequest({} as any); + + await expectMessageSuffixOnly(res, 400, "invalid input"); + expect(h.normalizeInputCalls).toBe(1); + }); + + test("fake streaming 错误响应同样附加 session id 后缀", async () => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.dispatchedResponse = null; + h.settingsError = null; + h.systemSettings = { + enableHighConcurrencyMode: true, + allowNonConversationEndpointProviderFallback: false, + }; + h.fakeStreamingResponse = ProxyResponses.buildError(502, "fake streaming upstream failed"); + h.session.requestUrl = new URL("http://localhost/v1/messages"); + h.session.getEndpointPolicy = () => resolveEndpointPolicy(h.session.requestUrl.pathname); + h.session.request = { model: "", message: {} }; + h.session.sessionId = "s_123"; + h.session.messageContext = { id: 1, user: { id: 1, name: "u" }, key: { name: "k" } }; + h.session.provider = { id: 1, name: "p" }; + + const res = await handleProxyRequest({} as any); + + await expectMessageSuffixOnly(res, 502, "fake streaming upstream failed"); + expect(h.session.highConcurrencyModeEnabled).toBe(true); + expect(h.session.rawCrossProviderFallbackEnabled).toBe(false); + expect(h.trackerCalls).toEqual(["inc", "startRequest", "dec"]); + + h.fakeStreamingResponse = null; + }); + + test("缺少 session id 时跳过并发计数与错误装饰", async () => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.settingsError = null; + h.systemSettings = {}; + h.fakeStreamingResponse = null; + h.forwardResponse = new Response("upstream", { status: 502 }); + h.dispatchedResponse = ProxyResponses.buildError(502, "upstream failed"); + h.session.requestUrl = new URL("http://localhost/v1/messages"); + h.session.getEndpointPolicy = () => resolveEndpointPolicy(h.session.requestUrl.pathname); + h.session.request = { model: "gpt", message: {} }; + h.session.sessionId = null; + h.session.messageContext = null; + h.session.provider = null; + + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error.message).toBe("upstream failed"); + expect(h.trackerCalls).toEqual([]); + + h.session.sessionId = "s_123"; + }); + + test("claude body detection keeps claude format without debug branch", async () => { + h.fromContextError = null; + h.session.originalFormat = "claude"; + h.endpointFormat = null; + h.clientFormat = "claude"; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.settingsError = null; + h.systemSettings = {}; + h.fakeStreamingResponse = null; + h.earlyResponse = ProxyResponses.buildError(400, "bad request"); + h.session.requestUrl = new URL("http://localhost/v1/unknown"); + h.session.getEndpointPolicy = () => resolveEndpointPolicy(h.session.requestUrl.pathname); + h.session.request = { model: "gpt", message: {} }; + h.session.sessionId = "s_123"; + h.session.messageContext = null; + h.session.provider = null; + + const res = await handleProxyRequest({} as any); + + await expectMessageSuffixOnly(res, 400, "bad request"); + + h.clientFormat = "openai"; + }); + + test.each([ + { label: "数据库准入错误", pipelineError: new DbPoolAdmissionError("app", 16) as unknown }, + { label: "非 Error 值", pipelineError: "guard exploded" as unknown }, + ])("pipeline 抛出 $label 时仍走 ProxyErrorHandler", async ({ pipelineError }) => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = pipelineError; + h.settingsError = null; + h.systemSettings = {}; + h.fakeStreamingResponse = null; + h.earlyResponse = null; + h.session.requestUrl = new URL("http://localhost/v1/messages"); + h.session.getEndpointPolicy = () => resolveEndpointPolicy(h.session.requestUrl.pathname); + h.session.request = { model: "gpt", message: {} }; + h.session.sessionId = "s_123"; + h.session.messageContext = null; + h.session.provider = null; + + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(502); + expect(await res.text()).toBe("handled"); + + h.pipelineError = null; + }); }); diff --git a/tests/unit/proxy/replay-guard.test.ts b/tests/unit/proxy/replay-guard.test.ts new file mode 100644 index 000000000..27e024759 --- /dev/null +++ b/tests/unit/proxy/replay-guard.test.ts @@ -0,0 +1,447 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProxyReplayGuard } from "@/app/v1/_lib/proxy/replay/replay-guard"; +import { + deriveReplayIdentity, + REPLAY_BYPASS_HEADER, + type ReplayIdentity, +} from "@/app/v1/_lib/proxy/replay/replay-identity"; +import type { ReplayMeta } from "@/app/v1/_lib/proxy/replay/replay-store"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; + +/** + * F2 replayAttach guard 步骤单测。 + * + * - identity 用真实 deriveReplayIdentity(env mock 打开 flag),保证 guard 与 + * identity 的推导一致; + * - store 通过 mock "@/app/v1/_lib/proxy/replay/replay-store".getReplayStore + * 注入可控 mock(getMeta/readChunks/findCompleted/tryClaimOwner/deleteChunks); + * - 审计行通过 mock "@/drizzle/db" 捕获 messageRequest insert values。 + */ + +const envControl = vi.hoisted(() => ({ + enableReplay: true, + liveDedup: true, +})); + +const storeControl = vi.hoisted(() => ({ + getMeta: vi.fn(async (): Promise => null), + readChunks: vi.fn(async (): Promise => null), + findCompleted: vi.fn(async (): Promise => null), + tryClaimOwner: vi.fn(async (): Promise => false), + deleteChunks: vi.fn(async (): Promise => undefined), +})); + +const dbControl = vi.hoisted(() => ({ + rows: [] as Record[], + insertError: null as Error | null, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/config/env.schema", async (importOriginal) => { + const actual = await importOriginal(); + const baseEnv = actual.EnvSchema.parse({}); + return { + ...actual, + getEnvConfig: () => ({ + ...baseEnv, + ENABLE_REQUEST_REPLAY: envControl.enableReplay, + REPLAY_LIVE_DEDUP_ENABLED: envControl.liveDedup, + }), + }; +}); + +vi.mock("@/app/v1/_lib/proxy/replay/replay-store", () => ({ + getReplayStore: () => storeControl, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + insert: () => ({ + values: async (values: Record) => { + if (dbControl.insertError) throw dbControl.insertError; + dbControl.rows.push(values); + }, + }), + }, +})); + +interface GuardSessionOverrides { + message?: Record; + headers?: Record; + apiKey?: string | null; +} + +function makeSession(overrides: GuardSessionOverrides = {}): ProxySession { + return { + method: "POST", + headers: new Headers(overrides.headers ?? {}), + request: { + message: overrides.message ?? { + stream: true, + model: "claude-sonnet-4", + messages: [{ role: "user", content: "hi" }], + }, + model: "claude-sonnet-4", + }, + authState: { + key: { id: 11 }, + user: { id: 22 }, + apiKey: "apiKey" in overrides ? overrides.apiKey : "sk-test", + }, + originalFormat: "claude", + sessionId: "sess-1", + userAgent: "vitest-agent", + replayState: null, + getEndpointPolicy: () => ({ kind: "default" }), + getOriginalModel: () => "claude-sonnet-4", + getEndpoint: () => "/v1/messages", + getMessagesLength: () => 1, + } as unknown as ProxySession; +} + +function expectedIdentity(): ReplayIdentity { + const identity = deriveReplayIdentity(makeSession()); + if (!identity) throw new Error("test fixture must derive a replay identity"); + return identity; +} + +function makeMeta(identity: ReplayIdentity, overrides: Partial = {}): ReplayMeta { + return { + status: "owning", + verifier: identity.verifier, + scopeTag: identity.scopeTag, + statusCode: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + format: "claude", + model: "claude-sonnet-4", + chunkCount: 1, + byteSize: 9, + heartbeatAt: Date.now(), + ...overrides, + }; +} + +beforeEach(() => { + envControl.enableReplay = true; + envControl.liveDedup = true; + dbControl.rows = []; + dbControl.insertError = null; +}); + +describe("ProxyReplayGuard:放行路径", () => { + it("功能开关关闭时直接放行,不触碰存储", async () => { + envControl.enableReplay = false; + const session = makeSession(); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + expect(storeControl.getMeta).not.toHaveBeenCalled(); + expect(storeControl.tryClaimOwner).not.toHaveBeenCalled(); + expect(session.replayState).toBeNull(); + }); + + it("非流式请求不参与 replay", async () => { + const session = makeSession({ message: { stream: false, model: "claude-sonnet-4" } }); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + expect(storeControl.getMeta).not.toHaveBeenCalled(); + }); + + it("Redis miss + PG miss 时放行,claim 成功则清残块后挂 owner 角色", async () => { + storeControl.tryClaimOwner.mockResolvedValueOnce(true); + const session = makeSession(); + const identity = expectedIdentity(); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + + expect(storeControl.findCompleted).toHaveBeenCalledWith(identity.replayId); + expect(session.replayState).toMatchObject({ + role: "owner", + identity: { replayId: identity.replayId, verifier: identity.verifier }, + }); + const ownerToken = session.replayState?.ownerToken; + expect(typeof ownerToken).toBe("string"); + expect(storeControl.tryClaimOwner).toHaveBeenCalledWith(identity.replayId, ownerToken); + // 上一 owner 异常退出遗留的旧 LIST 残块在挂角色前清除 + expect(storeControl.deleteChunks).toHaveBeenCalledWith(identity.replayId); + expect(dbControl.rows).toHaveLength(0); + }); + + it("claim 竞态输掉时不清残块(他人 LIST 不可碰)", async () => { + storeControl.tryClaimOwner.mockResolvedValueOnce(false); + + await expect(ProxyReplayGuard.ensure(makeSession())).resolves.toBeNull(); + expect(storeControl.deleteChunks).not.toHaveBeenCalled(); + }); + + it("claim 竞态输掉时放行且不带 replay 角色", async () => { + storeControl.tryClaimOwner.mockResolvedValueOnce(false); + const session = makeSession(); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + expect(session.replayState).toBeNull(); + }); + + it("meta verifier 不符(哈希碰撞)时绝不重放", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce( + makeMeta(identity, { status: "completed", verifier: "f".repeat(32) }) + ); + const session = makeSession(); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + expect(storeControl.readChunks).not.toHaveBeenCalled(); + expect(dbControl.rows).toHaveLength(0); + expect(storeControl.tryClaimOwner).toHaveBeenCalled(); + }); + + it("PG 持久行 verifier 不符时放行", async () => { + storeControl.findCompleted.mockResolvedValueOnce({ + verifier: "f".repeat(32), + statusCode: 200, + headersJson: null, + payload: "data: x\n\n", + }); + + await expect(ProxyReplayGuard.ensure(makeSession())).resolves.toBeNull(); + expect(dbControl.rows).toHaveLength(0); + }); + + it("aborted 终态条目不可重放", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "aborted" })); + + await expect(ProxyReplayGuard.ensure(makeSession())).resolves.toBeNull(); + expect(dbControl.rows).toHaveLength(0); + }); + + it("owning 但心跳过期(owner 失联)时不 attach 死流", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce( + makeMeta(identity, { status: "owning", heartbeatAt: Date.now() - 31_000 }) + ); + + await expect(ProxyReplayGuard.ensure(makeSession())).resolves.toBeNull(); + expect(dbControl.rows).toHaveLength(0); + }); + + it("owning 但去重开关关闭时不 attach", async () => { + envControl.liveDedup = false; + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "owning" })); + + await expect(ProxyReplayGuard.ensure(makeSession())).resolves.toBeNull(); + expect(storeControl.readChunks).not.toHaveBeenCalled(); + }); + + it("x-cch-no-replay: 1 跳过 attach(不重放),条目缺失/未完成时仍可成为 owner", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "owning" })); + storeControl.tryClaimOwner.mockResolvedValueOnce(true); + const session = makeSession({ headers: { [REPLAY_BYPASS_HEADER]: "1" } }); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + // 不 attach:不读 chunks、不写审计行 + expect(storeControl.readChunks).not.toHaveBeenCalled(); + expect(dbControl.rows).toHaveLength(0); + expect(storeControl.tryClaimOwner).toHaveBeenCalledWith(identity.replayId, expect.any(String)); + expect(session.replayState?.role).toBe("owner"); + }); + + it("x-cch-no-replay: 1 遇已完成条目(verifier 匹配):不 claim 不覆写,保留给其他客户端", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "completed" })); + const session = makeSession({ headers: { [REPLAY_BYPASS_HEADER]: "1" } }); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + expect(storeControl.tryClaimOwner).not.toHaveBeenCalled(); + expect(storeControl.deleteChunks).not.toHaveBeenCalled(); + expect(session.replayState).toBeNull(); + // 也不重放:照常执行(有意重复采样语义) + expect(storeControl.readChunks).not.toHaveBeenCalled(); + expect(dbControl.rows).toHaveLength(0); + }); + + it("x-cch-no-replay: 1 遇 completed 但 verifier 不符(哈希碰撞):不受保护,仍可 claim", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce( + makeMeta(identity, { status: "completed", verifier: "f".repeat(32) }) + ); + storeControl.tryClaimOwner.mockResolvedValueOnce(true); + const session = makeSession({ headers: { [REPLAY_BYPASS_HEADER]: "1" } }); + + await expect(ProxyReplayGuard.ensure(session)).resolves.toBeNull(); + expect(storeControl.tryClaimOwner).toHaveBeenCalledWith(identity.replayId, expect.any(String)); + expect(session.replayState?.role).toBe("owner"); + }); + + it("存储异常 fail-open:照常放行", async () => { + storeControl.getMeta.mockRejectedValueOnce(new Error("redis exploded")); + + await expect(ProxyReplayGuard.ensure(makeSession())).resolves.toBeNull(); + }); +}); + +describe("ProxyReplayGuard:completed 全量重放", () => { + it("Redis 热层 completed:全量重放响应头与 body,并写审计行", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce( + makeMeta(identity, { status: "completed", statusCode: 200 }) + ); + storeControl.readChunks.mockResolvedValueOnce(["data: a\n\n", "data: b\n\n"]); + const session = makeSession(); + + const response = await ProxyReplayGuard.ensure(session); + + expect(response).not.toBeNull(); + expect(response?.status).toBe(200); + expect(response?.headers.get("content-type")).toBe("text/event-stream; charset=utf-8"); + expect(response?.headers.get("cache-control")).toBe("no-cache"); + expect(response?.headers.get("x-cch-replay")).toBe("completed"); + await expect(response?.text()).resolves.toBe("data: a\n\ndata: b\n\n"); + + expect(storeControl.readChunks).toHaveBeenCalledWith(identity.replayId, 0); + expect(storeControl.tryClaimOwner).not.toHaveBeenCalled(); + + expect(dbControl.rows).toHaveLength(1); + expect(dbControl.rows[0]).toMatchObject({ + providerId: 0, + userId: 22, + key: "sk-test", + model: "claude-sonnet-4", + sessionId: "sess-1", + statusCode: 200, + costUsd: "0", + blockedBy: "replay_serve", + endpoint: "/v1/messages", + messagesCount: 1, + userAgent: "vitest-agent", + }); + expect(String(dbControl.rows[0].blockedReason)).toContain("redis_completed"); + expect(String(dbControl.rows[0].blockedReason)).toContain(identity.replayId.slice(0, 12)); + }); + + it("热层块已过期时落 PG 持久层重放", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "completed" })); + storeControl.readChunks.mockResolvedValueOnce([]); + storeControl.findCompleted.mockResolvedValueOnce({ + verifier: identity.verifier, + statusCode: 200, + headersJson: { "content-type": "text/event-stream" }, + payload: "data: pg\n\n", + }); + + const response = await ProxyReplayGuard.ensure(makeSession()); + + expect(response?.headers.get("x-cch-replay")).toBe("completed"); + await expect(response?.text()).resolves.toBe("data: pg\n\n"); + expect(String(dbControl.rows[0].blockedReason)).toContain("pg_completed"); + }); + + it("Redis 全 miss 时 PG 持久层直接命中;headersJson 缺失回退 SSE 头", async () => { + const identity = expectedIdentity(); + storeControl.findCompleted.mockResolvedValueOnce({ + verifier: identity.verifier, + statusCode: 201, + headersJson: null, + payload: "data: durable\n\n", + }); + + const response = await ProxyReplayGuard.ensure(makeSession()); + + expect(response?.status).toBe(201); + expect(response?.headers.get("content-type")).toBe("text/event-stream"); + await expect(response?.text()).resolves.toBe("data: durable\n\n"); + expect(dbControl.rows[0]).toMatchObject({ statusCode: 201, blockedBy: "replay_serve" }); + }); + + it("审计行写失败不影响重放响应", async () => { + const identity = expectedIdentity(); + dbControl.insertError = new Error("pg down"); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "completed" })); + storeControl.readChunks.mockResolvedValueOnce(["data: a\n\n"]); + + const response = await ProxyReplayGuard.ensure(makeSession()); + expect(response).not.toBeNull(); + await expect(response?.text()).resolves.toBe("data: a\n\n"); + }); + + it("缺认证上下文(apiKey 为空)时跳过审计行但仍重放", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "completed" })); + storeControl.readChunks.mockResolvedValueOnce(["data: a\n\n"]); + + const response = await ProxyReplayGuard.ensure(makeSession({ apiKey: null })); + expect(response).not.toBeNull(); + expect(dbControl.rows).toHaveLength(0); + }); +}); + +describe("ProxyReplayGuard:owning attach-live 跟尾", () => { + it("先吐已缓存前缀,轮询跟尾直到 completed 收尾", async () => { + const identity = expectedIdentity(); + const completed = makeMeta(identity, { status: "completed" }); + const metaSequence: ReplayMeta[] = [makeMeta(identity, { status: "owning" })]; + storeControl.getMeta.mockImplementation(async () => metaSequence.shift() ?? completed); + + // pull 循环时序:(0)->前缀["a"];(1)->[] 触发 meta 查询得 completed; + // tail(1)->["b"](completed 与最后一批块的竞态补读);(2)->[];tail(2)->[] 收尾 + const chunkSequence: string[][] = [["data: a\n\n"], [], ["data: b\n\n"], [], []]; + storeControl.readChunks.mockImplementation(async () => chunkSequence.shift() ?? []); + + const response = await ProxyReplayGuard.ensure(makeSession()); + + expect(response).not.toBeNull(); + expect(response?.status).toBe(200); + expect(response?.headers.get("x-cch-replay")).toBe("live"); + await expect(response?.text()).resolves.toBe("data: a\n\ndata: b\n\n"); + + expect(dbControl.rows).toHaveLength(1); + expect(dbControl.rows[0]).toMatchObject({ + blockedBy: "replay_serve", + costUsd: "0", + providerId: 0, + }); + expect(String(dbControl.rows[0].blockedReason)).toContain("attached_live"); + expect(storeControl.tryClaimOwner).not.toHaveBeenCalled(); + }); + + it("attach 中 Redis 失联按传输错误终止流", async () => { + const identity = expectedIdentity(); + storeControl.getMeta.mockResolvedValueOnce(makeMeta(identity, { status: "owning" })); + storeControl.readChunks.mockResolvedValueOnce(null); + + const response = await ProxyReplayGuard.ensure(makeSession()); + + expect(response).not.toBeNull(); + await expect(response?.text()).rejects.toThrow("replay attach lost redis connection"); + expect(dbControl.rows).toHaveLength(1); + }); + + it("attach 中源条目转为 aborted 时终止流", async () => { + const identity = expectedIdentity(); + const metaSequence: ReplayMeta[] = [ + makeMeta(identity, { status: "owning" }), + makeMeta(identity, { status: "aborted" }), + ]; + storeControl.getMeta.mockImplementation( + async () => metaSequence.shift() ?? makeMeta(identity, { status: "aborted" }) + ); + storeControl.readChunks.mockResolvedValue([]); + + const response = await ProxyReplayGuard.ensure(makeSession()); + + expect(response).not.toBeNull(); + await expect(response?.text()).rejects.toThrow("replay source aborted"); + }); +}); diff --git a/tests/unit/proxy/replay-identity.test.ts b/tests/unit/proxy/replay-identity.test.ts new file mode 100644 index 000000000..9a5eb4e45 --- /dev/null +++ b/tests/unit/proxy/replay-identity.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deriveReplayIdentity } from "@/app/v1/_lib/proxy/replay/replay-identity"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { buildScopeTag } from "@/lib/request-identity"; + +/** + * F2 Replay 身份推导单测。 + * + * deriveReplayIdentity 是纯函数(除 getEnvConfig 读 flag),这里 mock + * "@/lib/config/env.schema" 注入 ENABLE_REQUEST_REPLAY(模式复刻 + * tests/unit/proxy/stream-gate-forwarder-integration.test.ts),其余字段取 + * EnvSchema.parse({}) 默认值;session 用最小 stub。 + */ + +const envControl = vi.hoisted(() => ({ + enableReplay: true, +})); + +vi.mock("@/lib/config/env.schema", async (importOriginal) => { + const actual = await importOriginal(); + const baseEnv = actual.EnvSchema.parse({}); + return { + ...actual, + getEnvConfig: () => ({ ...baseEnv, ENABLE_REQUEST_REPLAY: envControl.enableReplay }), + }; +}); + +interface SessionStubOverrides { + method?: string; + policyKind?: string; + message?: Record | undefined; + buffer?: ArrayBuffer; + model?: string | null; + keyId?: number; + userId?: number; + authState?: null; + format?: string; + endpoint?: string | null; + headers?: Record; +} + +const DEFAULT_MODEL = "claude-sonnet-4"; + +function makeSession(overrides: SessionStubOverrides = {}): ProxySession { + const message = + "message" in overrides + ? overrides.message + : { stream: true, model: DEFAULT_MODEL, messages: [{ role: "user", content: "hi" }] }; + const model = "model" in overrides ? (overrides.model ?? null) : DEFAULT_MODEL; + const authState = + "authState" in overrides + ? overrides.authState + : { key: { id: overrides.keyId ?? 11 }, user: { id: overrides.userId ?? 22 } }; + return { + method: overrides.method ?? "POST", + headers: new Headers(overrides.headers ?? {}), + request: { message, buffer: overrides.buffer, model }, + authState, + originalFormat: overrides.format ?? "claude", + getEndpointPolicy: () => ({ kind: overrides.policyKind ?? "default" }), + getOriginalModel: () => model, + getEndpoint: () => ("endpoint" in overrides ? (overrides.endpoint ?? null) : "/v1/messages"), + } as unknown as ProxySession; +} + +beforeEach(() => { + envControl.enableReplay = true; +}); + +describe("deriveReplayIdentity:确定性", () => { + it("相同规范化输入重推导得到相同 replayId 与 verifier", () => { + const first = deriveReplayIdentity(makeSession()); + const second = deriveReplayIdentity(makeSession()); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(second?.replayId).toBe(first?.replayId); + expect(second?.verifier).toBe(first?.verifier); + }); + + it("message 键序不同但内容相同应得到相同 replayId(键序稳定序列化)", () => { + const a = deriveReplayIdentity( + makeSession({ message: { max_tokens: 8, stream: true, model: DEFAULT_MODEL } }) + ); + const b = deriveReplayIdentity( + makeSession({ message: { stream: true, model: DEFAULT_MODEL, max_tokens: 8 } }) + ); + expect(a?.replayId).toBe(b?.replayId); + expect(a?.verifier).toBe(b?.verifier); + }); + + it("身份基于过滤后 message,不受原始 buffer 影响(同 message 不同 buffer 同 ID)", () => { + const bufferA = new TextEncoder().encode('{"stream":true,"raw":"a"}').buffer as ArrayBuffer; + const bufferB = new TextEncoder().encode('{"stream":true,"raw":"b"}').buffer as ArrayBuffer; + const message = { stream: true, model: DEFAULT_MODEL, messages: [{ role: "user" }] }; + const a = deriveReplayIdentity(makeSession({ buffer: bufferA, message: { ...message } })); + const b = deriveReplayIdentity(makeSession({ buffer: bufferB, message: { ...message } })); + expect(a?.replayId).toBe(b?.replayId); + expect(a?.verifier).toBe(b?.verifier); + + // 反向:buffer 相同但过滤后 message 不同 -> 身份不同(过滤规则变更产生新身份) + const c = deriveReplayIdentity( + makeSession({ buffer: bufferA, message: { ...message, extra: 1 } }) + ); + expect(c?.replayId).not.toBe(a?.replayId); + }); + + it("长度与格式稳定:replayId/verifier 为 32 位小写 hex,scopeTag 为 16 位 hex", () => { + const identity = deriveReplayIdentity(makeSession()); + expect(identity?.replayId).toMatch(/^[0-9a-f]{32}$/); + expect(identity?.verifier).toMatch(/^[0-9a-f]{32}$/); + expect(identity?.scopeTag).toBe(buildScopeTag(11, "claude", DEFAULT_MODEL)); + expect(identity?.scopeTag).toMatch(/^[0-9a-f]{16}$/); + }); + + it("返回完整上下文字段", () => { + const identity = deriveReplayIdentity(makeSession()); + expect(identity).toMatchObject({ + keyId: 11, + userId: 22, + format: "claude", + model: DEFAULT_MODEL, + endpoint: "/v1/messages", + }); + }); + + it('getEndpoint 为 null 时回退到 "/"', () => { + const identity = deriveReplayIdentity(makeSession({ endpoint: null })); + expect(identity?.endpoint).toBe("/"); + }); +}); + +describe("deriveReplayIdentity:任一身份维度变化 ID 即变化", () => { + const base = () => deriveReplayIdentity(makeSession()); + + it("body 变化", () => { + const changed = deriveReplayIdentity( + makeSession({ message: { stream: true, model: DEFAULT_MODEL, messages: [] } }) + ); + expect(changed?.replayId).not.toBe(base()?.replayId); + expect(changed?.verifier).not.toBe(base()?.verifier); + }); + + it("keyId 变化只影响 replayId,verifier 保持内容维度不变", () => { + const changed = deriveReplayIdentity(makeSession({ keyId: 12 })); + expect(changed?.replayId).not.toBe(base()?.replayId); + expect(changed?.verifier).toBe(base()?.verifier); + }); + + it("model 变化", () => { + const changed = deriveReplayIdentity(makeSession({ model: "claude-opus-4" })); + expect(changed?.replayId).not.toBe(base()?.replayId); + expect(changed?.verifier).not.toBe(base()?.verifier); + }); + + it("endpoint 变化", () => { + const changed = deriveReplayIdentity(makeSession({ endpoint: "/v1/chat/completions" })); + expect(changed?.replayId).not.toBe(base()?.replayId); + }); + + it("idempotency-key 变化", () => { + const a = deriveReplayIdentity(makeSession({ headers: { "idempotency-key": "ik-1" } })); + const b = deriveReplayIdentity(makeSession({ headers: { "idempotency-key": "ik-2" } })); + expect(a?.replayId).not.toBe(base()?.replayId); + expect(a?.replayId).not.toBe(b?.replayId); + expect(a?.verifier).not.toBe(b?.verifier); + }); + + it("x-idempotency-key 参与推导,且 idempotency-key 优先", () => { + const xOnly = deriveReplayIdentity(makeSession({ headers: { "x-idempotency-key": "ik-x" } })); + expect(xOnly?.replayId).not.toBe(base()?.replayId); + + const both = deriveReplayIdentity( + makeSession({ headers: { "idempotency-key": "ik-1", "x-idempotency-key": "ik-x" } }) + ); + const primaryOnly = deriveReplayIdentity( + makeSession({ headers: { "idempotency-key": "ik-1" } }) + ); + expect(both?.replayId).toBe(primaryOnly?.replayId); + }); + + it("verifier 与 replayId 不同源(不同盐,同输入不相等)", () => { + const identity = deriveReplayIdentity(makeSession()); + expect(identity?.verifier).not.toBe(identity?.replayId); + }); +}); + +describe("deriveReplayIdentity:不合格条件返回 null", () => { + it("功能开关关闭", () => { + envControl.enableReplay = false; + expect(deriveReplayIdentity(makeSession())).toBeNull(); + }); + + it("非 default endpoint policy", () => { + expect(deriveReplayIdentity(makeSession({ policyKind: "raw_passthrough" }))).toBeNull(); + }); + + it("非 POST 请求", () => { + expect(deriveReplayIdentity(makeSession({ method: "GET" }))).toBeNull(); + }); + + it("非流式请求(stream 缺失或 false)", () => { + expect( + deriveReplayIdentity(makeSession({ message: { stream: false, model: DEFAULT_MODEL } })) + ).toBeNull(); + expect(deriveReplayIdentity(makeSession({ message: { model: DEFAULT_MODEL } }))).toBeNull(); + }); + + it("缺认证主体(authState 为 null 或 keyId falsy)", () => { + expect(deriveReplayIdentity(makeSession({ authState: null }))).toBeNull(); + expect(deriveReplayIdentity(makeSession({ keyId: 0 }))).toBeNull(); + }); + + it("message 缺失导致内部异常时 fail-open 返回 null", () => { + expect(deriveReplayIdentity(makeSession({ message: undefined }))).toBeNull(); + }); +}); diff --git a/tests/unit/proxy/replay-spool.test.ts b/tests/unit/proxy/replay-spool.test.ts new file mode 100644 index 000000000..35625383c --- /dev/null +++ b/tests/unit/proxy/replay-spool.test.ts @@ -0,0 +1,533 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReplayIdentity } from "@/app/v1/_lib/proxy/replay/replay-identity"; +import { + createReplaySpoolIfOwner, + getActiveReplaySpoolCount, + ReplaySpool, +} from "@/app/v1/_lib/proxy/replay/replay-spool"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { logger } from "@/lib/logger"; + +/** + * F2 owner 侧 spool 单测。 + * + * mock "@/app/v1/_lib/proxy/replay/replay-store".getReplayStore 注入可观测 + * store mock(callOrder 记录调用顺序),env 走 EnvSchema.parse({}) 默认值 + + * envControl 动态注入;write-behind 定时用 fake timers 驱动。 + */ + +const envControl = vi.hoisted(() => ({ + enableReplay: true, + maxPayloadBytes: 8 * 1024 * 1024, + maxConcurrentSpools: 64, +})); + +const storeControl = vi.hoisted(() => { + const order: string[] = []; + const store = { + appendChunks: vi.fn(async (_replayId: string, values: string[]) => { + order.push(`append:${values.join("|")}`); + return values.length; + }), + setMeta: vi.fn(async (_replayId: string, meta: { status: string }) => { + order.push(`meta:${meta.status}`); + return true; + }), + renewOwnerLease: vi.fn(async () => { + order.push("renew"); + return true; + }), + releaseOwner: vi.fn(async () => { + order.push("release"); + }), + persistCompleted: vi.fn(async () => { + order.push("persist"); + }), + deleteEntry: vi.fn(async () => { + order.push("deleteEntry"); + }), + deleteChunks: vi.fn(async () => { + order.push("deleteChunks"); + }), + }; + return { order, store }; +}); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/config/env.schema", async (importOriginal) => { + const actual = await importOriginal(); + const baseEnv = actual.EnvSchema.parse({}); + return { + ...actual, + getEnvConfig: () => ({ + ...baseEnv, + ENABLE_REQUEST_REPLAY: envControl.enableReplay, + REPLAY_MAX_PAYLOAD_BYTES: envControl.maxPayloadBytes, + REPLAY_MAX_CONCURRENT_SPOOLS: envControl.maxConcurrentSpools, + }), + }; +}); + +vi.mock("@/app/v1/_lib/proxy/replay/replay-store", () => ({ + getReplayStore: () => storeControl.store, +})); + +const identity: ReplayIdentity = { + replayId: "0123456789abcdef0123456789abcdef", + verifier: "fedcba9876543210fedcba9876543210", + scopeTag: "0011223344556677", + keyId: 11, + userId: 22, + format: "claude", + model: "claude-sonnet-4", + endpoint: "/v1/messages", +}; + +const encoder = new TextEncoder(); + +function makeSpool(statusCode = 200, contentType = "text/event-stream"): ReplaySpool { + return new ReplaySpool(identity, "owner-token", statusCode, contentType); +} + +async function drainWriteChain(spool: ReplaySpool): Promise { + await (spool as unknown as { writeChain: Promise }).writeChain; +} + +function makeOwnerSession(): ProxySession { + return { + replayState: { identity, ownerToken: "owner-token", role: "owner" }, + } as unknown as ProxySession; +} + +function sseResponse(status = 200, contentType: string | null = "text/event-stream"): Response { + const headers = new Headers(); + if (contentType) headers.set("content-type", contentType); + return new Response(null, { status, headers }); +} + +beforeEach(() => { + envControl.enableReplay = true; + envControl.maxPayloadBytes = 8 * 1024 * 1024; + envControl.maxConcurrentSpools = 64; + storeControl.order.length = 0; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + expect(getActiveReplaySpoolCount()).toBe(0); +}); + +describe("ReplaySpool:write-behind 批量冲刷", () => { + it("小块累积由 100ms 定时批量 RPUSH,并同步续 meta 心跳与 owner 租约", async () => { + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + spool.observe(encoder.encode("data: b\n\n")); + + expect(storeControl.store.appendChunks).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(100); + await drainWriteChain(spool); + + expect(storeControl.store.appendChunks).toHaveBeenCalledTimes(1); + expect(storeControl.store.appendChunks).toHaveBeenCalledWith(identity.replayId, [ + "data: a\n\n", + "data: b\n\n", + ]); + expect(storeControl.store.setMeta).toHaveBeenCalledWith( + identity.replayId, + expect.objectContaining({ + status: "owning", + verifier: identity.verifier, + scopeTag: identity.scopeTag, + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + chunkCount: 2, + byteSize: 18, + heartbeatAt: expect.any(Number), + }) + ); + expect(storeControl.store.renewOwnerLease).toHaveBeenCalledWith( + identity.replayId, + "owner-token" + ); + + await spool.abort("test_cleanup"); + }); + + it("累积达到 64KB 阈值立即冲刷,不等待定时器", async () => { + const spool = makeSpool(); + spool.observe(encoder.encode("x".repeat(64 * 1024))); + + await drainWriteChain(spool); + + expect(storeControl.store.appendChunks).toHaveBeenCalledTimes(1); + + await spool.abort("test_cleanup"); + }); + + it("空 chunk 不触发任何调度", async () => { + const spool = makeSpool(); + spool.observe(new Uint8Array(0)); + + await vi.advanceTimersByTimeAsync(200); + await drainWriteChain(spool); + expect(storeControl.store.appendChunks).not.toHaveBeenCalled(); + + await spool.abort("test_cleanup"); + }); + + it("appendChunks 返回 null(Redis 不可用)时放弃 spool", async () => { + storeControl.store.appendChunks.mockResolvedValueOnce(null); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await vi.advanceTimersByTimeAsync(100); + await drainWriteChain(spool); + + expect(storeControl.store.deleteEntry).toHaveBeenCalledWith(identity.replayId); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(storeControl.store.setMeta).not.toHaveBeenCalled(); + expect(getActiveReplaySpoolCount()).toBe(0); + }); + + it("冲刷续接体异常(setMeta 抛错)时 disable:链不被 poisoned、条目删除、租约释放", async () => { + storeControl.store.setMeta.mockRejectedValueOnce(new Error("redis exploded")); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await vi.advanceTimersByTimeAsync(100); + // 链必须 resolve 而非 reject(unhandled rejection 防护) + await expect(drainWriteChain(spool)).resolves.toBeUndefined(); + + expect(storeControl.store.deleteEntry).toHaveBeenCalledWith(identity.replayId); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(spool.isTerminal).toBe(true); + expect(getActiveReplaySpoolCount()).toBe(0); + + // 失效后 observe/complete 均为 no-op + spool.observe(encoder.encode("data: late\n\n")); + await vi.advanceTimersByTimeAsync(200); + await spool.completeAfterBilling(1); + expect(storeControl.store.persistCompleted).not.toHaveBeenCalled(); + }); + + it("bootstrap 续接体异常同样 disable 而不 poison 链", async () => { + storeControl.store.setMeta.mockRejectedValueOnce(new Error("redis exploded")); + const spool = makeSpool(); + spool.bootstrap(); + + await expect(drainWriteChain(spool)).resolves.toBeUndefined(); + expect(storeControl.store.deleteEntry).toHaveBeenCalledWith(identity.replayId); + expect(spool.isTerminal).toBe(true); + expect(getActiveReplaySpoolCount()).toBe(0); + }); +}); + +describe("ReplaySpool:续租丢失 halt", () => { + it("renewOwnerLease 返回 false 时停止 spool、释放自方租约,但绝不删条目", async () => { + storeControl.store.renewOwnerLease.mockResolvedValueOnce(false); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await vi.advanceTimersByTimeAsync(100); + await drainWriteChain(spool); + + // 新 owner 可能已在写同一 LIST:只 compare-delete 自己的租约,不碰 chunks/meta + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(storeControl.store.deleteEntry).not.toHaveBeenCalled(); + expect(storeControl.store.deleteChunks).not.toHaveBeenCalled(); + expect(spool.isTerminal).toBe(true); + expect(getActiveReplaySpoolCount()).toBe(0); + + // halt 后 abort 不得再写 aborted meta 覆盖新 owner + storeControl.store.setMeta.mockClear(); + await spool.abort("late_abort"); + expect(storeControl.store.setMeta).not.toHaveBeenCalled(); + expect(storeControl.store.deleteChunks).not.toHaveBeenCalled(); + }); + + it("halt 后 complete 为 no-op", async () => { + storeControl.store.renewOwnerLease.mockResolvedValueOnce(false); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + await vi.advanceTimersByTimeAsync(100); + await drainWriteChain(spool); + + await spool.completeAfterBilling(1); + expect(storeControl.store.persistCompleted).not.toHaveBeenCalled(); + const metaStatuses = storeControl.store.setMeta.mock.calls.map( + (call) => (call[1] as { status: string }).status + ); + expect(metaStatuses).not.toContain("completed"); + }); +}); + +describe("ReplaySpool:超尺寸自失效", () => { + it("超过 REPLAY_MAX_PAYLOAD_BYTES 停止 spool、删除已写条目并释放租约", async () => { + envControl.maxPayloadBytes = 16; + const spool = makeSpool(); + + spool.observe(encoder.encode("x".repeat(32))); + + // 计数同步归还;存储清理顺着 writeChain 串行执行(避免与 in-flight append 竞态) + expect(getActiveReplaySpoolCount()).toBe(0); + await drainWriteChain(spool); + expect(storeControl.store.deleteEntry).toHaveBeenCalledWith(identity.replayId); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + + // 已失效:后续 observe 与 complete 均为 no-op + spool.observe(encoder.encode("more")); + await vi.advanceTimersByTimeAsync(200); + await spool.completeAfterBilling(1); + expect(storeControl.store.appendChunks).not.toHaveBeenCalled(); + expect(storeControl.store.persistCompleted).not.toHaveBeenCalled(); + expect(storeControl.store.setMeta).not.toHaveBeenCalled(); + }); +}); + +describe("ReplaySpool:completeAfterBilling 终态屏障", () => { + it("按 尾批冲刷 -> PG 持久化 -> completed meta -> 释放租约 顺序执行", async () => { + const spool = makeSpool(200, "text/event-stream; charset=utf-8"); + spool.observe(encoder.encode("data: hello \n\n")); + spool.observe(encoder.encode("data: world\n\n")); + + await spool.completeAfterBilling(42); + + expect(storeControl.order).toEqual([ + "append:data: hello \n\n|data: world\n\n", + "persist", + "meta:completed", + "release", + ]); + expect(storeControl.store.persistCompleted).toHaveBeenCalledWith({ + replayId: identity.replayId, + verifier: identity.verifier, + scopeTag: identity.scopeTag, + keyId: 11, + userId: 22, + format: "claude", + model: "claude-sonnet-4", + statusCode: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + payload: "data: hello \n\ndata: world\n\n", + byteSize: 27, + sourceMessageRequestId: 42, + }); + expect(storeControl.store.setMeta).toHaveBeenCalledWith( + identity.replayId, + expect.objectContaining({ status: "completed", messageRequestId: 42, chunkCount: 2 }) + ); + expect(getActiveReplaySpoolCount()).toBe(0); + }); + + it("completed 只出现在 persistCompleted 成功之后;persist 失败则降级为 aborted 并清残块", async () => { + storeControl.store.persistCompleted.mockRejectedValueOnce(new Error("pg down")); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await spool.completeAfterBilling(7); + + const metaStatuses = storeControl.store.setMeta.mock.calls.map( + (call) => (call[1] as { status: string }).status + ); + expect(metaStatuses).not.toContain("completed"); + expect(metaStatuses).toContain("aborted"); + expect(storeControl.store.deleteChunks).toHaveBeenCalledWith(identity.replayId); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(logger.warn).toHaveBeenCalledWith( + "[ReplaySpool] complete failed, aborting entry", + expect.objectContaining({ pgPersisted: false }) + ); + expect(getActiveReplaySpoolCount()).toBe(0); + }); + + it("尾批冲刷返回 null(Redis 不可用)时终止为 aborted,绝不置 completed 也不写 PG", async () => { + storeControl.store.appendChunks.mockResolvedValueOnce(null); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await spool.completeAfterBilling(5); + + expect(storeControl.store.persistCompleted).not.toHaveBeenCalled(); + const metaStatuses = storeControl.store.setMeta.mock.calls.map( + (call) => (call[1] as { status: string }).status + ); + expect(metaStatuses).not.toContain("completed"); + expect(metaStatuses).toContain("aborted"); + expect(storeControl.store.deleteChunks).toHaveBeenCalledWith(identity.replayId); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(getActiveReplaySpoolCount()).toBe(0); + }); + + it("persist 成功但 completed 翻转失败:日志标记 pgPersisted=true,热层封死为 aborted", async () => { + // 首次 setMeta 即 complete 续接体里的 completed 写入(无 bootstrap、定时器未触发) + storeControl.store.setMeta.mockRejectedValueOnce(new Error("redis down")); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await spool.completeAfterBilling(7); + + expect(storeControl.store.persistCompleted).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "[ReplaySpool] complete failed, aborting entry", + expect.objectContaining({ pgPersisted: true }) + ); + const metaStatuses = storeControl.store.setMeta.mock.calls.map( + (call) => (call[1] as { status: string }).status + ); + expect(metaStatuses).toContain("aborted"); + expect(getActiveReplaySpoolCount()).toBe(0); + }); + + it("跨 chunk 截断的 UTF-8 序列在 complete 时冲刷解码尾部", async () => { + const spool = makeSpool(); + // "中" (0xE4 0xB8 0xAD) 只送前两字节:observe 阶段解码挂起,complete 时 flush 出替换字符 + spool.observe(new Uint8Array([0xe4, 0xb8])); + expect(storeControl.store.appendChunks).not.toHaveBeenCalled(); + + await spool.completeAfterBilling(9); + + expect(storeControl.store.appendChunks).toHaveBeenCalledWith(identity.replayId, ["\uFFFD"]); + expect(storeControl.store.persistCompleted).toHaveBeenCalledWith( + expect.objectContaining({ payload: "\uFFFD", byteSize: 2 }) + ); + }); + + it("重复 complete 是幂等 no-op", async () => { + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + await spool.completeAfterBilling(1); + + storeControl.order.length = 0; + await spool.completeAfterBilling(2); + expect(storeControl.order).toEqual([]); + }); +}); + +describe("ReplaySpool:abort 终态", () => { + it("置 aborted meta、删除响应块并释放租约", async () => { + const spool = makeSpool(); + spool.observe(encoder.encode("data: partial\n\n")); + + await spool.abort("upstream_error"); + + expect(storeControl.store.setMeta).toHaveBeenCalledWith( + identity.replayId, + expect.objectContaining({ status: "aborted", abortReason: "upstream_error" }) + ); + expect(storeControl.store.deleteChunks).toHaveBeenCalledWith(identity.replayId); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(storeControl.order).toEqual(["meta:aborted", "deleteChunks", "release"]); + expect(getActiveReplaySpoolCount()).toBe(0); + }); + + it("abort 后 observe 与 complete 均无副作用", async () => { + const spool = makeSpool(); + await spool.abort("client_disconnect"); + storeControl.order.length = 0; + + spool.observe(encoder.encode("data: late\n\n")); + await vi.advanceTimersByTimeAsync(200); + await spool.completeAfterBilling(1); + + expect(storeControl.order).toEqual([]); + expect(storeControl.store.appendChunks).not.toHaveBeenCalled(); + expect(storeControl.store.persistCompleted).not.toHaveBeenCalled(); + }); +}); + +describe("ReplaySpool:isTerminal", () => { + it("abort 置 terminal,disable(超限)置 disabled,两者均视为终态", async () => { + const aborted = makeSpool(); + expect(aborted.isTerminal).toBe(false); + await aborted.abort("done"); + expect(aborted.isTerminal).toBe(true); + + envControl.maxPayloadBytes = 4; + const oversized = makeSpool(); + oversized.observe(encoder.encode("12345678")); + expect(oversized.isTerminal).toBe(true); + await drainWriteChain(oversized); + }); +}); + +describe("createReplaySpoolIfOwner", () => { + it("非 owner 会话返回 null(无租约可释放)", () => { + const session = { replayState: null } as unknown as ProxySession; + expect(createReplaySpoolIfOwner(session, sseResponse())).toBeNull(); + expect(storeControl.store.releaseOwner).not.toHaveBeenCalled(); + }); + + it("功能开关关闭返回 null,并释放租约、清 replayState", () => { + envControl.enableReplay = false; + const session = makeOwnerSession(); + expect(createReplaySpoolIfOwner(session, sseResponse())).toBeNull(); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(session.replayState).toBeNull(); + }); + + it("非 2xx 或非 SSE 响应返回 null,并释放租约、清 replayState", () => { + const non2xx = makeOwnerSession(); + expect(createReplaySpoolIfOwner(non2xx, sseResponse(500))).toBeNull(); + expect(non2xx.replayState).toBeNull(); + + const nonSse = makeOwnerSession(); + expect(createReplaySpoolIfOwner(nonSse, sseResponse(200, "application/json"))).toBeNull(); + expect(nonSse.replayState).toBeNull(); + + expect(storeControl.store.releaseOwner).toHaveBeenCalledTimes(2); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + }); + + it("并发 spool 达上限时返回 null,并释放落选者租约", async () => { + envControl.maxConcurrentSpools = 1; + const first = createReplaySpoolIfOwner(makeOwnerSession(), sseResponse()); + expect(first).toBeInstanceOf(ReplaySpool); + expect(storeControl.store.releaseOwner).not.toHaveBeenCalled(); + + const second = makeOwnerSession(); + expect(createReplaySpoolIfOwner(second, sseResponse())).toBeNull(); + expect(second.replayState).toBeNull(); + expect(storeControl.store.releaseOwner).toHaveBeenCalledWith(identity.replayId, "owner-token"); + + await first?.abort("test_cleanup"); + }); + + it("正常创建 owner spool 并立即 bootstrap owning meta", async () => { + const spool = createReplaySpoolIfOwner(makeOwnerSession(), sseResponse()); + expect(spool).toBeInstanceOf(ReplaySpool); + expect(getActiveReplaySpoolCount()).toBe(1); + + await drainWriteChain(spool as ReplaySpool); + expect(storeControl.store.setMeta).toHaveBeenCalledWith( + identity.replayId, + expect.objectContaining({ status: "owning", chunkCount: 0, byteSize: 0 }) + ); + + await spool?.abort("test_cleanup"); + }); + + it("上游未带 content-type 时按 text/event-stream 处理", async () => { + const spool = createReplaySpoolIfOwner(makeOwnerSession(), sseResponse(200, null)); + expect(spool).toBeInstanceOf(ReplaySpool); + + await drainWriteChain(spool as ReplaySpool); + expect(storeControl.store.setMeta).toHaveBeenCalledWith( + identity.replayId, + expect.objectContaining({ headers: { "content-type": "text/event-stream" } }) + ); + + await spool?.abort("test_cleanup"); + }); +}); diff --git a/tests/unit/proxy/replay-store.test.ts b/tests/unit/proxy/replay-store.test.ts new file mode 100644 index 000000000..b9c22713c --- /dev/null +++ b/tests/unit/proxy/replay-store.test.ts @@ -0,0 +1,483 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getReplayStore, + type ReplayMeta, + type ReplayPersistedRow, + ReplayStore, + resolveReplayTtlSeconds, +} from "@/app/v1/_lib/proxy/replay/replay-store"; + +/** + * F2 Replay 双层存储单测。 + * + * - Redis 热层:mock "@/lib/redis/client".getRedisClient 注入内存版 fake client + * (Map 实现 KV/LIST/NX/XX/compare-delete 语义),或 null 验证 fail-open。 + * - PG 完成层:mock "@/drizzle/db",捕获 insert values 与 where 条件, + * 用 PgDialect.sqlToQuery 断言过期过滤 SQL。 + */ + +const envControl = vi.hoisted(() => ({ + shouldThrow: false, + replayTtlSeconds: 600, + completedTtlSeconds: 3600, +})); + +const redisControl = vi.hoisted(() => ({ + client: null as unknown, +})); + +const dbState = vi.hoisted(() => ({ + insertValues: [] as Record[], + onConflictCalls: 0, + deleteWheres: [] as unknown[], + selectWheres: [] as unknown[], + selectRows: [] as Record[], + insertError: null as Error | null, + selectError: null as Error | null, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/config/env.schema", async (importOriginal) => { + const actual = await importOriginal(); + const baseEnv = actual.EnvSchema.parse({}); + return { + ...actual, + getEnvConfig: () => { + if (envControl.shouldThrow) throw new Error("env unavailable"); + return { + ...baseEnv, + REPLAY_TTL_SECONDS: envControl.replayTtlSeconds, + REPLAY_COMPLETED_TTL_SECONDS: envControl.completedTtlSeconds, + }; + }, + }; +}); + +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: () => redisControl.client, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + insert: () => ({ + values: (values: Record) => { + if (dbState.insertError) throw dbState.insertError; + dbState.insertValues.push(values); + return { + onConflictDoNothing: async () => { + dbState.onConflictCalls += 1; + }, + }; + }, + }), + delete: () => ({ + where: (condition: unknown) => { + dbState.deleteWheres.push(condition); + return { returning: async () => [] as { replayId: string }[] }; + }, + }), + select: () => ({ + from: () => ({ + where: (condition: unknown) => { + dbState.selectWheres.push(condition); + return { + limit: async () => { + if (dbState.selectError) throw dbState.selectError; + return dbState.selectRows; + }, + }; + }, + }), + }), + }, +})); + +function createFakeRedis() { + const kv = new Map(); + const lists = new Map(); + const listTtls = new Map(); + return { + status: "ready", + kv, + lists, + listTtls, + setex: vi.fn(async (key: string, _ttl: number, value: string) => { + kv.set(key, value); + return "OK"; + }), + get: vi.fn(async (key: string) => kv.get(key) ?? null), + del: vi.fn(async (...keys: string[]) => { + let deleted = 0; + for (const key of keys) { + if (kv.delete(key)) deleted += 1; + if (lists.delete(key)) deleted += 1; + } + return deleted; + }), + set: vi.fn(async (key: string, value: string, ...args: (string | number)[]) => { + const flags = args.filter((arg): arg is string => typeof arg === "string"); + const exists = kv.has(key); + if (flags.includes("NX") && exists) return null; + if (flags.includes("XX") && !exists) return null; + kv.set(key, value); + return "OK"; + }), + // 按脚本内容分发:RPUSH+EXPIRE(list 追加)/ compare-expire(续租)/ compare-delete(释放) + eval: vi.fn( + async (script: string, _numkeys: number, key: string, ...args: (string | number)[]) => { + if (script.includes("RPUSH")) { + const [ttl, ...values] = args; + const list = lists.get(key) ?? []; + list.push(...values.map(String)); + lists.set(key, list); + if (Number(ttl) > 0) listTtls.set(key, Number(ttl)); + return list.length; + } + const token = args[0] as string; + if (script.includes("EXPIRE")) { + return kv.get(key) === token ? 1 : 0; + } + if (kv.get(key) === token) { + kv.delete(key); + return 1; + } + return 0; + } + ), + lrange: vi.fn(async (key: string, start: number, stop: number) => { + const list = lists.get(key) ?? []; + return stop === -1 ? list.slice(start) : list.slice(start, stop + 1); + }), + llen: vi.fn(async (key: string) => (lists.get(key) ?? []).length), + expire: vi.fn(async () => 1), + }; +} + +type FakeRedis = ReturnType; + +function makeMeta(overrides: Partial = {}): ReplayMeta { + return { + status: "owning", + verifier: "vf".repeat(16), + scopeTag: "st".repeat(8), + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + format: "claude", + model: "claude-sonnet-4", + chunkCount: 0, + byteSize: 0, + heartbeatAt: Date.now(), + ...overrides, + }; +} + +function makePersistedRow(overrides: Partial = {}): ReplayPersistedRow { + return { + replayId: "r1".repeat(16), + verifier: "vf".repeat(16), + scopeTag: "st".repeat(8), + keyId: 11, + userId: 22, + format: "claude", + model: "claude-sonnet-4", + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + payload: "data: hello\n\n", + byteSize: 13, + sourceMessageRequestId: 77, + ...overrides, + }; +} + +const dialect = new PgDialect(); + +function toSqlText(condition: unknown): string { + return dialect.sqlToQuery(condition as SQL).sql; +} + +beforeEach(() => { + envControl.shouldThrow = false; + envControl.replayTtlSeconds = 600; + envControl.completedTtlSeconds = 3600; + redisControl.client = createFakeRedis(); + dbState.insertValues = []; + dbState.onConflictCalls = 0; + dbState.deleteWheres = []; + dbState.selectWheres = []; + dbState.selectRows = []; + dbState.insertError = null; + dbState.selectError = null; +}); + +function currentRedis(): FakeRedis { + return redisControl.client as FakeRedis; +} + +describe("ReplayStore:Redis 不可用时全部 fail-open", () => { + it("client 为 null 时读 miss、写放弃、claim 失败,均不抛", async () => { + redisControl.client = null; + const store = new ReplayStore(); + + await expect(store.getMeta("r1")).resolves.toBeNull(); + await expect(store.setMeta("r1", makeMeta())).resolves.toBe(false); + await expect(store.appendChunks("r1", ["a"])).resolves.toBeNull(); + await expect(store.readChunks("r1", 0)).resolves.toBeNull(); + await expect(store.tryClaimOwner("r1", "tok")).resolves.toBe(false); + // 续租时 Redis 不可用 = 状态未知:返回 true 不惩罚正在冲刷的 owner + await expect(store.renewOwnerLease("r1", "tok")).resolves.toBe(true); + await expect(store.releaseOwner("r1", "tok")).resolves.toBeUndefined(); + await expect(store.deleteEntry("r1")).resolves.toBeUndefined(); + await expect(store.deleteChunks("r1")).resolves.toBeUndefined(); + }); + + it("client 未 ready 时同样 fail-open 且不发命令", async () => { + const fake = createFakeRedis(); + fake.status = "connecting"; + redisControl.client = fake; + const store = new ReplayStore(); + + await expect(store.getMeta("r1")).resolves.toBeNull(); + await expect(store.tryClaimOwner("r1", "tok")).resolves.toBe(false); + expect(fake.get).not.toHaveBeenCalled(); + expect(fake.set).not.toHaveBeenCalled(); + }); + + it("Redis 命令抛错时租约方法吞掉异常", async () => { + const fake = currentRedis(); + fake.set.mockRejectedValue(new Error("boom")); + fake.eval.mockRejectedValue(new Error("boom")); + const store = new ReplayStore(); + + await expect(store.tryClaimOwner("r1", "tok")).resolves.toBe(false); + // 续租异常保守视为所有权已失 + await expect(store.renewOwnerLease("r1", "tok")).resolves.toBe(false); + await expect(store.releaseOwner("r1", "tok")).resolves.toBeUndefined(); + }); +}); + +describe("ReplayStore:meta 状态机(Redis 热层)", () => { + it("setMeta/getMeta roundtrip,key 带前缀且 TTL 取 REPLAY_TTL_SECONDS", async () => { + envControl.replayTtlSeconds = 123; + const store = new ReplayStore(); + const meta = makeMeta(); + + await expect(store.setMeta("r1", meta)).resolves.toBe(true); + expect(currentRedis().setex).toHaveBeenCalledWith( + "cch:replay:meta:r1", + 123, + JSON.stringify(meta) + ); + await expect(store.getMeta("r1")).resolves.toEqual(meta); + }); + + it("setMeta 支持显式 ttlSeconds 覆盖", async () => { + const store = new ReplayStore(); + await store.setMeta("r1", makeMeta(), 45); + expect(currentRedis().setex).toHaveBeenCalledWith("cch:replay:meta:r1", 45, expect.any(String)); + }); + + it("owning -> completed 状态迁移", async () => { + const store = new ReplayStore(); + await store.setMeta("r1", makeMeta({ status: "owning" })); + await store.setMeta("r1", makeMeta({ status: "completed", chunkCount: 3 })); + + const meta = await store.getMeta("r1"); + expect(meta?.status).toBe("completed"); + expect(meta?.chunkCount).toBe(3); + }); + + it("owning -> aborted 状态迁移", async () => { + const store = new ReplayStore(); + await store.setMeta("r1", makeMeta({ status: "owning" })); + await store.setMeta("r1", makeMeta({ status: "aborted", abortReason: "upstream_error" })); + + const meta = await store.getMeta("r1"); + expect(meta?.status).toBe("aborted"); + expect(meta?.abortReason).toBe("upstream_error"); + }); + + it("deleteEntry 同时删除 meta 与 chunks", async () => { + const store = new ReplayStore(); + await store.setMeta("r1", makeMeta()); + await store.appendChunks("r1", ["a", "b"]); + + await store.deleteEntry("r1"); + + await expect(store.getMeta("r1")).resolves.toBeNull(); + await expect(store.readChunks("r1", 0)).resolves.toEqual([]); + }); +}); + +describe("ReplayStore:chunks 热层", () => { + it("appendChunks 单条 Lua 原子追加返回累计长度并续期,readChunks 支持 offset 跟尾", async () => { + envControl.replayTtlSeconds = 300; + const store = new ReplayStore(); + + await expect(store.appendChunks("r1", ["a", "b"])).resolves.toBe(2); + await expect(store.appendChunks("r1", ["c"])).resolves.toBe(3); + expect(currentRedis().eval).toHaveBeenCalledWith( + expect.stringContaining("RPUSH"), + 1, + "cch:replay:chunks:r1", + 300, + "a", + "b" + ); + expect(currentRedis().listTtls.get("cch:replay:chunks:r1")).toBe(300); + + await expect(store.readChunks("r1", 0)).resolves.toEqual(["a", "b", "c"]); + await expect(store.readChunks("r1", 2)).resolves.toEqual(["c"]); + await expect(store.readChunks("r1", 3)).resolves.toEqual([]); + }); +}); + +describe("ReplayStore:owner 租约", () => { + it("tryClaimOwner 走 SET NX EX 语义,首个 claim 成功、并发第二个失败", async () => { + const store = new ReplayStore(); + + await expect(store.tryClaimOwner("r1", "tok-a")).resolves.toBe(true); + expect(currentRedis().set).toHaveBeenCalledWith("cch:replay:owner:r1", "tok-a", "EX", 45, "NX"); + await expect(store.tryClaimOwner("r1", "tok-b")).resolves.toBe(false); + expect(currentRedis().kv.get("cch:replay:owner:r1")).toBe("tok-a"); + }); + + it("renewOwnerLease 是 compare-and-expire:token 仍属自己时续期返回 true", async () => { + const store = new ReplayStore(); + await store.tryClaimOwner("r1", "tok-a"); + + await expect(store.renewOwnerLease("r1", "tok-a")).resolves.toBe(true); + expect(currentRedis().eval).toHaveBeenLastCalledWith( + expect.stringContaining("EXPIRE"), + 1, + "cch:replay:owner:r1", + "tok-a", + 45 + ); + expect(currentRedis().kv.get("cch:replay:owner:r1")).toBe("tok-a"); + }); + + it("renewOwnerLease 在租约不存在或已被接管时返回 false,且绝不覆写他人租约", async () => { + const store = new ReplayStore(); + + await expect(store.renewOwnerLease("r1", "tok-a")).resolves.toBe(false); + expect(currentRedis().kv.has("cch:replay:owner:r1")).toBe(false); + + await store.tryClaimOwner("r1", "tok-b"); + await expect(store.renewOwnerLease("r1", "tok-a")).resolves.toBe(false); + expect(currentRedis().kv.get("cch:replay:owner:r1")).toBe("tok-b"); + }); + + it("releaseOwner 是 compare-delete:token 不匹配不删,匹配才删", async () => { + const store = new ReplayStore(); + await store.tryClaimOwner("r1", "tok-a"); + + await store.releaseOwner("r1", "tok-other"); + expect(currentRedis().kv.get("cch:replay:owner:r1")).toBe("tok-a"); + + await store.releaseOwner("r1", "tok-a"); + expect(currentRedis().kv.has("cch:replay:owner:r1")).toBe(false); + await expect(store.tryClaimOwner("r1", "tok-b")).resolves.toBe(true); + }); +}); + +describe("ReplayStore:PG 完成持久层", () => { + it("persistCompleted 写入行(expiresAt = now + REPLAY_COMPLETED_TTL_SECONDS),写路径不顺带清理", async () => { + envControl.completedTtlSeconds = 1000; + const store = new ReplayStore(); + const row = makePersistedRow(); + + const before = Date.now(); + await store.persistCompleted(row); + const after = Date.now(); + + expect(dbState.insertValues).toHaveLength(1); + const inserted = dbState.insertValues[0]; + expect(inserted).toMatchObject({ + replayId: row.replayId, + verifier: row.verifier, + scopeTag: row.scopeTag, + keyId: 11, + userId: 22, + format: "claude", + model: "claude-sonnet-4", + statusCode: 200, + headersJson: row.headers, + payload: row.payload, + byteSize: 13, + sourceMessageRequestId: 77, + }); + const expiresAt = (inserted.expiresAt as Date).getTime(); + expect(expiresAt).toBeGreaterThanOrEqual(before + 1000 * 1000); + expect(expiresAt).toBeLessThanOrEqual(after + 1000 * 1000); + expect(dbState.onConflictCalls).toBe(1); + + // 过期行清理只归定时调度器:写路径不做机会式扫尾 + expect(dbState.deleteWheres).toHaveLength(0); + }); + + it("persistCompleted 遇 PG 异常必须抛出(complete 屏障依赖异常走 abort)", async () => { + dbState.insertError = new Error("pg down"); + const store = new ReplayStore(); + + await expect(store.persistCompleted(makePersistedRow())).rejects.toThrow("pg down"); + }); + + it("cleanupExpired 删除过期行(供定时调度器调用)", async () => { + const store = new ReplayStore(); + await expect(store.cleanupExpired()).resolves.toBe(0); + + expect(dbState.deleteWheres).toHaveLength(1); + const deleteSql = toSqlText(dbState.deleteWheres[0]); + expect(deleteSql).toContain('"expires_at" <'); + }); + + it("findCompleted 只按 replayId + 未过期条件查询并返回首行", async () => { + const persisted = { replayId: "r1", verifier: "vf", payload: "data: x\n\n" }; + dbState.selectRows = [persisted]; + const store = new ReplayStore(); + + await expect(store.findCompleted("r1")).resolves.toEqual(persisted); + expect(dbState.selectWheres).toHaveLength(1); + const whereSql = toSqlText(dbState.selectWheres[0]); + expect(whereSql).toContain('"replay_id" ='); + expect(whereSql).toContain('"expires_at" >'); + }); + + it("findCompleted 无行返回 null,PG 异常也返回 null", async () => { + const store = new ReplayStore(); + await expect(store.findCompleted("r1")).resolves.toBeNull(); + + dbState.selectError = new Error("pg down"); + await expect(store.findCompleted("r1")).resolves.toBeNull(); + }); +}); + +describe("resolveReplayTtlSeconds / getReplayStore", () => { + it("读 env 的 REPLAY_TTL_SECONDS", () => { + envControl.replayTtlSeconds = 1234; + expect(resolveReplayTtlSeconds()).toBe(1234); + }); + + it("env 不可用时回退 600", () => { + envControl.shouldThrow = true; + expect(resolveReplayTtlSeconds()).toBe(600); + }); + + it("getReplayStore 返回共享单例", () => { + const first = getReplayStore(); + expect(getReplayStore()).toBe(first); + expect(first).toBeInstanceOf(ReplayStore); + }); +}); diff --git a/tests/unit/proxy/responses-session-id.test.ts b/tests/unit/proxy/responses-session-id.test.ts index 3f2b5dddb..cbbadaf76 100644 --- a/tests/unit/proxy/responses-session-id.test.ts +++ b/tests/unit/proxy/responses-session-id.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; -import { attachSessionIdToErrorResponse } from "@/app/v1/_lib/proxy/error-session-id"; +import { + attachSessionIdToErrorMessage, + attachSessionIdToErrorResponse, +} from "@/app/v1/_lib/proxy/error-session-id"; describe("ProxyResponses.attachSessionIdToErrorResponse", () => { test("appends to error.message for JSON error responses without exposing header", async () => { @@ -71,4 +74,61 @@ describe("ProxyResponses.attachSessionIdToErrorResponse", () => { expect(decorated).toBe(response); expect(await decorated.text()).toBe("data: hi\n\n"); }); + + test("does not rewrite error responses without content-type", async () => { + const response = new Response(null, { status: 500 }); + response.headers.delete("content-type"); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated).toBe(response); + }); + + test("returns original response when error body cannot be read", async () => { + const response = ProxyResponses.buildError(500, "boom"); + vi.spyOn(response, "clone").mockImplementation(() => { + throw new Error("body already consumed"); + }); + + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated).toBe(response); + const body = await decorated.json(); + expect(body.error.message).toBe("boom"); + }); + + test.each([ + { label: "json null", body: "null" }, + { label: "json top-level string", body: JSON.stringify("plain error") }, + { label: "error is null", body: JSON.stringify({ error: null }) }, + { label: "error is not an object", body: JSON.stringify({ error: "broken" }) }, + { label: "error without message", body: JSON.stringify({ error: {} }) }, + { label: "error.message is not a string", body: JSON.stringify({ error: { message: 123 } }) }, + { label: "invalid json", body: "{invalid json" }, + ])("does not rewrite unrecognized error payload: $label", async ({ body }) => { + const response = new Response(body, { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated).toBe(response); + expect(await decorated.text()).toBe(body); + }); +}); + +describe("attachSessionIdToErrorMessage", () => { + test("returns message unchanged when sessionId is missing", () => { + expect(attachSessionIdToErrorMessage(null, "boom")).toBe("boom"); + expect(attachSessionIdToErrorMessage(undefined, "boom")).toBe("boom"); + }); + + test("does not double-append when message already carries a session id", () => { + expect(attachSessionIdToErrorMessage("s_123", "boom (cch_session_id: s_999)")).toBe( + "boom (cch_session_id: s_999)" + ); + }); + + test("appends session id suffix to plain messages", () => { + expect(attachSessionIdToErrorMessage("s_123", "boom")).toBe("boom (cch_session_id: s_123)"); + }); }); diff --git a/tests/unit/proxy/stream-gate-content-gate.test.ts b/tests/unit/proxy/stream-gate-content-gate.test.ts new file mode 100644 index 000000000..ff1c5a09d --- /dev/null +++ b/tests/unit/proxy/stream-gate-content-gate.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { EmptyResponseError, ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { + concatChunks, + runStreamContentGate, + StreamPrecommitError, +} from "@/app/v1/_lib/proxy/stream-gate/stream-content-gate"; + +const encoder = new TextEncoder(); + +function readerFromChunks( + chunks: (string | Uint8Array)[], + options?: { failAfter?: number; failWith?: Error } +): ReadableStreamDefaultReader { + let index = 0; + const stream = new ReadableStream({ + pull(controller) { + if (options?.failAfter !== undefined && index >= options.failAfter) { + controller.error(options.failWith ?? new Error("stream failed")); + return; + } + if (index >= chunks.length) { + controller.close(); + return; + } + const chunk = chunks[index++]; + controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk); + }, + }); + return stream.getReader(); +} + +const GATE_OPTIONS = { + family: "anthropic" as const, + providerId: 7, + providerName: "test-provider", + prebufferEventCap: 64, + prebufferByteCap: 256 * 1024, +}; + +const PING = 'event: ping\ndata: {"type":"ping"}\n\n'; +const MESSAGE_START = + 'event: message_start\ndata: {"type":"message_start","message":{"id":"m1"}}\n\n'; +const TEXT_DELTA = + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}\n\n'; +const ERROR_FRAME = + 'event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"overloaded"}}\n\n'; +const MESSAGE_STOP = 'event: message_stop\ndata: {"type":"message_stop"}\n\n'; + +async function drainPrefix(chunks: Uint8Array[]): Promise { + const merged = concatChunks(chunks); + return merged ? new TextDecoder().decode(merged) : ""; +} + +describe("runStreamContentGate", () => { + it("commits on first valid content frame and returns full buffered prefix", async () => { + const reader = readerFromChunks([PING, MESSAGE_START, TEXT_DELTA, MESSAGE_STOP]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(true); + if (!result.committed) return; + // 前缀包含中性帧与触发提交的内容帧所在 chunk + expect(await drainPrefix(result.prefixChunks)).toBe(PING + MESSAGE_START + TEXT_DELTA); + expect(result.readerDone).toBe(false); + // 剩余字节(message_stop)仍在 reader 上 + const rest = await reader.read(); + expect(new TextDecoder().decode(rest.value)).toBe(MESSAGE_STOP); + }); + + it("fails over on error frame before content with upstream error body preserved", async () => { + const reader = readerFromChunks([PING, ERROR_FRAME, TEXT_DELTA]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(false); + if (result.committed) return; + expect(result.error).toBeInstanceOf(StreamPrecommitError); + const gateError = result.error as StreamPrecommitError; + expect(gateError.gateReason).toBe("gate_error"); + expect(gateError.statusCode).toBe(502); + expect(gateError.upstreamError?.body).toContain("overloaded_error"); + expect(gateError.upstreamError?.providerId).toBe(7); + }); + + it("fails over on malformed frame (fail-closed)", async () => { + const reader = readerFromChunks([PING, "data: {broken json\n\n"]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(false); + if (result.committed) return; + expect((result.error as StreamPrecommitError).gateReason).toBe("decode_error"); + }); + + it("treats terminal before content as empty stream", async () => { + const reader = readerFromChunks([PING, MESSAGE_STOP]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(false); + if (result.committed) return; + expect((result.error as StreamPrecommitError).gateReason).toBe("empty_stream"); + }); + + it("treats EOF without any content as empty stream", async () => { + const reader = readerFromChunks([PING, MESSAGE_START]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(false); + if (result.committed) return; + expect((result.error as StreamPrecommitError).gateReason).toBe("empty_stream"); + }); + + it("treats fully empty stream as empty stream", async () => { + const reader = readerFromChunks([]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(false); + if (result.committed) return; + expect((result.error as StreamPrecommitError).gateReason).toBe("empty_stream"); + }); + + it("commits on trailing content frame without terminating blank line", async () => { + const reader = readerFromChunks([ + 'data: {"type":"content_block_delta","delta":{"text":"tail"}}', + ]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(true); + if (!result.committed) return; + expect(result.readerDone).toBe(true); + }); + + it("fails with prebuffer_overflow when event cap exceeded", async () => { + const pings = Array.from({ length: 20 }, () => PING); + const reader = readerFromChunks(pings); + const result = await runStreamContentGate(reader, { + ...GATE_OPTIONS, + prebufferEventCap: 10, + }); + expect(result.committed).toBe(false); + if (result.committed) return; + expect((result.error as StreamPrecommitError).gateReason).toBe("prebuffer_overflow"); + }); + + it("fails with prebuffer_overflow when a single chunk carries more frames than the event cap", async () => { + // event 上限是逐帧硬上限:单 chunk 内塞满小中性帧同样触发 + const manyFramesOneChunk = Array.from({ length: 20 }, () => PING).join(""); + const reader = readerFromChunks([manyFramesOneChunk]); + const result = await runStreamContentGate(reader, { + ...GATE_OPTIONS, + prebufferEventCap: 10, + }); + expect(result.committed).toBe(false); + if (result.committed) return; + expect((result.error as StreamPrecommitError).gateReason).toBe("prebuffer_overflow"); + }); + + it("commits when content arrives right at the event cap boundary", async () => { + // 第 cap 帧仍允许缓冲(framesSeen > cap 才溢出);下一帧即 content 应正常提交 + const reader = readerFromChunks([PING + PING + PING + TEXT_DELTA]); + const result = await runStreamContentGate(reader, { + ...GATE_OPTIONS, + prebufferEventCap: 3, + }); + expect(result.committed).toBe(true); + }); + + it("fails with prebuffer_overflow when byte cap exceeded", async () => { + const bigNeutral = `event: ping\ndata: {"type":"ping","pad":"${"x".repeat(4000)}"}\n\n`; + const reader = readerFromChunks([bigNeutral, bigNeutral, bigNeutral]); + const result = await runStreamContentGate(reader, { + ...GATE_OPTIONS, + prebufferByteCap: 8000, + }); + expect(result.committed).toBe(false); + if (result.committed) return; + expect((result.error as StreamPrecommitError).gateReason).toBe("prebuffer_overflow"); + }); + + it("propagates read rejection unchanged (timeout/client abort classification stays upstream)", async () => { + const abortError = new Error("This operation was aborted"); + abortError.name = "AbortError"; + const reader = readerFromChunks([PING], { failAfter: 1, failWith: abortError }); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(false); + if (result.committed) return; + expect(result.error).toBe(abortError); + expect(result.error).not.toBeInstanceOf(StreamPrecommitError); + }); + + it("is invariant to arbitrary chunk splits", async () => { + const body = PING + MESSAGE_START + TEXT_DELTA; + const bytes = encoder.encode(body); + for (const splitAt of [1, 7, 20, 55, bytes.length - 1]) { + const reader = readerFromChunks([bytes.slice(0, splitAt), bytes.slice(splitAt)]); + const result = await runStreamContentGate(reader, GATE_OPTIONS); + expect(result.committed).toBe(true); + if (!result.committed) continue; + expect(await drainPrefix(result.prefixChunks)).toBe(body); + } + }); + + it("openai-chat: [DONE]-only stream is empty, in-stream error fails over", async () => { + const doneOnly = readerFromChunks(["data: [DONE]\n\n"]); + const doneResult = await runStreamContentGate(doneOnly, { + ...GATE_OPTIONS, + family: "openai-chat", + }); + expect(doneResult.committed).toBe(false); + if (!doneResult.committed) { + expect((doneResult.error as StreamPrecommitError).gateReason).toBe("empty_stream"); + } + + const errorStream = readerFromChunks([ + 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n', + 'data: {"error":{"message":"rate limited","code":429}}\n\n', + ]); + const errorResult = await runStreamContentGate(errorStream, { + ...GATE_OPTIONS, + family: "openai-chat", + }); + expect(errorResult.committed).toBe(false); + if (!errorResult.committed) { + expect((errorResult.error as StreamPrecommitError).gateReason).toBe("gate_error"); + } + }); + + it("gemini: usage-only chunks buffer until content commits", async () => { + const reader = readerFromChunks([ + 'data: {"usageMetadata":{"totalTokenCount":1}}\n\n', + 'data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}\n\n', + ]); + const result = await runStreamContentGate(reader, { ...GATE_OPTIONS, family: "gemini" }); + expect(result.committed).toBe(true); + }); +}); + +describe("concatChunks", () => { + it("returns null for empty, identity for single, concatenation for many", () => { + expect(concatChunks([])).toBeNull(); + const single = encoder.encode("abc"); + expect(concatChunks([single])).toBe(single); + const merged = concatChunks([encoder.encode("ab"), encoder.encode("cd")]); + expect(new TextDecoder().decode(merged as Uint8Array)).toBe("abcd"); + }); +}); + +describe("StreamPrecommitError classification", () => { + it("is a ProxyError with 502 so categorizeErrorAsync yields PROVIDER_ERROR semantics", () => { + const error = new StreamPrecommitError("gate_error", { + family: "anthropic", + providerId: 1, + providerName: "p", + }); + expect(error).toBeInstanceOf(ProxyError); + expect(error.statusCode).toBe(502); + expect(error).not.toBeInstanceOf(EmptyResponseError); + }); +}); diff --git a/tests/unit/proxy/stream-gate-forwarder-integration.test.ts b/tests/unit/proxy/stream-gate-forwarder-integration.test.ts new file mode 100644 index 000000000..ea4c9d510 --- /dev/null +++ b/tests/unit/proxy/stream-gate-forwarder-integration.test.ts @@ -0,0 +1,475 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; + +/** + * F1 流式内容门控(stream content gate)在 ProxyForwarder 顺序路径中的接线集成测试。 + * + * 顺序路径进入条件:provider.firstByteTimeoutStreamingMs = 0(关闭 first-byte hedge), + * shouldUseStreamingHedge() 返回 false,ProxyForwarder.send() 走顺序重试循环, + * 在 isSSE 分支对 response.body 执行真实的 runStreamContentGate(本文件不 mock 门控本体)。 + * + * STREAM_GATE_MODE 由 getEnvConfig() 读取(模块级缓存 _envConfig,首次调用即固化), + * 因此这里 mock "@/lib/config/env.schema",通过 vi.hoisted 的 envControl 注入模式值: + * - 绕开缓存后,同一测试文件内即可分别驱动 enforce 与 off 两种模式; + * - 其余 env 字段取 EnvSchema.parse({}) 的默认值,不依赖本机/CI 的 process.env。 + * + * mock 前置结构复刻自 tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts, + * 避免触碰真实 DB/Redis/熔断器。 + */ + +const envControl = vi.hoisted(() => ({ + streamGateMode: "enforce" as "off" | "shadow" | "enforce", +})); + +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 () => {}), + checkAndTrackProviderSession: vi.fn(async () => ({ + allowed: true, + count: 1, + tracked: true, + referenced: true, + })), + releaseProviderSession: vi.fn(async (_providerId: number, _sessionId: string) => {}), + categorizeErrorAsync: vi.fn(async () => 0), + getErrorDetectionResultAsync: vi.fn(async () => ({ matched: false })), + getCachedSystemSettings: vi.fn(async () => ({ + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + })), + storeSessionSpecialSettings: vi.fn(async () => {}), + storeSessionRequestPhaseSnapshot: vi.fn(async () => {}), + storeSessionResponsePhaseSnapshot: 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/env.schema", async (importOriginal) => { + const actual = await importOriginal(); + // 全字段均有 default/optional,parse({}) 恒成功;STREAM_GATE_MODE 由 envControl 动态注入 + const baseEnv = actual.EnvSchema.parse({}); + return { + ...actual, + getEnvConfig: () => ({ ...baseEnv, STREAM_GATE_MODE: envControl.streamGateMode }), + }; +}); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedSystemSettings: mocks.getCachedSystemSettings, + 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/rate-limit/service", () => ({ + RateLimitService: { + checkAndTrackProviderSession: mocks.checkAndTrackProviderSession, + releaseProviderSession: mocks.releaseProviderSession, + }, +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + updateSessionBindingSmart: mocks.updateSessionBindingSmart, + updateSessionProvider: mocks.updateSessionProvider, + clearSessionProvider: mocks.clearSessionProvider, + storeSessionSpecialSettings: mocks.storeSessionSpecialSettings, + storeSessionRequestPhaseSnapshot: mocks.storeSessionRequestPhaseSnapshot, + storeSessionResponsePhaseSnapshot: mocks.storeSessionResponsePhaseSnapshot, + }, +})); + +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, + getErrorDetectionResultAsync: mocks.getErrorDetectionResultAsync, + }; +}); + +import { ErrorCategory as ProxyErrorCategory } from "@/app/v1/_lib/proxy/errors"; +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; + releaseAgent?: () => void; +}; + +function sseFrame(eventName: string | null, data: Record): string { + const dataLine = `data: ${JSON.stringify(data)}\n\n`; + return eventName ? `event: ${eventName}\n${dataLine}` : dataLine; +} + +// 仅使用 anthropic 家族的真实帧格式(providerType "claude" -> family "anthropic") +const PING_FRAME = sseFrame("ping", { type: "ping" }); +const ERROR_FRAME = sseFrame(null, { + type: "error", + error: { type: "overloaded_error", message: "x" }, +}); +const MESSAGE_START_FRAME = sseFrame("message_start", { + type: "message_start", + message: { + id: "msg_01", + type: "message", + role: "assistant", + model: "claude-test", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 3, output_tokens: 1 }, + }, +}); +const CONTENT_DELTA_FRAME = sseFrame("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, +}); +const MESSAGE_STOP_FRAME = sseFrame("message_stop", { type: "message_stop" }); + +// failover 后获胜供应商的正常内容流 +const WINNER_FRAMES = [MESSAGE_START_FRAME, CONTENT_DELTA_FRAME, MESSAGE_STOP_FRAME]; + +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, + // 0 = 关闭 first-byte hedge,强制 ProxyForwarder.send() 走顺序路径 + firstByteTimeoutStreamingMs: 0, + streamingIdleTimeoutMs: 0, + requestTimeoutNonStreamingMs: 0, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + codexImageGenerationPreference: 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-stream-gate", + 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 createSseResponse(frames: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // 每帧一个 chunk:门控 commit 时前缀 chunk 与帧一一对应 + for (const frame of frames) { + controller.enqueue(encoder.encode(frame)); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function spyOnDoForward() { + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + // 兜底:脚本之外的额外调用直接失败,避免落回真实 doForward 触发网络请求 + doForward.mockImplementation(async () => { + throw new Error("unexpected doForward call beyond scripted attempts"); + }); + return doForward; +} + +function attachAttemptRuntime( + attemptSession: unknown, + cleanup: { clearResponseTimeout: () => void; releaseAgent: () => void } +): void { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = new AbortController(); + runtime.clearResponseTimeout = cleanup.clearResponseTimeout; + runtime.releaseAgent = cleanup.releaseAgent; +} + +describe("F1 stream content gate x ProxyForwarder sequential path", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.categorizeErrorAsync.mockResolvedValue(ProxyErrorCategory.PROVIDER_ERROR); + }); + + describe("STREAM_GATE_MODE=enforce", () => { + beforeEach(() => { + envControl.streamGateMode = "enforce"; + }); + + test("上游 error 帧先于内容:precommit 失败触发供应商切换,失败供应商零字节泄漏", async () => { + const provider1 = createProvider({ id: 1, name: "gate-p1" }); + const provider2 = createProvider({ id: 2, name: "gate-p2" }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const clearResponseTimeout1 = vi.fn(); + const releaseAgent1 = vi.fn(); + const doForward = spyOnDoForward(); + + doForward.mockImplementationOnce(async (attemptSession) => { + attachAttemptRuntime(attemptSession, { + clearResponseTimeout: clearResponseTimeout1, + releaseAgent: releaseAgent1, + }); + return createSseResponse([PING_FRAME, ERROR_FRAME]); + }); + doForward.mockImplementationOnce(async () => createSseResponse(WINNER_FRAMES)); + + const response = await ProxyForwarder.send(session); + const text = await response.text(); + + expect(doForward).toHaveBeenCalledTimes(2); + expect((doForward.mock.calls[1] as unknown[])[1]).toMatchObject({ id: provider2.id }); + + // 客户端只能读到第二个供应商的帧:失败供应商已缓冲的 ping 前缀整段丢弃 + expect(text).toBe(WINNER_FRAMES.join("")); + expect(text).not.toContain("ping"); + expect(text).not.toContain("overloaded_error"); + + // precommit 失败按 PROVIDER_ERROR 结算:计入熔断器并清理计时器 / agent 引用 + expect(mocks.recordFailure).toHaveBeenCalledWith(provider1.id, expect.any(Error)); + expect(clearResponseTimeout1).toHaveBeenCalledTimes(1); + expect(releaseAgent1).toHaveBeenCalledTimes(1); + expect(session.provider?.id).toBe(provider2.id); + + // 决策链保留 502 gate_error 审计,upstreamBody 携带上游错误帧原文 + const gateFailureEntry = session + .getProviderChain() + .find((item) => item.id === provider1.id && item.reason === "retry_failed"); + expect(gateFailureEntry?.statusCode).toBe(502); + expect(gateFailureEntry?.errorDetails?.provider?.upstreamBody).toContain("overloaded_error"); + }); + + test("中性前缀(ping/message_start)在首个内容帧提交时完整冲刷,无丢失无重复", async () => { + const provider1 = createProvider({ id: 1, name: "gate-p1" }); + const session = createSession(); + session.setProvider(provider1); + + const frames = [PING_FRAME, MESSAGE_START_FRAME, CONTENT_DELTA_FRAME, MESSAGE_STOP_FRAME]; + const doForward = spyOnDoForward(); + doForward.mockImplementationOnce(async () => createSseResponse(frames)); + + const response = await ProxyForwarder.send(session); + const text = await response.text(); + + expect(doForward).toHaveBeenCalledTimes(1); + // 缓冲前缀(ping + message_start + 触发提交的 content_block_delta)与 + // 提交后仍留在 reader 上的 message_stop 拼接后与原始四帧一字节不差 + expect(text).toBe(frames.join("")); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(mocks.pickRandomProviderWithExclusion).not.toHaveBeenCalled(); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + }); + + test("terminal-only 流(message_stop 即终止)按 empty_stream 失败并切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "gate-p1" }); + const provider2 = createProvider({ id: 2, name: "gate-p2" }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = spyOnDoForward(); + doForward.mockImplementationOnce(async () => createSseResponse([MESSAGE_STOP_FRAME])); + doForward.mockImplementationOnce(async () => createSseResponse(WINNER_FRAMES)); + + const response = await ProxyForwarder.send(session); + const text = await response.text(); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(text).toBe(WINNER_FRAMES.join("")); + expect(text).toContain('"text":"Hello"'); + expect(mocks.recordFailure).toHaveBeenCalledWith(provider1.id, expect.any(Error)); + expect(session.provider?.id).toBe(provider2.id); + + const emptyStreamEntry = session + .getProviderChain() + .find((item) => item.id === provider1.id && item.reason === "retry_failed"); + expect(emptyStreamEntry?.statusCode).toBe(502); + expect(emptyStreamEntry?.errorMessage).toContain("empty_stream"); + }); + }); + + describe("STREAM_GATE_MODE=off", () => { + beforeEach(() => { + envControl.streamGateMode = "off"; + }); + + test("默认 off:含 error 帧的 200 SSE 原样透传,不触发 failover", async () => { + const provider1 = createProvider({ id: 1, name: "gate-p1" }); + const session = createSession(); + session.setProvider(provider1); + + const frames = [PING_FRAME, ERROR_FRAME]; + const doForward = spyOnDoForward(); + doForward.mockImplementationOnce(async () => createSseResponse(frames)); + + const response = await ProxyForwarder.send(session); + const text = await response.text(); + + // 与现状一致:门控关闭时错误帧照常透传给客户端,由既有事后检测兜底 + expect(doForward).toHaveBeenCalledTimes(1); + expect(text).toBe(frames.join("")); + expect(text).toContain("overloaded_error"); + expect(mocks.pickRandomProviderWithExclusion).not.toHaveBeenCalled(); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/proxy/stream-gate-frame-classifier.test.ts b/tests/unit/proxy/stream-gate-frame-classifier.test.ts new file mode 100644 index 000000000..6fcd89b9c --- /dev/null +++ b/tests/unit/proxy/stream-gate-frame-classifier.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from "vitest"; +import { + classifyFrame, + mapProviderTypeToFamily, +} from "@/app/v1/_lib/proxy/stream-gate/frame-classifier"; + +describe("mapProviderTypeToFamily", () => { + it("maps provider types to protocol families", () => { + expect(mapProviderTypeToFamily("claude")).toBe("anthropic"); + expect(mapProviderTypeToFamily("claude-auth")).toBe("anthropic"); + expect(mapProviderTypeToFamily("codex")).toBe("openai-responses"); + expect(mapProviderTypeToFamily("openai-compatible")).toBe("openai-chat"); + expect(mapProviderTypeToFamily("gemini")).toBe("gemini"); + expect(mapProviderTypeToFamily("gemini-cli")).toBe("gemini"); + expect(mapProviderTypeToFamily("unknown-type")).toBeNull(); + expect(mapProviderTypeToFamily(null)).toBeNull(); + }); +}); + +describe("classifyFrame: anthropic", () => { + it("content: text delta", () => { + expect( + classifyFrame( + "anthropic", + "content_block_delta", + '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}' + ) + ).toBe("content"); + }); + + it("content: embedded type without SSE event name", () => { + expect( + classifyFrame("anthropic", null, '{"type":"content_block_delta","delta":{"text":"hi"}}') + ).toBe("content"); + }); + + it("content: thinking / partial_json / signature deltas", () => { + expect( + classifyFrame("anthropic", null, '{"type":"content_block_delta","delta":{"thinking":"..."}}') + ).toBe("content"); + expect( + classifyFrame( + "anthropic", + null, + '{"type":"content_block_delta","delta":{"partial_json":"{\\"a\\""}}' + ) + ).toBe("content"); + expect( + classifyFrame("anthropic", null, '{"type":"content_block_delta","delta":{"signature":"sig"}}') + ).toBe("content"); + }); + + it("neutral: empty text delta", () => { + expect( + classifyFrame("anthropic", null, '{"type":"content_block_delta","delta":{"text":""}}') + ).toBe("neutral"); + }); + + it("content: content_block_start carrying entity payload (tool_use)", () => { + expect( + classifyFrame( + "anthropic", + "content_block_start", + '{"type":"content_block_start","content_block":{"type":"tool_use","id":"t1","name":"f"}}' + ) + ).toBe("content"); + }); + + it("neutral: content_block_start for empty text block", () => { + expect( + classifyFrame( + "anthropic", + "content_block_start", + '{"type":"content_block_start","content_block":{"type":"text","text":""}}' + ) + ).toBe("neutral"); + }); + + it("neutral: message_start / ping / message_delta bookkeeping", () => { + expect( + classifyFrame("anthropic", "message_start", '{"type":"message_start","message":{"id":"m"}}') + ).toBe("neutral"); + expect(classifyFrame("anthropic", "ping", '{"type":"ping"}')).toBe("neutral"); + expect( + classifyFrame( + "anthropic", + "message_delta", + '{"type":"message_delta","usage":{"output_tokens":5}}' + ) + ).toBe("neutral"); + }); + + it("error: error event and fake-200 error envelope", () => { + expect( + classifyFrame( + "anthropic", + "error", + '{"type":"error","error":{"type":"overloaded_error","message":"x"}}' + ) + ).toBe("error"); + expect(classifyFrame("anthropic", null, '{"error":{"message":"boom"}}')).toBe("error"); + }); + + it("error takes precedence over content in the same frame", () => { + expect( + classifyFrame( + "anthropic", + "content_block_delta", + '{"type":"content_block_delta","delta":{"text":"hi"},"error":{"message":"x"}}' + ) + ).toBe("error"); + }); + + it("terminal: message_stop", () => { + expect(classifyFrame("anthropic", "message_stop", '{"type":"message_stop"}')).toBe("terminal"); + }); + + it("malformed: broken or non-object JSON", () => { + expect(classifyFrame("anthropic", null, '{"type":')).toBe("malformed"); + expect(classifyFrame("anthropic", null, "plain text")).toBe("malformed"); + expect(classifyFrame("anthropic", null, '"just a string"')).toBe("malformed"); + }); + + it("neutral: unknown future event", () => { + expect(classifyFrame("anthropic", "future_event", '{"type":"future_event"}')).toBe("neutral"); + }); +}); + +describe("classifyFrame: openai-chat", () => { + it("content: delta content / tool_calls / refusal / audio", () => { + expect(classifyFrame("openai-chat", null, '{"choices":[{"delta":{"content":"hi"}}]}')).toBe( + "content" + ); + expect( + classifyFrame( + "openai-chat", + null, + '{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"f"}}]}}]}' + ) + ).toBe("content"); + expect(classifyFrame("openai-chat", null, '{"choices":[{"delta":{"refusal":"no"}}]}')).toBe( + "content" + ); + expect( + classifyFrame("openai-chat", null, '{"choices":[{"delta":{"audio":{"data":"b64"}}}]}') + ).toBe("content"); + }); + + it("neutral: role-only first chunk / finish_reason-only / usage-only", () => { + expect(classifyFrame("openai-chat", null, '{"choices":[{"delta":{"role":"assistant"}}]}')).toBe( + "neutral" + ); + expect( + classifyFrame("openai-chat", null, '{"choices":[{"delta":{},"finish_reason":"stop"}]}') + ).toBe("neutral"); + expect(classifyFrame("openai-chat", null, '{"choices":[],"usage":{"total_tokens":10}}')).toBe( + "neutral" + ); + }); + + it("neutral: empty string content delta", () => { + expect(classifyFrame("openai-chat", null, '{"choices":[{"delta":{"content":""}}]}')).toBe( + "neutral" + ); + }); + + it("error: in-stream error payload", () => { + expect(classifyFrame("openai-chat", null, '{"error":{"message":"rate limited"}}')).toBe( + "error" + ); + }); + + it("terminal: [DONE] sentinel", () => { + expect(classifyFrame("openai-chat", null, "[DONE]")).toBe("terminal"); + expect(classifyFrame("openai-chat", null, " [DONE] ")).toBe("terminal"); + }); + + it("malformed: non-JSON that is not the sentinel", () => { + expect(classifyFrame("openai-chat", null, "DONE")).toBe("malformed"); + }); +}); + +describe("classifyFrame: openai-responses", () => { + it("content: output_text delta via SSE event name", () => { + expect( + classifyFrame( + "openai-responses", + "response.output_text.delta", + '{"type":"response.output_text.delta","delta":"hi"}' + ) + ).toBe("content"); + }); + + it("content: embedded type without event name", () => { + expect( + classifyFrame("openai-responses", null, '{"type":"response.output_text.delta","delta":"hi"}') + ).toBe("content"); + }); + + it("content: reasoning delta / function_call arguments done / partial image", () => { + expect( + classifyFrame( + "openai-responses", + "response.reasoning_text.delta", + '{"type":"response.reasoning_text.delta","delta":"think"}' + ) + ).toBe("content"); + expect( + classifyFrame( + "openai-responses", + "response.function_call_arguments.done", + '{"type":"response.function_call_arguments.done","arguments":"{}"}' + ) + ).toBe("content"); + expect( + classifyFrame( + "openai-responses", + "response.image_generation_call.partial_image", + '{"type":"response.image_generation_call.partial_image","partial_image_b64":"abc"}' + ) + ).toBe("content"); + }); + + it("content: output_item.added carrying tool name", () => { + expect( + classifyFrame( + "openai-responses", + "response.output_item.added", + '{"type":"response.output_item.added","item":{"type":"function_call","name":"get_x"}}' + ) + ).toBe("content"); + }); + + it("neutral: output_item.added without item.name (message item)", () => { + expect( + classifyFrame( + "openai-responses", + "response.output_item.added", + '{"type":"response.output_item.added","item":{"type":"message"}}' + ) + ).toBe("neutral"); + }); + + it("neutral: response.created with error:null does not hit error rule", () => { + expect( + classifyFrame( + "openai-responses", + "response.created", + '{"type":"response.created","response":{"id":"r","error":null}}' + ) + ).toBe("neutral"); + }); + + it("neutral: sub-tool failures are recoverable", () => { + expect( + classifyFrame( + "openai-responses", + "response.mcp_call.failed", + '{"type":"response.mcp_call.failed"}' + ) + ).toBe("neutral"); + }); + + it("error: top-level error event / response.failed / populated response.error", () => { + expect( + classifyFrame("openai-responses", "error", '{"type":"error","code":"x","message":"m"}') + ).toBe("error"); + expect( + classifyFrame( + "openai-responses", + "response.failed", + '{"type":"response.failed","response":{"error":{"message":"m"}}}' + ) + ).toBe("error"); + expect( + classifyFrame( + "openai-responses", + "response.in_progress", + '{"type":"response.in_progress","response":{"error":{"message":"m"}}}' + ) + ).toBe("error"); + }); + + it("terminal: response.completed / response.incomplete", () => { + expect( + classifyFrame( + "openai-responses", + "response.completed", + '{"type":"response.completed","response":{"output":[],"error":null}}' + ) + ).toBe("terminal"); + expect( + classifyFrame( + "openai-responses", + "response.incomplete", + '{"type":"response.incomplete","response":{"error":null}}' + ) + ).toBe("terminal"); + }); +}); + +describe("classifyFrame: gemini", () => { + it("content: text / functionCall / inlineData parts", () => { + expect( + classifyFrame( + "gemini", + null, + '{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"}}]}' + ) + ).toBe("content"); + expect( + classifyFrame( + "gemini", + null, + '{"candidates":[{"content":{"parts":[{"functionCall":{"name":"f","args":{}}}]}}]}' + ) + ).toBe("content"); + expect( + classifyFrame( + "gemini", + null, + '{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"b64"}}]}}]}' + ) + ).toBe("content"); + }); + + it("neutral: usageMetadata-only chunk", () => { + expect( + classifyFrame("gemini", null, '{"usageMetadata":{"totalTokenCount":10},"modelVersion":"g"}') + ).toBe("neutral"); + }); + + it("neutral: empty parts / empty text", () => { + expect(classifyFrame("gemini", null, '{"candidates":[{"content":{"parts":[]}}]}')).toBe( + "neutral" + ); + expect( + classifyFrame("gemini", null, '{"candidates":[{"content":{"parts":[{"text":""}]}}]}') + ).toBe("neutral"); + }); + + it("error: error chunk / promptFeedback.blockReason / abnormal finishReason", () => { + expect( + classifyFrame("gemini", null, '{"error":{"code":500,"message":"x","status":"INTERNAL"}}') + ).toBe("error"); + expect(classifyFrame("gemini", null, '{"promptFeedback":{"blockReason":"SAFETY"}}')).toBe( + "error" + ); + expect(classifyFrame("gemini", null, '{"candidates":[{"finishReason":"SAFETY"}]}')).toBe( + "error" + ); + expect( + classifyFrame("gemini", null, '{"candidates":[{"finishReason":"MALFORMED_FUNCTION_CALL"}]}') + ).toBe("error"); + }); + + it("error precedence: SAFETY finishReason beats content in same chunk", () => { + expect( + classifyFrame( + "gemini", + null, + '{"candidates":[{"content":{"parts":[{"text":"partial"}]},"finishReason":"SAFETY"}]}' + ) + ).toBe("error"); + }); + + it("content precedence: normal STOP with text is content, not terminal", () => { + expect( + classifyFrame( + "gemini", + null, + '{"candidates":[{"content":{"parts":[{"text":"done"}]},"finishReason":"STOP"}]}' + ) + ).toBe("content"); + }); + + it("terminal: STOP / MAX_TOKENS finishReason without content", () => { + expect(classifyFrame("gemini", null, '{"candidates":[{"finishReason":"STOP"}]}')).toBe( + "terminal" + ); + expect(classifyFrame("gemini", null, '{"candidates":[{"finishReason":"MAX_TOKENS"}]}')).toBe( + "terminal" + ); + }); +}); + +describe("classifyFrame: shared edge cases", () => { + it("neutral: empty / whitespace-only data", () => { + expect(classifyFrame("anthropic", null, "")).toBe("neutral"); + expect(classifyFrame("openai-chat", null, " ")).toBe("neutral"); + }); + + it("malformed: truncated JSON in every family", () => { + for (const family of ["anthropic", "openai-chat", "openai-responses", "gemini"] as const) { + expect(classifyFrame(family, null, '{"cut')).toBe("malformed"); + } + }); + + it("neutral: JSON array payloads with no rule hits", () => { + expect(classifyFrame("openai-chat", null, "[]")).toBe("neutral"); + }); +}); diff --git a/tests/unit/proxy/stream-gate-mode-resolution.test.ts b/tests/unit/proxy/stream-gate-mode-resolution.test.ts new file mode 100644 index 000000000..0577fbb40 --- /dev/null +++ b/tests/unit/proxy/stream-gate-mode-resolution.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { SystemSettings } from "@/types/system-config"; + +// F1 门控模式解析链:系统设置快照(proxy-runtime)优先,env STREAM_GATE_MODE 兜底。 +// 通过真实 proxy-runtime 模块驱动快照生命周期,验证 resolveStreamGateMode 的三级回退。 + +const getCachedSystemSettingsMock = vi.fn(); +const getEnvConfigMock = vi.fn(); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: () => getCachedSystemSettingsMock(), +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => getEnvConfigMock(), +})); + +// 真实 logger 模块加载时读取完整 env;此处仅需静默日志 +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +function createSettings(overrides: Partial = {}): Partial { + return { + streamGateMode: "enforce", + affinityIgnoreClientSessionId: true, + ...overrides, + }; +} + +async function loadModules() { + const runtime = await import("@/lib/system-settings/proxy-runtime"); + const gate = await import("@/app/v1/_lib/proxy/stream-gate/stream-content-gate"); + return { ...runtime, ...gate }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getEnvConfigMock.mockReturnValue({ STREAM_GATE_MODE: "off" }); +}); + +describe("getProxyRuntimeSettings / getCachedProxyRuntimeSettings", () => { + test("无快照时 getCachedProxyRuntimeSettings 返回 null", async () => { + const { getCachedProxyRuntimeSettings } = await loadModules(); + expect(getCachedProxyRuntimeSettings()).toBeNull(); + }); + + test("getProxyRuntimeSettings 从系统设置缓存映射两字段并更新快照", async () => { + getCachedSystemSettingsMock.mockResolvedValue( + createSettings({ streamGateMode: "shadow", affinityIgnoreClientSessionId: false }) + ); + const { getProxyRuntimeSettings, getCachedProxyRuntimeSettings } = await loadModules(); + + const settings = await getProxyRuntimeSettings(); + expect(settings).toEqual({ streamGateMode: "shadow", affinityIgnoreClientSessionId: false }); + expect(getCachedProxyRuntimeSettings()).toEqual({ + streamGateMode: "shadow", + affinityIgnoreClientSessionId: false, + }); + }); + + test("系统设置读取异常且无快照时回退 env(affinity 默认开)", async () => { + getCachedSystemSettingsMock.mockRejectedValue(new Error("db down")); + getEnvConfigMock.mockReturnValue({ STREAM_GATE_MODE: "shadow" }); + const { getProxyRuntimeSettings } = await loadModules(); + + const settings = await getProxyRuntimeSettings(); + expect(settings).toEqual({ streamGateMode: "shadow", affinityIgnoreClientSessionId: true }); + }); + + test("系统设置读取异常但已有快照时返回旧快照", async () => { + getCachedSystemSettingsMock.mockResolvedValueOnce(createSettings({ streamGateMode: "off" })); + const { getProxyRuntimeSettings } = await loadModules(); + await getProxyRuntimeSettings(); + + getCachedSystemSettingsMock.mockRejectedValueOnce(new Error("db down")); + const settings = await getProxyRuntimeSettings(); + expect(settings).toEqual({ streamGateMode: "off", affinityIgnoreClientSessionId: true }); + }); +}); + +describe("resolveStreamGateMode", () => { + test("快照优先于 env", async () => { + getCachedSystemSettingsMock.mockResolvedValue(createSettings({ streamGateMode: "shadow" })); + getEnvConfigMock.mockReturnValue({ STREAM_GATE_MODE: "enforce" }); + const { getProxyRuntimeSettings, resolveStreamGateMode } = await loadModules(); + + await getProxyRuntimeSettings(); + expect(resolveStreamGateMode()).toBe("shadow"); + }); + + test("无快照时回退 env STREAM_GATE_MODE", async () => { + getEnvConfigMock.mockReturnValue({ STREAM_GATE_MODE: "enforce" }); + const { resolveStreamGateMode } = await loadModules(); + + expect(resolveStreamGateMode()).toBe("enforce"); + }); + + test("无快照且 env 不可用时 fail-safe 返回 off", async () => { + getEnvConfigMock.mockImplementation(() => { + throw new Error("env not ready"); + }); + const { resolveStreamGateMode } = await loadModules(); + + expect(resolveStreamGateMode()).toBe("off"); + }); +}); diff --git a/tests/unit/proxy/stream-gate-sse-frames.test.ts b/tests/unit/proxy/stream-gate-sse-frames.test.ts new file mode 100644 index 000000000..5db9d824d --- /dev/null +++ b/tests/unit/proxy/stream-gate-sse-frames.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { parseSseBody, SseFrameParser } from "@/app/v1/_lib/proxy/stream-gate/sse-frames"; + +function collectAll(parser: SseFrameParser, chunks: Uint8Array[]) { + const frames = chunks.flatMap((chunk) => parser.push(chunk)); + return [...frames, ...parser.finish()]; +} + +describe("SseFrameParser", () => { + it("parses a simple event stream", () => { + const frames = parseSseBody('event: message_start\ndata: {"a":1}\n\ndata: [DONE]\n\n'); + expect(frames).toEqual([ + { eventName: "message_start", data: '{"a":1}' }, + { eventName: null, data: "[DONE]" }, + ]); + }); + + it("joins multi-line data with newline", () => { + const frames = parseSseBody("data: line1\ndata: line2\n\n"); + expect(frames).toEqual([{ eventName: null, data: "line1\nline2" }]); + }); + + it("handles CRLF line endings", () => { + const frames = parseSseBody("event: ping\r\ndata: {}\r\n\r\n"); + expect(frames).toEqual([{ eventName: "ping", data: "{}" }]); + }); + + it("skips comment lines and id/retry fields", () => { + const frames = parseSseBody(": keep-alive\nid: 42\nretry: 500\ndata: x\n\n"); + expect(frames).toEqual([{ eventName: null, data: "x" }]); + }); + + it("event without data emits no frame and resets event name", () => { + const frames = parseSseBody("event: orphan\n\ndata: y\n\n"); + expect(frames).toEqual([{ eventName: null, data: "y" }]); + }); + + it("emits trailing frame without terminating blank line at EOF", () => { + const frames = parseSseBody('event: e\ndata: {"z":1}'); + expect(frames).toEqual([{ eventName: "e", data: '{"z":1}' }]); + }); + + it("strips exactly one leading space after data:", () => { + const frames = parseSseBody("data: two-spaces\n\ndata:none\n\n"); + expect(frames).toEqual([ + { eventName: null, data: " two-spaces" }, + { eventName: null, data: "none" }, + ]); + }); + + it("handles CRLF split across chunk boundary", () => { + const encoder = new TextEncoder(); + const parser = new SseFrameParser(); + const frames = collectAll(parser, [ + encoder.encode("data: a\r"), + encoder.encode("\ndata: b\r\n\r\n"), + ]); + expect(frames).toEqual([{ eventName: null, data: "a\nb" }]); + }); + + it("handles UTF-8 codepoint split across chunk boundary", () => { + const bytes = new TextEncoder().encode("data: 中文内容\n\n"); + // 在多字节码点中间切开 + const splitAt = 8; + const parser = new SseFrameParser(); + const frames = collectAll(parser, [bytes.slice(0, splitAt), bytes.slice(splitAt)]); + expect(frames).toEqual([{ eventName: null, data: "中文内容" }]); + }); + + it("byte-split invariance: any single split point yields identical frames", () => { + const body = + 'event: content_block_delta\r\ndata: {"type":"content_block_delta","delta":{"text":"你好"}}\r\n\r\n' + + ": comment\n" + + "data: part1\ndata: part2\n\n" + + "event: message_stop\ndata: {}\n\n" + + "data: [DONE]\n\n"; + const bytes = new TextEncoder().encode(body); + const expected = parseSseBody(body); + expect(expected.length).toBe(4); + for (let i = 1; i < bytes.length; i++) { + const parser = new SseFrameParser(); + const frames = collectAll(parser, [bytes.slice(0, i), bytes.slice(i)]); + expect(frames).toEqual(expected); + } + }); + + it("byte-split invariance: byte-by-byte feeding yields identical frames", () => { + const body = 'event: e1\ndata: {"a":"中"}\n\ndata: tail'; + const bytes = new TextEncoder().encode(body); + const expected = parseSseBody(body); + const parser = new SseFrameParser(); + const chunks: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i++) { + chunks.push(bytes.slice(i, i + 1)); + } + expect(collectAll(parser, chunks)).toEqual(expected); + }); +}); diff --git a/tests/unit/redis/leaderboard-cache.test.ts b/tests/unit/redis/leaderboard-cache.test.ts index 0ff0a460c..f3ce0f6c4 100644 --- a/tests/unit/redis/leaderboard-cache.test.ts +++ b/tests/unit/redis/leaderboard-cache.test.ts @@ -102,7 +102,7 @@ describe("getLeaderboardWithCache", () => { true ); expect(redis.setex).toHaveBeenCalledWith( - "leaderboard:userCacheHitRate:daily:2026-04-13:tz:UTC:USD:includeModelStats:tags:team-a,vip:groups:group-1", + "leaderboard:v2:userCacheHitRate:daily:2026-04-13:tz:UTC:USD:includeModelStats:tags:team-a,vip:groups:group-1", 60, JSON.stringify(rows) ); @@ -128,7 +128,7 @@ describe("getLeaderboardWithCache", () => { await getLeaderboardWithCache("daily", "USD", "userCacheHitRate"); expect(redis.setex).toHaveBeenCalledWith( - "leaderboard:userCacheHitRate:daily:2026-04-14:tz:Asia/Shanghai:USD", + "leaderboard:v2:userCacheHitRate:daily:2026-04-14:tz:Asia/Shanghai:USD", 60, JSON.stringify(rows) ); diff --git a/tests/unit/repository/leaderboard-cache-coefficient.test.ts b/tests/unit/repository/leaderboard-cache-coefficient.test.ts new file mode 100644 index 000000000..7fe13de34 --- /dev/null +++ b/tests/unit/repository/leaderboard-cache-coefficient.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getProviderCacheCoefficients, + resolveLeaderboardWindow, +} from "@/repository/provider-cache-effectiveness"; + +/** + * F3b 缓存系数数据源测试: + * - getProviderCacheCoefficients:按 provider 汇总窗口行后用定点公式重算 effectivenessBp + * - resolveLeaderboardWindow:把排行榜周期解析成 [start, end](语义对齐 buildDateCondition) + */ + +const dbMocks = vi.hoisted(() => { + const groupBy = vi.fn(); + const where = vi.fn(() => ({ groupBy })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + return { select, from, where, groupBy }; +}); + +vi.mock("@/drizzle/db", () => ({ + db: { select: dbMocks.select }, +})); + +function aggregateRow( + providerId: number, + sample: string, + eligible: string, + theoretical: string, + observed: string +) { + return { + providerId, + sampleCount: sample, + eligibleCount: eligible, + theoreticalCacheTokens: theoretical, + observedCacheReadTokens: observed, + }; +} + +describe("getProviderCacheCoefficients", () => { + beforeEach(() => { + dbMocks.groupBy.mockResolvedValue([]); + }); + + const window = { start: new Date("2026-07-22T00:00:00Z"), end: new Date("2026-07-22T01:00:00Z") }; + + it("recomputes the fixed-point formula on aggregated sums", async () => { + // rawBp = 86000*10000/100000 = 8600; factor(150>=100) = 10000 + // observableBp = 150*10000/200 = 7500; confidenceBp = 7500 + // effectivenessBp = 8600*7500/10000 = 6450 + dbMocks.groupBy.mockResolvedValue([aggregateRow(7, "200", "150", "100000", "86000")]); + + const result = await getProviderCacheCoefficients(window); + + expect(result.get(7)).toEqual({ providerId: 7, coefficientBp: 6450, sampleCount: 200 }); + }); + + it("clamps rawBp at 10000 when observed exceeds theoretical", async () => { + dbMocks.groupBy.mockResolvedValue([aggregateRow(1, "200", "150", "100000", "250000")]); + + const result = await getProviderCacheCoefficients(window); + + // raw clamp 10000 -> effectiveness = confidenceBp = 7500 + expect(result.get(1)?.coefficientBp).toBe(7500); + }); + + it("returns 0 coefficient when theoretical tokens are zero", async () => { + dbMocks.groupBy.mockResolvedValue([aggregateRow(1, "10", "10", "0", "0")]); + + const result = await getProviderCacheCoefficients(window); + + expect(result.get(1)?.coefficientBp).toBe(0); + }); + + it("applies the sample-size factor tiers on aggregated eligible counts", async () => { + dbMocks.groupBy.mockResolvedValue([ + // eligible 30 / sample 40: factor 6000, observable 7500, confidence 4500; raw 5000 -> 2250 + aggregateRow(1, "40", "30", "100", "50"), + // eligible 5 / sample 5: factor 3000, observable 10000, confidence 3000; raw 10000 -> 3000 + aggregateRow(2, "5", "5", "100", "100"), + // eligible 4 / sample 4: factor 1000, observable 10000, confidence 1000; raw 10000 -> 1000 + aggregateRow(3, "4", "4", "100", "100"), + ]); + + const result = await getProviderCacheCoefficients(window); + + expect(result.get(1)?.coefficientBp).toBe(2250); + expect(result.get(2)?.coefficientBp).toBe(3000); + expect(result.get(3)?.coefficientBp).toBe(1000); + }); + + it("returns an empty map when no windows fall inside the range", async () => { + const result = await getProviderCacheCoefficients(window); + + expect(result.size).toBe(0); + }); +}); + +describe("resolveLeaderboardWindow", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-22T10:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves custom ranges as [startDate 00:00, endDate + 1 day 00:00) in the given timezone", () => { + const utc = resolveLeaderboardWindow("custom", "UTC", { + startDate: "2026-01-01", + endDate: "2026-01-15", + }); + expect(utc.start.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(utc.end.toISOString()).toBe("2026-01-16T00:00:00.000Z"); + + const shanghai = resolveLeaderboardWindow("custom", "Asia/Shanghai", { + startDate: "2026-01-01", + endDate: "2026-01-15", + }); + expect(shanghai.start.toISOString()).toBe("2025-12-31T16:00:00.000Z"); + expect(shanghai.end.toISOString()).toBe("2026-01-15T16:00:00.000Z"); + }); + + it("resolves daily to the local calendar day of the system timezone", () => { + // 2026-07-22T10:00Z 在上海是 22 日 18:00,当地当日为 [21T16:00Z, 22T16:00Z) + const { start, end } = resolveLeaderboardWindow("daily", "Asia/Shanghai"); + expect(start.toISOString()).toBe("2026-07-21T16:00:00.000Z"); + expect(end.toISOString()).toBe("2026-07-22T16:00:00.000Z"); + }); + + it("resolves weekly to the ISO week (Monday start)", () => { + // 2026-07-22 是周三,ISO 周一为 2026-07-20 + const { start, end } = resolveLeaderboardWindow("weekly", "UTC"); + expect(start.toISOString()).toBe("2026-07-20T00:00:00.000Z"); + expect(end.toISOString()).toBe("2026-07-27T00:00:00.000Z"); + }); + + it("resolves monthly to the local calendar month", () => { + const { start, end } = resolveLeaderboardWindow("monthly", "UTC"); + expect(start.toISOString()).toBe("2026-07-01T00:00:00.000Z"); + expect(end.toISOString()).toBe("2026-08-01T00:00:00.000Z"); + }); + + it("resolves allTime from epoch to now and last24h as a rolling day", () => { + const allTime = resolveLeaderboardWindow("allTime", "UTC"); + expect(allTime.start.getTime()).toBe(0); + expect(allTime.end.toISOString()).toBe("2026-07-22T10:00:00.000Z"); + + const last24h = resolveLeaderboardWindow("last24h", "UTC"); + expect(last24h.start.toISOString()).toBe("2026-07-21T10:00:00.000Z"); + expect(last24h.end.toISOString()).toBe("2026-07-22T10:00:00.000Z"); + }); + + it("falls back to the allTime window when custom lacks a dateRange", () => { + const { start, end } = resolveLeaderboardWindow("custom", "UTC"); + expect(start.getTime()).toBe(0); + expect(end.toISOString()).toBe("2026-07-22T10:00:00.000Z"); + }); +}); diff --git a/tests/unit/repository/leaderboard-provider-metrics.test.ts b/tests/unit/repository/leaderboard-provider-metrics.test.ts index 2cc659d14..102dbf0f2 100644 --- a/tests/unit/repository/leaderboard-provider-metrics.test.ts +++ b/tests/unit/repository/leaderboard-provider-metrics.test.ts @@ -30,6 +30,7 @@ const mockSelect = vi.fn(() => { const mocks = vi.hoisted(() => ({ resolveSystemTimezone: vi.fn(), getSystemSettings: vi.fn(), + getProviderCacheCoefficients: vi.fn(), })); vi.mock("@/drizzle/db", () => ({ @@ -91,6 +92,16 @@ vi.mock("@/repository/system-config", () => ({ getSystemSettings: mocks.getSystemSettings, })); +vi.mock("@/repository/provider-cache-effectiveness", () => ({ + getProviderCacheCoefficients: mocks.getProviderCacheCoefficients, + resolveLeaderboardWindow: () => ({ start: new Date(0), end: new Date() }), +})); + +/** 构造 getProviderCacheCoefficients 返回的 Map */ +function coefficientMap(entries: Array<{ providerId: number; coefficientBp: number }>) { + return new Map(entries.map((e) => [e.providerId, { ...e, sampleCount: 100 }] as const)); +} + describe("Provider Leaderboard Average Cost Metrics", () => { beforeEach(() => { vi.resetModules(); @@ -99,6 +110,7 @@ describe("Provider Leaderboard Average Cost Metrics", () => { mockSelect.mockClear(); mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); }); it("computes avgCostPerRequest = totalCost / totalRequests for valid denominators", async () => { @@ -267,6 +279,7 @@ describe("Provider Leaderboard Model Breakdown", () => { mockSelect.mockClear(); mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); }); it("includes modelStats when includeModelStats=true and excludes empty model names", async () => { @@ -409,6 +422,7 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { mockSelect.mockClear(); mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); }); it("includes modelStats field on cache-hit leaderboard entries", async () => { @@ -456,7 +470,7 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { expect(entry.modelStats[0].model).toBe("claude-3-opus"); }); - it("provider cache hit ranking sort stability preserved after adding modelStats", async () => { + it("falls back to cacheHitRate descending when no provider has a cache coefficient", async () => { chainMocks = [ createChainMock([ { @@ -487,6 +501,8 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { const result = await findDailyProviderCacheHitRateLeaderboard(); expect(result).toHaveLength(2); + // 默认排序为 cacheCoefficientBp DESC NULLS LAST;全 null 时并列,按 cacheHitRate DESC + expect(result.map((r) => r.cacheCoefficientBp)).toEqual([null, null]); expect(result[0].cacheHitRate).toBeGreaterThanOrEqual(result[1].cacheHitRate); }); @@ -654,6 +670,113 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { }); }); +describe("Provider Leaderboard Cache Coefficient", () => { + beforeEach(() => { + vi.resetModules(); + selectCallIndex = 0; + chainMocks = []; + mockSelect.mockClear(); + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); + }); + + const usageRow = (providerId: number, providerName: string, totalCost: string) => ({ + providerId, + providerName, + totalRequests: 10, + totalCost, + totalTokens: 1000, + successRate: 0.9, + avgTtfbMs: 100, + avgTokensPerSecond: 10, + }); + + const cacheRow = (providerId: number, providerName: string, cacheHitRate: number) => ({ + providerId, + providerName, + totalRequests: 10, + totalCost: "1.0", + cacheReadTokens: 1000, + cacheCreationCost: "0.5", + totalInputTokens: 2000, + cacheHitRate, + }); + + it("merges coefficientBp into usage entries and keeps null for providers without data", async () => { + chainMocks = [ + createChainMock([usageRow(1, "with-data", "10.0"), usageRow(2, "no-data", "5.0")]), + ]; + mocks.getProviderCacheCoefficients.mockResolvedValue( + coefficientMap([{ providerId: 1, coefficientBp: 8600 }]) + ); + + const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyProviderLeaderboard(); + + expect(result.find((r) => r.providerId === 1)?.cacheCoefficientBp).toBe(8600); + expect(result.find((r) => r.providerId === 2)?.cacheCoefficientBp).toBeNull(); + }); + + it("usage leaderboard keeps cost descending order even when coefficients disagree", async () => { + chainMocks = [createChainMock([usageRow(1, "expensive", "10.0"), usageRow(2, "cheap", "2.0")])]; + mocks.getProviderCacheCoefficients.mockResolvedValue( + coefficientMap([ + { providerId: 1, coefficientBp: 100 }, + { providerId: 2, coefficientBp: 9900 }, + ]) + ); + + const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyProviderLeaderboard(); + + expect(result.map((r) => r.providerId)).toEqual([1, 2]); + expect(result.map((r) => r.cacheCoefficientBp)).toEqual([100, 9900]); + }); + + it("cache hit leaderboard sorts by coefficientBp descending with nulls last", async () => { + chainMocks = [ + createChainMock([ + cacheRow(1, "high-hit-no-coefficient", 0.9), + cacheRow(2, "low-hit-high-coefficient", 0.3), + cacheRow(3, "mid-hit-low-coefficient", 0.6), + ]), + createChainMock([]), + ]; + mocks.getProviderCacheCoefficients.mockResolvedValue( + coefficientMap([ + { providerId: 2, coefficientBp: 9000 }, + { providerId: 3, coefficientBp: 2000 }, + ]) + ); + + const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyProviderCacheHitRateLeaderboard(); + + // coefficient DESC,无系数的 provider 1 排最后 + expect(result.map((r) => r.providerId)).toEqual([2, 3, 1]); + expect(result.map((r) => r.cacheCoefficientBp)).toEqual([9000, 2000, null]); + }); + + it("cache hit leaderboard breaks coefficient ties by cacheHitRate descending", async () => { + chainMocks = [ + createChainMock([cacheRow(1, "low-hit", 0.2), cacheRow(2, "high-hit", 0.8)]), + createChainMock([]), + ]; + mocks.getProviderCacheCoefficients.mockResolvedValue( + coefficientMap([ + { providerId: 1, coefficientBp: 5000 }, + { providerId: 2, coefficientBp: 5000 }, + ]) + ); + + const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyProviderCacheHitRateLeaderboard(); + + expect(result.map((r) => r.providerId)).toEqual([2, 1]); + }); +}); + describe("Model Leaderboard basis handling", () => { beforeEach(() => { vi.resetModules(); @@ -662,6 +785,7 @@ describe("Model Leaderboard basis handling", () => { mockSelect.mockClear(); mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); }); it("marks top-level model successRate as unavailable when billingModelSource is redirected", async () => { @@ -700,6 +824,7 @@ describe("Model Leaderboard sort order", () => { mockSelect.mockClear(); mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); }); it("orders by total cost descending with request count as tiebreaker", async () => { diff --git a/tests/unit/repository/message-hedge-loser-cost.test.ts b/tests/unit/repository/message-hedge-loser-cost.test.ts index 2c68db9f8..0b327bcea 100644 --- a/tests/unit/repository/message-hedge-loser-cost.test.ts +++ b/tests/unit/repository/message-hedge-loser-cost.test.ts @@ -7,7 +7,6 @@ function sqlToString(sqlObj: unknown): string { visited.add(node); if (typeof node === "string") return node; if (typeof node === "object") { - // biome-ignore lint/suspicious/noExplicitAny: test-only structural walk const anyNode = node as any; if (Array.isArray(anyNode)) return anyNode.map(walk).join(""); if (anyNode.name && typeof anyNode.name === "string") return anyNode.name; diff --git a/tests/unit/repository/system-config-degradation-ladder.test.ts b/tests/unit/repository/system-config-degradation-ladder.test.ts index 54020cdb6..ea75e70d7 100644 --- a/tests/unit/repository/system-config-degradation-ladder.test.ts +++ b/tests/unit/repository/system-config-degradation-ladder.test.ts @@ -7,6 +7,8 @@ import type { UpdateSystemSettingsInput } from "@/types/system-config"; // 近代新增列(最新在前),降级链按引入顺序逐层累计剥离。 const RECENT_COLUMNS = [ + "affinityIgnoreClientSessionId", + "streamGateMode", "stickyTimeoutCooldownMs", "racingTotalTimeoutMs", "stickySlaMs", @@ -23,8 +25,10 @@ const RECENT_COLUMNS = [ "allowNonConversationEndpointProviderFallback", ] as const; -// 全量字段集(44 列)。 +// 全量字段集(46 列)。 const FULL_COLUMNS = [ + "affinityIgnoreClientSessionId", + "streamGateMode", "discoveryEnabled", "discoveryConcurrency", "maxDiscoveryRounds", @@ -138,7 +142,7 @@ function createResolvingSelectQuery(rows: unknown[]) { } describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { - test("getSystemSettings 全部列缺失时按既定顺序尝试 12 套字段集", async () => { + test("getSystemSettings 全部列缺失时按既定顺序尝试 14 套字段集", async () => { vi.resetModules(); const selections: string[][] = []; @@ -181,7 +185,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const selectMock = vi.fn((selection: Record) => { selections.push(sortedKeys(selection)); callIndex += 1; - if (callIndex < 16) { + if (callIndex < 18) { return createRejectingSelectQuery({ code: "42703" }); } return createResolvingSelectQuery([ @@ -214,14 +218,14 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const result = await getSystemSettings(); - expect(selectMock).toHaveBeenCalledTimes(16); - // 第 15 次(近代链末层)不含这些新列;第 16 次(passThrough 世代)重新包含旧列。 - expect(selections[14]).not.toContain("enableThinkingEffortConflictRectifier"); - expect(selections[14]).not.toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[14]).toContain("passThroughUpstreamErrorMessage"); - expect(selections[15]).toContain("enableThinkingEffortConflictRectifier"); - expect(selections[15]).toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[15]).not.toContain("passThroughUpstreamErrorMessage"); + expect(selectMock).toHaveBeenCalledTimes(18); + // 第 17 次(近代链末层)不含这些新列;第 18 次(passThrough 世代)重新包含旧列。 + expect(selections[16]).not.toContain("enableThinkingEffortConflictRectifier"); + expect(selections[16]).not.toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[16]).toContain("passThroughUpstreamErrorMessage"); + expect(selections[17]).toContain("enableThinkingEffortConflictRectifier"); + expect(selections[17]).toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[17]).not.toContain("passThroughUpstreamErrorMessage"); // 世代字段集选出的真实值要透传,缺失列由 transformer 落默认值。 expect(result.siteTitle).toBe("Era Row"); @@ -232,7 +236,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { expect(result.passThroughUpstreamErrorMessage).toBe(true); }); - test("updateSystemSettings 全部列缺失时按既定顺序尝试 11 套 set/returning 组合", async () => { + test("updateSystemSettings 全部列缺失时按既定顺序尝试 13 套 set/returning 组合", async () => { vi.resetModules(); const now = new Date("2026-01-04T00:00:00.000Z"); @@ -289,6 +293,8 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { enableGeminiFunctionIdRectifier: false, allowNonConversationEndpointProviderFallback: false, fakeStreamingWhitelist: [], + streamGateMode: "shadow", + affinityIgnoreClientSessionId: false, publicStatusWindowHours: 48, publicStatusAggregationIntervalMinutes: 10, ipExtractionConfig: null, @@ -299,7 +305,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { "system_settings 表列缺失,请执行数据库迁移以升级数据库结构。" ); - expect(updateMock).toHaveBeenCalledTimes(18); + expect(updateMock).toHaveBeenCalledTimes(20); const expectedReturningSequence = [ [...FULL_COLUMNS], @@ -323,6 +329,8 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { "enableGeminiFunctionIdRectifier", "allowNonConversationEndpointProviderFallback", "fakeStreamingWhitelist", + "streamGateMode", + "affinityIgnoreClientSessionId", "publicStatusWindowHours", "publicStatusAggregationIntervalMinutes", "ipExtractionConfig", @@ -368,7 +376,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { let updateCallIndex = 0; const updateMock = vi.fn(() => { updateCallIndex += 1; - const shouldResolve = updateCallIndex === 10; + const shouldResolve = updateCallIndex === 12; const query: Record = {}; query.set = vi.fn(() => query); query.where = vi.fn(() => query); @@ -407,7 +415,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { codexPriorityBillingSource: "actual", }); - expect(updateMock).toHaveBeenCalledTimes(10); + expect(updateMock).toHaveBeenCalledTimes(12); expect(result.siteTitle).toBe("Tail Success"); expect(result.codexPriorityBillingSource).toBe("actual"); }); diff --git a/tests/unit/repository/system-config-update-missing-columns.test.ts b/tests/unit/repository/system-config-update-missing-columns.test.ts index 611ab919d..b602dc559 100644 --- a/tests/unit/repository/system-config-update-missing-columns.test.ts +++ b/tests/unit/repository/system-config-update-missing-columns.test.ts @@ -291,7 +291,7 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { vi.useRealTimers(); }); - test("getSystemSettings 在仅缺 enable_gemini_function_id_rectifier 新列时应降级读取并默认开启", async () => { + test("getSystemSettings 在仅缺 affinity_ignore_client_session_id 新列时应降级读取并默认开启", async () => { vi.resetModules(); const now = new Date("2026-01-04T00:00:00.000Z"); @@ -299,7 +299,7 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { vi.setSystemTime(now); // 第一次 select(fullSelection) 因新列缺失而抛 42703; - // 第二次 select(selectionWithoutDiscoveryColumn) 命中——验证新列已加入降级链最外层。 + // 第二次 select(selectionWithoutAffinityIgnore) 命中——验证新列已加入降级链最外层。 const selectMock = vi .fn() .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) @@ -334,16 +334,22 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { const result = await getSystemSettings(); - // 降级读取成功(未抛错)。 + // 降级读取成功(未抛错),缺失列由 transformer 落默认值。 expect(selectMock).toHaveBeenCalledTimes(2); expect(result.siteTitle).toBe("Claude Code Hub"); expect(result.enableHttp2).toBe(true); + expect(result.affinityIgnoreClientSessionId).toBe(true); + expect(result.streamGateMode).toBe("enforce"); - // 关键回归保护:第二次 select 必须恰好剥离了最新 Discovery 列, - // 而非旧行为先剥离 enableGeminiFunctionIdRectifier。 + // 关键回归保护:第二次 select 必须恰好剥离了最新列(最外层降级), + // 而非旧行为先剥离更早引入的列。若新列未加入降级链最外层,下面断言会失败。 const secondSelection = selectMock.mock.calls[1]?.[0] as Record; - expect(secondSelection).not.toHaveProperty("stickyTimeoutCooldownMs"); + expect(secondSelection).not.toHaveProperty("affinityIgnoreClientSessionId"); + expect(secondSelection).toHaveProperty("streamGateMode"); + expect(secondSelection).toHaveProperty("stickyTimeoutCooldownMs"); expect(secondSelection).toHaveProperty("racingTotalTimeoutMs"); + expect(secondSelection).toHaveProperty("enableGeminiFunctionIdRectifier"); + expect(secondSelection).toHaveProperty("enableThinkingEffortConflictRectifier"); vi.useRealTimers(); }); diff --git a/tests/unit/server-response-write-backpressure.test.ts b/tests/unit/server-response-write-backpressure.test.ts index 006c36aca..f8c02a456 100644 --- a/tests/unit/server-response-write-backpressure.test.ts +++ b/tests/unit/server-response-write-backpressure.test.ts @@ -26,7 +26,7 @@ type ServerModule = { request: http.ClientRequest, response?: http.IncomingMessage | null, settleTurn?: () => boolean - ) => boolean | void, + ) => boolean | undefined, close?: (code: number, reason: string) => void ) => Promise; };