From f25e7db81ac40c4d212a3abcf283962da02aa09f Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Fri, 12 Jun 2026 22:17:59 +0800 Subject: [PATCH 01/13] feat(db): add keyword_routing_rules table and enableKeywordModelRouting setting --- drizzle/0106_stale_demogoblin.sql | 16 + drizzle/meta/0106_snapshot.json | 4657 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/drizzle/schema.ts | 25 + 4 files changed, 4705 insertions(+) create mode 100644 drizzle/0106_stale_demogoblin.sql create mode 100644 drizzle/meta/0106_snapshot.json diff --git a/drizzle/0106_stale_demogoblin.sql b/drizzle/0106_stale_demogoblin.sql new file mode 100644 index 000000000..e10780891 --- /dev/null +++ b/drizzle/0106_stale_demogoblin.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS "keyword_routing_rules" ( + "id" serial PRIMARY KEY NOT NULL, + "keyword" varchar(500) NOT NULL, + "source_model" varchar(128), + "target_model" varchar(128) NOT NULL, + "case_sensitive" boolean DEFAULT true NOT NULL, + "priority" integer DEFAULT 0 NOT NULL, + "description" text, + "is_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "enable_keyword_model_routing" boolean DEFAULT false NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_keyword_routing_rules_enabled" ON "keyword_routing_rules" USING btree ("is_enabled","priority");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_keyword_routing_rules_created_at" ON "keyword_routing_rules" USING btree ("created_at"); \ No newline at end of file diff --git a/drizzle/meta/0106_snapshot.json b/drizzle/meta/0106_snapshot.json new file mode 100644 index 000000000..8e1bb4132 --- /dev/null +++ b/drizzle/meta/0106_snapshot.json @@ -0,0 +1,4657 @@ +{ + "id": "5e9ad529-5bc5-40f2-9110-c99c39e25485", + "prevId": "e61c4e85-4edf-4d73-b2e3-5326af94eba0", + "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.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.keyword_routing_rules": { + "name": "keyword_routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "source_model": { + "name": "source_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "target_model": { + "name": "target_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "case_sensitive": { + "name": "case_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_keyword_routing_rules_enabled": { + "name": "idx_keyword_routing_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": {} + }, + "idx_keyword_routing_rules_created_at": { + "name": "idx_keyword_routing_rules_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.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 + }, + "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 + }, + "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": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_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_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "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 + }, + "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 + }, + "enable_keyword_model_routing": { + "name": "enable_keyword_model_routing", + "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_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 + }, + "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 4d57a874a..83ddaa022 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -743,6 +743,13 @@ "when": 1781156586163, "tag": "0105_chief_rocket_racer", "breakpoints": true + }, + { + "idx": 106, + "version": "7", + "when": 1781273211629, + "tag": "0106_stale_demogoblin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ff1a76a2a..de7b555c6 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -722,6 +722,27 @@ export const sensitiveWords = pgTable('sensitive_words', { sensitiveWordsCreatedAtIdx: index('idx_sensitive_words_created_at').on(table.createdAt), })); +// Keyword Routing Rules table - 关键词模型路由规则 +export const keywordRoutingRules = pgTable('keyword_routing_rules', { + id: serial('id').primaryKey(), + keyword: varchar('keyword', { length: 500 }).notNull(), + // 匹配的请求模型,null 表示匹配任意请求模型 + sourceModel: varchar('source_model', { length: 128 }), + targetModel: varchar('target_model', { length: 128 }).notNull(), + caseSensitive: boolean('case_sensitive').notNull().default(true), + // 优先级,数值越小越先匹配 + priority: integer('priority').notNull().default(0), + description: text('description'), + isEnabled: boolean('is_enabled').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +}, (table) => ({ + // 优化启用状态和优先级的查询 + keywordRoutingRulesEnabledIdx: index('idx_keyword_routing_rules_enabled').on(table.isEnabled, table.priority), + // 基础索引 + keywordRoutingRulesCreatedAtIdx: index('idx_keyword_routing_rules_created_at').on(table.createdAt), +})); + // System Settings table export const systemSettings = pgTable('system_settings', { id: serial('id').primaryKey(), @@ -764,6 +785,10 @@ export const systemSettings = pgTable('system_settings', { // 客户端版本检查配置 enableClientVersionCheck: boolean('enable_client_version_check').notNull().default(false), + // 关键词模型路由(默认关闭) + // 开启后:当请求的 system 提示或最后一条用户消息命中配置的关键词时,在供应商选择前将请求模型重写为目标模型 + enableKeywordModelRouting: boolean('enable_keyword_model_routing').notNull().default(false), + // 供应商不可用时是否返回详细错误信息 verboseProviderError: boolean('verbose_provider_error').notNull().default(false), From 658b755141e1c5e7413307025f3ede570771b296 Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Fri, 12 Jun 2026 23:33:51 +0800 Subject: [PATCH 02/13] feat(repository): add keyword routing rules repository with cache invalidation events --- src/lib/emit-event.ts | 23 ++ src/lib/event-emitter.ts | 8 + src/lib/redis/pubsub.ts | 1 + src/repository/keyword-routing-rules.ts | 152 +++++++++++++ tests/unit/lib/emit-event.test.ts | 24 +- .../repository/keyword-routing-events.test.ts | 212 ++++++++++++++++++ 6 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 src/repository/keyword-routing-rules.ts create mode 100644 tests/unit/repository/keyword-routing-events.test.ts diff --git a/src/lib/emit-event.ts b/src/lib/emit-event.ts index defa3f27b..2ff89b513 100644 --- a/src/lib/emit-event.ts +++ b/src/lib/emit-event.ts @@ -51,6 +51,29 @@ export async function emitSensitiveWordsUpdated(): Promise { } } +/** + * 触发 keywordRoutingRulesUpdated 事件 + */ +export async function emitKeywordRoutingRulesUpdated(): Promise { + if (typeof process !== "undefined" && process.env.NEXT_RUNTIME !== "edge") { + try { + const { eventEmitter } = await import("@/lib/event-emitter"); + eventEmitter.emitKeywordRoutingRulesUpdated(); + } catch { + // 忽略导入错误 + } + + try { + const { CHANNEL_KEYWORD_ROUTING_RULES_UPDATED, publishCacheInvalidation } = await import( + "@/lib/redis/pubsub" + ); + await publishCacheInvalidation(CHANNEL_KEYWORD_ROUTING_RULES_UPDATED); + } catch { + // 忽略导入错误 + } + } +} + /** * 触发 requestFiltersUpdated 事件 */ diff --git a/src/lib/event-emitter.ts b/src/lib/event-emitter.ts index 3b1cd2ae7..a8dc35a5c 100644 --- a/src/lib/event-emitter.ts +++ b/src/lib/event-emitter.ts @@ -19,6 +19,7 @@ interface EventMap { errorRulesUpdated: []; sensitiveWordsUpdated: []; requestFiltersUpdated: []; + keywordRoutingRulesUpdated: []; } /** @@ -45,6 +46,13 @@ class GlobalEventEmitter extends NodeEventEmitter { emitRequestFiltersUpdated(): void { this.emit("requestFiltersUpdated"); } + + /** + * 发送 keywordRoutingRulesUpdated 事件 + */ + emitKeywordRoutingRulesUpdated(): void { + this.emit("keywordRoutingRulesUpdated"); + } } /** diff --git a/src/lib/redis/pubsub.ts b/src/lib/redis/pubsub.ts index ebabbaf07..03486511f 100644 --- a/src/lib/redis/pubsub.ts +++ b/src/lib/redis/pubsub.ts @@ -7,6 +7,7 @@ import { getRedisClient } from "./client"; export const CHANNEL_ERROR_RULES_UPDATED = "cch:cache:error_rules:updated"; export const CHANNEL_REQUEST_FILTERS_UPDATED = "cch:cache:request_filters:updated"; export const CHANNEL_SENSITIVE_WORDS_UPDATED = "cch:cache:sensitive_words:updated"; +export const CHANNEL_KEYWORD_ROUTING_RULES_UPDATED = "cch:cache:keyword_routing_rules:updated"; // API Key 集合发生变化(典型:创建新 key)时,通知各实例重建 Vacuum Filter,避免误拒绝 export const CHANNEL_API_KEYS_UPDATED = "cch:cache:api_keys:updated"; diff --git a/src/repository/keyword-routing-rules.ts b/src/repository/keyword-routing-rules.ts new file mode 100644 index 000000000..e453a6a4c --- /dev/null +++ b/src/repository/keyword-routing-rules.ts @@ -0,0 +1,152 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { keywordRoutingRules } from "@/drizzle/schema"; +import { emitKeywordRoutingRulesUpdated } from "@/lib/emit-event"; + +export interface KeywordRoutingRule { + id: number; + keyword: string; + sourceModel: string | null; + targetModel: string; + caseSensitive: boolean; + priority: number; + description: string | null; + isEnabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** + * 将空字符串的来源模型归一化为 null(null 表示匹配任意请求模型) + */ +function normalizeSourceModel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function mapRow(row: typeof keywordRoutingRules.$inferSelect): KeywordRoutingRule { + return { + id: row.id, + keyword: row.keyword, + sourceModel: row.sourceModel, + targetModel: row.targetModel, + caseSensitive: row.caseSensitive, + priority: row.priority, + description: row.description, + isEnabled: row.isEnabled, + createdAt: row.createdAt ?? new Date(), + updatedAt: row.updatedAt ?? new Date(), + }; +} + +/** + * 获取所有启用的关键词路由规则(用于缓存加载,按优先级升序排列) + */ +export async function getActiveKeywordRoutingRules(): Promise { + const results = await db.query.keywordRoutingRules.findMany({ + where: eq(keywordRoutingRules.isEnabled, true), + orderBy: [keywordRoutingRules.priority, keywordRoutingRules.id], + }); + + return results.map(mapRow); +} + +/** + * 获取所有关键词路由规则(包括禁用的,按评估顺序排列) + */ +export async function getAllKeywordRoutingRules(): Promise { + const results = await db.query.keywordRoutingRules.findMany({ + orderBy: [keywordRoutingRules.priority, keywordRoutingRules.id], + }); + + return results.map(mapRow); +} + +/** + * 创建关键词路由规则 + */ +export async function createKeywordRoutingRule(data: { + keyword: string; + sourceModel?: string | null; + targetModel: string; + caseSensitive?: boolean; + priority?: number; + description?: string | null; +}): Promise { + const [result] = await db + .insert(keywordRoutingRules) + .values({ + keyword: data.keyword.trim(), + sourceModel: normalizeSourceModel(data.sourceModel), + targetModel: data.targetModel.trim(), + caseSensitive: data.caseSensitive, + priority: data.priority, + description: data.description, + }) + .returning(); + + await emitKeywordRoutingRulesUpdated(); + + return mapRow(result); +} + +/** + * 更新关键词路由规则 + */ +export async function updateKeywordRoutingRule( + id: number, + data: Partial<{ + keyword: string; + sourceModel: string | null; + targetModel: string; + caseSensitive: boolean; + priority: number; + description: string | null; + isEnabled: boolean; + }> +): Promise { + const updates = { ...data }; + if (updates.keyword !== undefined) { + updates.keyword = updates.keyword.trim(); + } + if (updates.targetModel !== undefined) { + updates.targetModel = updates.targetModel.trim(); + } + if (updates.sourceModel !== undefined) { + updates.sourceModel = normalizeSourceModel(updates.sourceModel); + } + + const [result] = await db + .update(keywordRoutingRules) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(keywordRoutingRules.id, id)) + .returning(); + + if (!result) { + return null; + } + + await emitKeywordRoutingRulesUpdated(); + + return mapRow(result); +} + +/** + * 删除关键词路由规则 + */ +export async function deleteKeywordRoutingRule(id: number): Promise { + const result = await db + .delete(keywordRoutingRules) + .where(eq(keywordRoutingRules.id, id)) + .returning(); + + if (result.length === 0) return false; + + await emitKeywordRoutingRulesUpdated(); + return true; +} diff --git a/tests/unit/lib/emit-event.test.ts b/tests/unit/lib/emit-event.test.ts index 53948dbf5..229b322da 100644 --- a/tests/unit/lib/emit-event.test.ts +++ b/tests/unit/lib/emit-event.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => { emitErrorRulesUpdated: vi.fn(), emitSensitiveWordsUpdated: vi.fn(), emitRequestFiltersUpdated: vi.fn(), + emitKeywordRoutingRulesUpdated: vi.fn(), publishCacheInvalidation: vi.fn(async () => {}), }; }); @@ -14,6 +15,7 @@ vi.mock("@/lib/event-emitter", () => ({ emitErrorRulesUpdated: mocks.emitErrorRulesUpdated, emitSensitiveWordsUpdated: mocks.emitSensitiveWordsUpdated, emitRequestFiltersUpdated: mocks.emitRequestFiltersUpdated, + emitKeywordRoutingRulesUpdated: mocks.emitKeywordRoutingRulesUpdated, }, })); @@ -21,6 +23,7 @@ vi.mock("@/lib/redis/pubsub", () => ({ CHANNEL_ERROR_RULES_UPDATED: "cch:cache:error_rules:updated", CHANNEL_REQUEST_FILTERS_UPDATED: "cch:cache:request_filters:updated", CHANNEL_SENSITIVE_WORDS_UPDATED: "cch:cache:sensitive_words:updated", + CHANNEL_KEYWORD_ROUTING_RULES_UPDATED: "cch:cache:keyword_routing_rules:updated", publishCacheInvalidation: mocks.publishCacheInvalidation, })); @@ -67,19 +70,36 @@ describe.sequential("emit-event", () => { ); }); + test("emitKeywordRoutingRulesUpdated:Node.js runtime 下应触发本地事件并广播缓存失效", async () => { + const { emitKeywordRoutingRulesUpdated } = await import("@/lib/emit-event"); + await emitKeywordRoutingRulesUpdated(); + + expect(mocks.emitKeywordRoutingRulesUpdated).toHaveBeenCalledTimes(1); + expect(mocks.publishCacheInvalidation).toHaveBeenCalledTimes(1); + expect(mocks.publishCacheInvalidation).toHaveBeenCalledWith( + "cch:cache:keyword_routing_rules:updated" + ); + }); + test("Edge runtime 下应静默跳过(不触发任何事件/广播)", async () => { process.env.NEXT_RUNTIME = "edge"; - const { emitErrorRulesUpdated, emitSensitiveWordsUpdated, emitRequestFiltersUpdated } = - await import("@/lib/emit-event"); + const { + emitErrorRulesUpdated, + emitSensitiveWordsUpdated, + emitRequestFiltersUpdated, + emitKeywordRoutingRulesUpdated, + } = await import("@/lib/emit-event"); await emitErrorRulesUpdated(); await emitSensitiveWordsUpdated(); await emitRequestFiltersUpdated(); + await emitKeywordRoutingRulesUpdated(); expect(mocks.emitErrorRulesUpdated).not.toHaveBeenCalled(); expect(mocks.emitSensitiveWordsUpdated).not.toHaveBeenCalled(); expect(mocks.emitRequestFiltersUpdated).not.toHaveBeenCalled(); + expect(mocks.emitKeywordRoutingRulesUpdated).not.toHaveBeenCalled(); expect(mocks.publishCacheInvalidation).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/repository/keyword-routing-events.test.ts b/tests/unit/repository/keyword-routing-events.test.ts new file mode 100644 index 000000000..41b8bb65d --- /dev/null +++ b/tests/unit/repository/keyword-routing-events.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test, vi } from "vitest"; + +function createDbMock(options: { + insertReturning: unknown[]; + updateReturning: unknown[]; + deleteReturning: unknown[]; +}) { + const valuesMock = vi.fn(() => ({ + returning: vi.fn(async () => options.insertReturning), + })); + const setMock = vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => options.updateReturning), + })), + })); + + const db = { + insert: vi.fn(() => ({ + values: valuesMock, + })), + update: vi.fn(() => ({ + set: setMock, + })), + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => options.deleteReturning), + })), + })), + query: { + keywordRoutingRules: { + findMany: vi.fn(), + }, + }, + }; + + return { db, valuesMock, setMock }; +} + +function createRow(overrides: Record = {}) { + return { + id: 1, + keyword: "ultrathink", + sourceModel: null, + targetModel: "claude-opus-4-5", + caseSensitive: true, + priority: 0, + description: null, + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockModules(db: unknown, emitKeywordRoutingRulesUpdated: ReturnType) { + vi.doMock("@/drizzle/db", () => ({ db })); + vi.doMock("@/drizzle/schema", () => ({ + keywordRoutingRules: { id: {}, priority: {}, isEnabled: {} }, + })); + vi.doMock("drizzle-orm", () => ({ eq: vi.fn(() => ({})) })); + vi.doMock("@/lib/emit-event", () => ({ emitKeywordRoutingRulesUpdated })); +} + +describe("Keyword routing rules repository events", () => { + test("createKeywordRoutingRule: should emitKeywordRoutingRulesUpdated", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db } = createDbMock({ + insertReturning: [createRow()], + updateReturning: [], + deleteReturning: [], + }); + + mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + await repo.createKeywordRoutingRule({ + keyword: "ultrathink", + targetModel: "claude-opus-4-5", + }); + + expect(emitKeywordRoutingRulesUpdated).toHaveBeenCalledTimes(1); + }); + + test("createKeywordRoutingRule: should normalize empty sourceModel to null and trim inputs", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db, valuesMock } = createDbMock({ + insertReturning: [createRow()], + updateReturning: [], + deleteReturning: [], + }); + + mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + await repo.createKeywordRoutingRule({ + keyword: " ultrathink ", + sourceModel: " ", + targetModel: " claude-opus-4-5 ", + }); + + expect(valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + keyword: "ultrathink", + sourceModel: null, + targetModel: "claude-opus-4-5", + }) + ); + }); + + test("updateKeywordRoutingRule: should emitKeywordRoutingRulesUpdated when row found", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db } = createDbMock({ + insertReturning: [], + updateReturning: [createRow()], + deleteReturning: [], + }); + + mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + const result = await repo.updateKeywordRoutingRule(1, { keyword: "updated" }); + + expect(result).not.toBeNull(); + expect(emitKeywordRoutingRulesUpdated).toHaveBeenCalledTimes(1); + }); + + test("updateKeywordRoutingRule: should set updatedAt and normalize empty sourceModel to null", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db, setMock } = createDbMock({ + insertReturning: [], + updateReturning: [createRow()], + deleteReturning: [], + }); + + mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + await repo.updateKeywordRoutingRule(1, { sourceModel: "" }); + + expect(setMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceModel: null, + updatedAt: expect.any(Date), + }) + ); + }); + + test("updateKeywordRoutingRule: should not emitKeywordRoutingRulesUpdated when row not found", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db } = createDbMock({ + insertReturning: [], + updateReturning: [], + deleteReturning: [], + }); + + mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + const result = await repo.updateKeywordRoutingRule(1, { keyword: "updated" }); + + expect(result).toBeNull(); + expect(emitKeywordRoutingRulesUpdated).not.toHaveBeenCalled(); + }); + + test("deleteKeywordRoutingRule: should emitKeywordRoutingRulesUpdated when row deleted", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db } = createDbMock({ + insertReturning: [], + updateReturning: [], + deleteReturning: [createRow()], + }); + + mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + const deleted = await repo.deleteKeywordRoutingRule(1); + + expect(deleted).toBe(true); + expect(emitKeywordRoutingRulesUpdated).toHaveBeenCalledTimes(1); + }); + + test("deleteKeywordRoutingRule: should not emitKeywordRoutingRulesUpdated when row not deleted", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db } = createDbMock({ + insertReturning: [], + updateReturning: [], + deleteReturning: [], + }); + + mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + const deleted = await repo.deleteKeywordRoutingRule(1); + + expect(deleted).toBe(false); + expect(emitKeywordRoutingRulesUpdated).not.toHaveBeenCalled(); + }); +}); From d812e5a4759c46d64a91123d1f4427a5b324f80f Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Fri, 12 Jun 2026 23:58:42 +0800 Subject: [PATCH 03/13] feat(lib): add keyword routing text extraction and rule matcher --- src/lib/keyword-routing/matcher.ts | 72 +++++++ src/lib/message-extractor.ts | 121 ++++++++++++ .../unit/lib/keyword-routing/matcher.test.ts | 185 ++++++++++++++++++ .../message-extractor-keyword-routing.test.ts | 185 ++++++++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 src/lib/keyword-routing/matcher.ts create mode 100644 tests/unit/lib/keyword-routing/matcher.test.ts create mode 100644 tests/unit/lib/message-extractor-keyword-routing.test.ts diff --git a/src/lib/keyword-routing/matcher.ts b/src/lib/keyword-routing/matcher.ts new file mode 100644 index 000000000..231b1aed6 --- /dev/null +++ b/src/lib/keyword-routing/matcher.ts @@ -0,0 +1,72 @@ +import type { KeywordRoutingScanTexts } from "@/lib/message-extractor"; +import type { KeywordRoutingRule } from "@/repository/keyword-routing-rules"; + +/** + * 关键词路由匹配结果 + * + * matchedIn 标记关键词命中的位置:system(系统提示词)或 user(最后一条用户消息) + */ +export interface KeywordRoutingMatch { + rule: KeywordRoutingRule; + matchedIn: "system" | "user"; +} + +/** + * 判断规则的关键词是否命中给定文本(子串匹配) + * + * caseSensitive=false 时双方统一转为小写后比较 + */ +export function ruleMatchesText( + rule: Pick, + text: string +): boolean { + if (rule.caseSensitive) { + return text.includes(rule.keyword); + } + return text.toLowerCase().includes(rule.keyword.toLowerCase()); +} + +/** + * 在扫描文本中查找首个命中的关键词路由规则 + * + * 语义: + * - 按传入顺序逐条评估(调用方需保证 priority 升序、id 升序),首个命中即返回 + * - 跳过已禁用的规则(深度防御) + * - 跳过关键词为空或仅空白字符的规则(空关键词会匹配一切,防御脏数据) + * - sourceModel 非空时要求与请求模型严格相等(大小写敏感),否则跳过该规则 + * - 先检查 systemTexts,再检查 lastUserTexts,matchedIn 反映命中位置 + * + * @param rules - 已按评估顺序排列的规则列表 + * @param texts - 按来源分类的待扫描文本 + * @param requestedModel - 客户端请求的模型名(可能为 null) + * @returns 首个命中的规则及命中位置,未命中返回 null + */ +export function findMatchingKeywordRoutingRule( + rules: readonly KeywordRoutingRule[], + texts: KeywordRoutingScanTexts, + requestedModel: string | null +): KeywordRoutingMatch | null { + for (const rule of rules) { + if (!rule.isEnabled) { + continue; + } + + if (rule.keyword.trim().length === 0) { + continue; + } + + if (rule.sourceModel && rule.sourceModel !== requestedModel) { + continue; + } + + if (texts.systemTexts.some((text) => ruleMatchesText(rule, text))) { + return { rule, matchedIn: "system" }; + } + + if (texts.lastUserTexts.some((text) => ruleMatchesText(rule, text))) { + return { rule, matchedIn: "user" }; + } + } + + return null; +} diff --git a/src/lib/message-extractor.ts b/src/lib/message-extractor.ts index 65f582967..78b541030 100644 --- a/src/lib/message-extractor.ts +++ b/src/lib/message-extractor.ts @@ -140,6 +140,127 @@ function extractInputText(input: unknown): string[] { return texts; } +/** + * 关键词路由扫描文本 + * + * systemTexts: 系统提示词文本(system / instructions / role=system|developer 消息) + * lastUserTexts: 最后一条用户消息文本(含顶层 prompt) + */ +export interface KeywordRoutingScanTexts { + systemTexts: string[]; + lastUserTexts: string[]; +} + +/** + * 从单个消息条目的 content 字段中提取文本(string 或 content block 数组) + */ +function extractEntryContentTexts(entry: Record): string[] { + const texts: string[] = []; + + if (typeof entry.content === "string") { + texts.push(entry.content); + } else if (Array.isArray(entry.content)) { + entry.content.forEach((block) => { + const text = extractTextFromBlock(block); + if (text) { + texts.push(text); + } + }); + } + + return texts; +} + +/** + * 从消息条目数组(messages 或 input 字段)中按角色收集关键词路由扫描文本 + * + * - role=system / role=developer 的条目进入 systemTexts + * - 仅最后一条 role=user 的条目进入 lastUserTexts + */ +function collectRoleScanTexts( + entries: unknown[], + systemTexts: string[], + lastUserTexts: string[] +): void { + let lastUserEntry: Record | null = null; + + entries.forEach((entry) => { + if (typeof entry !== "object" || entry === null) { + return; + } + + const obj = entry as Record; + + if (obj.role === "system" || obj.role === "developer") { + systemTexts.push(...extractEntryContentTexts(obj)); + } else if (obj.role === "user") { + lastUserEntry = obj; + } + }); + + if (lastUserEntry) { + lastUserTexts.push(...extractEntryContentTexts(lastUserEntry)); + } +} + +/** + * 从请求消息中提取关键词路由需要扫描的文本 + * + * 与 extractTextFromMessages 不同: + * - 区分系统提示词与最后一条用户消息两个来源 + * - 额外支持 instructions 字段(Codex / Response API 格式) + * - 支持 role=system / role=developer 的消息条目 + * - 用户消息仅扫描最后一条,避免历史消息误触发 + * + * 注意:Gemini 格式(contents / systemInstruction)暂不支持 + * + * @param message - 任意客户端格式的请求消息对象 + * @returns 按来源分类的待扫描文本 + */ +export function extractKeywordRoutingTexts( + message: Record +): KeywordRoutingScanTexts { + const systemTexts: string[] = []; + const lastUserTexts: string[] = []; + + // 1. 提取 system 字段(Claude 格式,string 或 content block 数组) + if ("system" in message) { + systemTexts.push(...extractSystemText(message.system)); + } + + // 2. 提取 instructions 字段(Codex / Response API 格式) + if (typeof message.instructions === "string") { + systemTexts.push(message.instructions); + } + + // 3. 提取 messages 数组(Claude / OpenAI Chat 格式) + if (Array.isArray(message.messages)) { + collectRoleScanTexts(message.messages, systemTexts, lastUserTexts); + } + + // 4. 提取 input 数组(Codex / Response API 格式) + if (Array.isArray(message.input)) { + collectRoleScanTexts(message.input, systemTexts, lastUserTexts); + } + + // 5. 提取图片接口等顶层 prompt 字段(string 或 string 数组) + if (typeof message.prompt === "string") { + lastUserTexts.push(message.prompt); + } else if (Array.isArray(message.prompt)) { + for (const item of message.prompt) { + if (typeof item === "string") { + lastUserTexts.push(item); + } + } + } + + // 过滤空字符串 + return { + systemTexts: systemTexts.filter((t) => t.length > 0), + lastUserTexts: lastUserTexts.filter((t) => t.length > 0), + }; +} + /** * 从请求消息中提取所有需要检测的文本 * diff --git a/tests/unit/lib/keyword-routing/matcher.test.ts b/tests/unit/lib/keyword-routing/matcher.test.ts new file mode 100644 index 000000000..71dae1686 --- /dev/null +++ b/tests/unit/lib/keyword-routing/matcher.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import type { KeywordRoutingScanTexts } from "@/lib/message-extractor"; +import { findMatchingKeywordRoutingRule, ruleMatchesText } from "@/lib/keyword-routing/matcher"; +import type { KeywordRoutingRule } from "@/repository/keyword-routing-rules"; + +let nextRuleId = 1; + +/** 构建测试规则的工厂函数(id 自增,提供合理默认值) */ +function makeRule(overrides: Partial = {}): KeywordRoutingRule { + const now = new Date(); + return { + id: nextRuleId++, + keyword: "EXAMPLE DIALOGE", + sourceModel: null, + targetModel: "claude-haiku-4-5", + caseSensitive: true, + priority: 0, + description: null, + isEnabled: true, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +/** 构建扫描文本的便捷函数 */ +function makeTexts(overrides: Partial = {}): KeywordRoutingScanTexts { + return { + systemTexts: [], + lastUserTexts: [], + ...overrides, + }; +} + +describe("ruleMatchesText", () => { + it("大小写敏感(默认):仅匹配完全一致的大小写", () => { + const rule = { keyword: "EXAMPLE DIALOGE", caseSensitive: true }; + + expect(ruleMatchesText(rule, "prefix EXAMPLE DIALOGE suffix")).toBe(true); + expect(ruleMatchesText(rule, "prefix example dialoge suffix")).toBe(false); + }); + + it("大小写不敏感:匹配任意大小写组合", () => { + const rule = { keyword: "EXAMPLE DIALOGE", caseSensitive: false }; + + expect(ruleMatchesText(rule, "EXAMPLE DIALOGE")).toBe(true); + expect(ruleMatchesText(rule, "example dialoge")).toBe(true); + expect(ruleMatchesText(rule, "Example Dialoge")).toBe(true); + }); + + it("子串语义:关键词出现在较长句子中也算命中", () => { + const rule = { keyword: "magic-token", caseSensitive: true }; + + expect(ruleMatchesText(rule, "please include the magic-token in your reply")).toBe(true); + expect(ruleMatchesText(rule, "no token here")).toBe(false); + }); +}); + +describe("findMatchingKeywordRoutingRule", () => { + it("无规则时返回 null", () => { + const result = findMatchingKeywordRoutingRule( + [], + makeTexts({ systemTexts: ["EXAMPLE DIALOGE"] }), + "claude-opus-4-8" + ); + + expect(result).toBeNull(); + }); + + it("文本为空时返回 null", () => { + const result = findMatchingKeywordRoutingRule([makeRule()], makeTexts(), "claude-opus-4-8"); + + expect(result).toBeNull(); + }); + + it("无任何关键词命中时返回 null", () => { + const result = findMatchingKeywordRoutingRule( + [makeRule({ keyword: "not present" })], + makeTexts({ systemTexts: ["some system"], lastUserTexts: ["some user"] }), + "claude-opus-4-8" + ); + + expect(result).toBeNull(); + }); + + describe("sourceModel 约束", () => { + it("sourceModel 非空时要求与请求模型严格相等", () => { + const rule = makeRule({ sourceModel: "claude-opus-4-8" }); + const texts = makeTexts({ lastUserTexts: ["EXAMPLE DIALOGE"] }); + + expect(findMatchingKeywordRoutingRule([rule], texts, "claude-opus-4-8")?.rule).toBe(rule); + expect(findMatchingKeywordRoutingRule([rule], texts, "claude-sonnet-4-5")).toBeNull(); + expect(findMatchingKeywordRoutingRule([rule], texts, "CLAUDE-OPUS-4-8")).toBeNull(); + }); + + it("sourceModel 为 null 或空字符串时匹配任意请求模型", () => { + const texts = makeTexts({ lastUserTexts: ["EXAMPLE DIALOGE"] }); + + const nullRule = makeRule({ sourceModel: null }); + expect(findMatchingKeywordRoutingRule([nullRule], texts, "any-model")?.rule).toBe(nullRule); + + const emptyRule = makeRule({ sourceModel: "" }); + expect(findMatchingKeywordRoutingRule([emptyRule], texts, "any-model")?.rule).toBe(emptyRule); + }); + + it("请求模型为 null 且规则带 sourceModel 约束时跳过该规则", () => { + const rule = makeRule({ sourceModel: "claude-opus-4-8" }); + const texts = makeTexts({ lastUserTexts: ["EXAMPLE DIALOGE"] }); + + expect(findMatchingKeywordRoutingRule([rule], texts, null)).toBeNull(); + }); + }); + + describe("评估顺序", () => { + it("数组顺序中首个命中的规则胜出,即使后续规则也能命中", () => { + const first = makeRule({ keyword: "shared", targetModel: "model-a" }); + const second = makeRule({ keyword: "shared", targetModel: "model-b" }); + const texts = makeTexts({ lastUserTexts: ["contains shared keyword"] }); + + const result = findMatchingKeywordRoutingRule([first, second], texts, null); + + expect(result?.rule).toBe(first); + expect(result?.rule.targetModel).toBe("model-a"); + }); + + it("前序规则未命中时回退到后续规则", () => { + const first = makeRule({ keyword: "absent" }); + const second = makeRule({ keyword: "shared" }); + const texts = makeTexts({ lastUserTexts: ["contains shared keyword"] }); + + expect(findMatchingKeywordRoutingRule([first, second], texts, null)?.rule).toBe(second); + }); + }); + + describe("规则有效性防御", () => { + it("跳过 isEnabled=false 的规则", () => { + const disabled = makeRule({ keyword: "shared", isEnabled: false }); + const enabled = makeRule({ keyword: "shared" }); + const texts = makeTexts({ lastUserTexts: ["contains shared keyword"] }); + + expect(findMatchingKeywordRoutingRule([disabled, enabled], texts, null)?.rule).toBe(enabled); + expect(findMatchingKeywordRoutingRule([disabled], texts, null)).toBeNull(); + }); + + it("跳过关键词为空或仅空白字符的规则", () => { + const empty = makeRule({ keyword: "" }); + const whitespace = makeRule({ keyword: " " }); + const texts = makeTexts({ systemTexts: ["anything"], lastUserTexts: ["anything"] }); + + expect(findMatchingKeywordRoutingRule([empty, whitespace], texts, null)).toBeNull(); + }); + }); + + describe("matchedIn 来源标记", () => { + it("仅 systemTexts 命中时 matchedIn 为 system", () => { + const rule = makeRule({ keyword: "shared" }); + const texts = makeTexts({ + systemTexts: ["contains shared keyword"], + lastUserTexts: ["nothing here"], + }); + + expect(findMatchingKeywordRoutingRule([rule], texts, null)?.matchedIn).toBe("system"); + }); + + it("仅 lastUserTexts 命中时 matchedIn 为 user", () => { + const rule = makeRule({ keyword: "shared" }); + const texts = makeTexts({ + systemTexts: ["nothing here"], + lastUserTexts: ["contains shared keyword"], + }); + + expect(findMatchingKeywordRoutingRule([rule], texts, null)?.matchedIn).toBe("user"); + }); + + it("两处同时命中时优先返回 system(system 先检查)", () => { + const rule = makeRule({ keyword: "shared" }); + const texts = makeTexts({ + systemTexts: ["contains shared keyword"], + lastUserTexts: ["also contains shared keyword"], + }); + + expect(findMatchingKeywordRoutingRule([rule], texts, null)?.matchedIn).toBe("system"); + }); + }); +}); diff --git a/tests/unit/lib/message-extractor-keyword-routing.test.ts b/tests/unit/lib/message-extractor-keyword-routing.test.ts new file mode 100644 index 000000000..7d43b8378 --- /dev/null +++ b/tests/unit/lib/message-extractor-keyword-routing.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { extractKeywordRoutingTexts } from "@/lib/message-extractor"; + +describe("extractKeywordRoutingTexts", () => { + describe("Claude 格式", () => { + it("提取字符串形式的 system 到 systemTexts", () => { + const result = extractKeywordRoutingTexts({ + system: "You are a helpful assistant", + messages: [{ role: "user", content: "hello" }], + }); + + expect(result.systemTexts).toEqual(["You are a helpful assistant"]); + expect(result.lastUserTexts).toEqual(["hello"]); + }); + + it("提取 content block 数组形式的 system 到 systemTexts", () => { + const result = extractKeywordRoutingTexts({ + system: [ + { type: "text", text: "block one" }, + { type: "text", text: "block two" }, + ], + }); + + expect(result.systemTexts).toEqual(["block one", "block two"]); + expect(result.lastUserTexts).toEqual([]); + }); + + it("多轮对话仅提取最后一条 user 消息", () => { + const result = extractKeywordRoutingTexts({ + messages: [ + { role: "user", content: "first question" }, + { role: "assistant", content: "first answer" }, + { role: "user", content: "second question" }, + { role: "assistant", content: "second answer" }, + { role: "user", content: "final question" }, + ], + }); + + expect(result.lastUserTexts).toEqual(["final question"]); + expect(result.lastUserTexts).not.toContain("first question"); + expect(result.lastUserTexts).not.toContain("second question"); + expect(result.systemTexts).toEqual([]); + }); + + it("完全忽略 assistant 消息", () => { + const result = extractKeywordRoutingTexts({ + messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: "assistant text" }, + ], + }); + + expect(result.systemTexts).toEqual([]); + expect(result.lastUserTexts).toEqual(["question"]); + }); + }); + + describe("OpenAI Chat 格式", () => { + it("role=system 与 role=developer 的消息进入 systemTexts", () => { + const result = extractKeywordRoutingTexts({ + messages: [ + { role: "system", content: "system prompt" }, + { role: "developer", content: "developer prompt" }, + { role: "user", content: "user prompt" }, + ], + }); + + expect(result.systemTexts).toEqual(["system prompt", "developer prompt"]); + expect(result.lastUserTexts).toEqual(["user prompt"]); + }); + + it("最后一条 user 消息的 content block 数组被正确提取", () => { + const result = extractKeywordRoutingTexts({ + messages: [ + { role: "user", content: "earlier" }, + { + role: "user", + content: [ + { type: "text", text: "part one" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + { type: "text", text: "part two" }, + ], + }, + ], + }); + + expect(result.lastUserTexts).toEqual(["part one", "part two"]); + expect(result.lastUserTexts).not.toContain("earlier"); + }); + }); + + describe("Codex / Response API 格式", () => { + it("顶层 instructions 字符串进入 systemTexts", () => { + const result = extractKeywordRoutingTexts({ + instructions: "Always respond in English", + input: [{ role: "user", content: "hi" }], + }); + + expect(result.systemTexts).toEqual(["Always respond in English"]); + expect(result.lastUserTexts).toEqual(["hi"]); + }); + + it("input 数组中 system/developer 进入 systemTexts,仅最后一条 user 进入 lastUserTexts", () => { + const result = extractKeywordRoutingTexts({ + input: [ + { role: "system", content: "input system" }, + { role: "developer", content: "input developer" }, + { role: "user", content: [{ type: "input_text", text: "first input" }] }, + { role: "assistant", content: "irrelevant" }, + { role: "user", content: [{ type: "input_text", text: "last input" }] }, + ], + }); + + expect(result.systemTexts).toEqual(["input system", "input developer"]); + expect(result.lastUserTexts).toEqual(["last input"]); + expect(result.lastUserTexts).not.toContain("first input"); + }); + }); + + describe("顶层 prompt 字段", () => { + it("字符串形式的 prompt 进入 lastUserTexts", () => { + const result = extractKeywordRoutingTexts({ prompt: "draw a cat" }); + + expect(result.systemTexts).toEqual([]); + expect(result.lastUserTexts).toEqual(["draw a cat"]); + }); + + it("字符串数组形式的 prompt 进入 lastUserTexts", () => { + const result = extractKeywordRoutingTexts({ prompt: ["draw a cat", "draw a dog"] }); + + expect(result.lastUserTexts).toEqual(["draw a cat", "draw a dog"]); + }); + }); + + describe("边界情况", () => { + it("空消息对象返回两个空数组", () => { + const result = extractKeywordRoutingTexts({}); + + expect(result.systemTexts).toEqual([]); + expect(result.lastUserTexts).toEqual([]); + }); + + it("跳过非对象的消息条目与数字 content", () => { + const result = extractKeywordRoutingTexts({ + messages: [ + "not an object", + 42, + null, + { role: "user", content: 123 }, + { role: "system", content: 456 }, + { role: "user", content: "valid" }, + ], + }); + + expect(result.systemTexts).toEqual([]); + expect(result.lastUserTexts).toEqual(["valid"]); + }); + + it("过滤空字符串", () => { + const result = extractKeywordRoutingTexts({ + system: "", + instructions: "", + messages: [ + { role: "system", content: "" }, + { role: "user", content: "" }, + ], + prompt: ["", "non-empty"], + }); + + expect(result.systemTexts).toEqual([]); + expect(result.lastUserTexts).toEqual(["non-empty"]); + }); + + it("最后一条 user 消息为 malformed 时 lastUserTexts 为空", () => { + const result = extractKeywordRoutingTexts({ + messages: [ + { role: "user", content: "earlier" }, + { role: "user", content: { nested: "object" } }, + ], + }); + + expect(result.lastUserTexts).toEqual([]); + }); + }); +}); From aedccd7f3c9a4a39629151dd141dfb9837c9d43d Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 00:24:12 +0800 Subject: [PATCH 04/13] refactor(lib): memoize case-insensitive matching and fix closure narrowing --- src/lib/keyword-routing/matcher.ts | 31 +++++++++++++++++-- src/lib/message-extractor.ts | 6 ++-- .../unit/lib/keyword-routing/matcher.test.ts | 28 +++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/lib/keyword-routing/matcher.ts b/src/lib/keyword-routing/matcher.ts index 231b1aed6..6e7ab4f2b 100644 --- a/src/lib/keyword-routing/matcher.ts +++ b/src/lib/keyword-routing/matcher.ts @@ -15,6 +15,9 @@ export interface KeywordRoutingMatch { * 判断规则的关键词是否命中给定文本(子串匹配) * * caseSensitive=false 时双方统一转为小写后比较 + * + * 注意:依据 String.includes 语义,空关键词会命中任意文本; + * 空关键词的防御性过滤由 findMatchingKeywordRoutingRule 负责 */ export function ruleMatchesText( rule: Pick, @@ -26,6 +29,22 @@ export function ruleMatchesText( return text.toLowerCase().includes(rule.keyword.toLowerCase()); } +/** + * 创建小写文本数组的惰性缓存 + * + * 仅在首次遇到大小写不敏感规则时才构建小写副本,且整个匹配调用内只构建一次, + * 避免对每个 (规则, 文本) 组合重复执行 toLowerCase(文本可能高达 100KB+) + */ +function createLoweredTextsCache(source: readonly string[]): () => readonly string[] { + let lowered: string[] | null = null; + return () => { + if (lowered === null) { + lowered = source.map((text) => text.toLowerCase()); + } + return lowered; + }; +} + /** * 在扫描文本中查找首个命中的关键词路由规则 * @@ -46,6 +65,9 @@ export function findMatchingKeywordRoutingRule( texts: KeywordRoutingScanTexts, requestedModel: string | null ): KeywordRoutingMatch | null { + const loweredSystemTexts = createLoweredTextsCache(texts.systemTexts); + const loweredLastUserTexts = createLoweredTextsCache(texts.lastUserTexts); + for (const rule of rules) { if (!rule.isEnabled) { continue; @@ -59,11 +81,16 @@ export function findMatchingKeywordRoutingRule( continue; } - if (texts.systemTexts.some((text) => ruleMatchesText(rule, text))) { + // 大小写不敏感时:关键词每条规则只转小写一次,扫描文本走惰性缓存 + const keyword = rule.caseSensitive ? rule.keyword : rule.keyword.toLowerCase(); + const systemTexts = rule.caseSensitive ? texts.systemTexts : loweredSystemTexts(); + const lastUserTexts = rule.caseSensitive ? texts.lastUserTexts : loweredLastUserTexts(); + + if (systemTexts.some((text) => text.includes(keyword))) { return { rule, matchedIn: "system" }; } - if (texts.lastUserTexts.some((text) => ruleMatchesText(rule, text))) { + if (lastUserTexts.some((text) => text.includes(keyword))) { return { rule, matchedIn: "user" }; } } diff --git a/src/lib/message-extractor.ts b/src/lib/message-extractor.ts index 78b541030..fc0546a42 100644 --- a/src/lib/message-extractor.ts +++ b/src/lib/message-extractor.ts @@ -184,9 +184,9 @@ function collectRoleScanTexts( ): void { let lastUserEntry: Record | null = null; - entries.forEach((entry) => { + for (const entry of entries) { if (typeof entry !== "object" || entry === null) { - return; + continue; } const obj = entry as Record; @@ -196,7 +196,7 @@ function collectRoleScanTexts( } else if (obj.role === "user") { lastUserEntry = obj; } - }); + } if (lastUserEntry) { lastUserTexts.push(...extractEntryContentTexts(lastUserEntry)); diff --git a/tests/unit/lib/keyword-routing/matcher.test.ts b/tests/unit/lib/keyword-routing/matcher.test.ts index 71dae1686..6bd6acffd 100644 --- a/tests/unit/lib/keyword-routing/matcher.test.ts +++ b/tests/unit/lib/keyword-routing/matcher.test.ts @@ -130,6 +130,20 @@ describe("findMatchingKeywordRoutingRule", () => { expect(findMatchingKeywordRoutingRule([first, second], texts, null)?.rule).toBe(second); }); + + it("规则顺序优先于来源顺序:前序规则命中 user 时胜过后续规则命中 system", () => { + const first = makeRule({ keyword: "user-only" }); + const second = makeRule({ keyword: "system-only" }); + const texts = makeTexts({ + systemTexts: ["contains system-only keyword"], + lastUserTexts: ["contains user-only keyword"], + }); + + const result = findMatchingKeywordRoutingRule([first, second], texts, null); + + expect(result?.rule).toBe(first); + expect(result?.matchedIn).toBe("user"); + }); }); describe("规则有效性防御", () => { @@ -151,6 +165,20 @@ describe("findMatchingKeywordRoutingRule", () => { }); }); + it("中文关键词:大小写敏感与不敏感均能命中 CJK 文本", () => { + const texts = makeTexts({ lastUserTexts: ["以下是一段示例对话,请参考其中的格式"] }); + + const sensitiveRule = makeRule({ keyword: "示例对话", caseSensitive: true }); + const sensitiveResult = findMatchingKeywordRoutingRule([sensitiveRule], texts, null); + expect(sensitiveResult?.rule).toBe(sensitiveRule); + expect(sensitiveResult?.matchedIn).toBe("user"); + + const insensitiveRule = makeRule({ keyword: "示例对话", caseSensitive: false }); + const insensitiveResult = findMatchingKeywordRoutingRule([insensitiveRule], texts, null); + expect(insensitiveResult?.rule).toBe(insensitiveRule); + expect(insensitiveResult?.matchedIn).toBe("user"); + }); + describe("matchedIn 来源标记", () => { it("仅 systemTexts 命中时 matchedIn 为 system", () => { const rule = makeRule({ keyword: "shared" }); From ba33107df0accff1027a00e37559ddfa81a2286d Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 00:40:14 +0800 Subject: [PATCH 05/13] feat(lib): add keyword routing rule cache engine with warmup --- src/app/v1/[...route]/route.ts | 5 + src/lib/keyword-routing/engine.ts | 142 +++++++++++ tests/unit/lib/hot-reload-singleton.test.ts | 32 +++ tests/unit/lib/keyword-routing/engine.test.ts | 228 ++++++++++++++++++ .../repository/keyword-routing-events.test.ts | 71 +++++- 5 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 src/lib/keyword-routing/engine.ts create mode 100644 tests/unit/lib/keyword-routing/engine.test.ts diff --git a/src/app/v1/[...route]/route.ts b/src/app/v1/[...route]/route.ts index ec4c22c9f..0eadf9a09 100644 --- a/src/app/v1/[...route]/route.ts +++ b/src/app/v1/[...route]/route.ts @@ -8,6 +8,7 @@ import { handleOpenAICompatibleModels, } from "@/app/v1/_lib/models/available-models"; import { handleProxyRequest } from "@/app/v1/_lib/proxy-handler"; +import { keywordRoutingEngine } from "@/lib/keyword-routing/engine"; import { logger } from "@/lib/logger"; import { sensitiveWordDetector } from "@/lib/sensitive-word-detector"; import { SessionTracker } from "@/lib/session-tracker"; @@ -28,6 +29,10 @@ if (hasDsn) { sensitiveWordDetector.reload().catch((err) => { logger.error("[App] SensitiveWordDetector initialization failed:", err); }); + // 关键词路由为非关键能力:预热失败仅记录日志,绝不阻断启动 + keywordRoutingEngine.reload().catch((err) => { + logger.error("[App] KeywordRoutingEngine initialization failed:", err); + }); } else if (canSkipDsnWarmup) { logger.info("[App] SensitiveWordDetector warmup skipped: DSN not configured"); } else { diff --git a/src/lib/keyword-routing/engine.ts b/src/lib/keyword-routing/engine.ts new file mode 100644 index 000000000..2a9b19596 --- /dev/null +++ b/src/lib/keyword-routing/engine.ts @@ -0,0 +1,142 @@ +/** + * 关键词路由规则缓存引擎 + * + * 特性: + * - 内存缓存全部启用规则(priority 升序、id 升序,与匹配器评估顺序一致) + * - 单例模式,全局复用 + * - 支持热重载(本地 eventEmitter + Redis pub/sub) + * + * 注意:与敏感词引擎保持一致,构造时不主动加载规则, + * 由 route.ts 模块初始化阶段统一预热(warmup) + */ + +import { + findMatchingKeywordRoutingRule, + type KeywordRoutingMatch, +} from "@/lib/keyword-routing/matcher"; +import { logger } from "@/lib/logger"; +import type { KeywordRoutingScanTexts } from "@/lib/message-extractor"; +import { + getActiveKeywordRoutingRules, + type KeywordRoutingRule, +} from "@/repository/keyword-routing-rules"; + +class KeywordRoutingRuleCache { + private rules: KeywordRoutingRule[] = []; + private lastReloadTime = 0; + private isLoading = false; + + private eventEmitterCleanup: (() => void) | null = null; + private redisPubSubCleanup: (() => void) | null = null; + + constructor() { + this.setupEventListener(); + } + + private async setupEventListener(): Promise { + if (typeof process !== "undefined" && process.env.NEXT_RUNTIME !== "edge") { + try { + const { eventEmitter } = await import("@/lib/event-emitter"); + const handler = () => { + logger.info("[KeywordRoutingRuleCache] Received update event, reloading..."); + void this.reload(); + }; + eventEmitter.on("keywordRoutingRulesUpdated", handler); + logger.info("[KeywordRoutingRuleCache] Subscribed to local eventEmitter"); + + this.eventEmitterCleanup = () => { + eventEmitter.off("keywordRoutingRulesUpdated", handler); + }; + + try { + const { CHANNEL_KEYWORD_ROUTING_RULES_UPDATED, subscribeCacheInvalidation } = + await import("@/lib/redis/pubsub"); + const cleanup = await subscribeCacheInvalidation( + CHANNEL_KEYWORD_ROUTING_RULES_UPDATED, + handler + ); + if (cleanup) { + this.redisPubSubCleanup = cleanup; + logger.info("[KeywordRoutingRuleCache] Subscribed to Redis pub/sub channel"); + } + } catch (error) { + logger.warn("[KeywordRoutingRuleCache] Failed to subscribe to Redis pub/sub", { error }); + } + } catch (error) { + logger.warn("[KeywordRoutingRuleCache] Failed to setup event listener", { error }); + } + } + } + + destroy(): void { + this.eventEmitterCleanup?.(); + this.eventEmitterCleanup = null; + + this.redisPubSubCleanup?.(); + this.redisPubSubCleanup = null; + } + + /** + * 从数据库重新加载关键词路由规则 + */ + async reload(): Promise { + if (this.isLoading) { + logger.warn("[KeywordRoutingRuleCache] Reload already in progress, skipping"); + return; + } + + this.isLoading = true; + + try { + logger.info("[KeywordRoutingRuleCache] Reloading keyword routing rules from database..."); + + const rules = await getActiveKeywordRoutingRules(); + + this.rules = rules; + this.lastReloadTime = Date.now(); + + logger.info(`[KeywordRoutingRuleCache] Loaded ${rules.length} keyword routing rules`); + } catch (error) { + logger.error("[KeywordRoutingRuleCache] Failed to reload keyword routing rules:", error); + // 失败时不清空现有缓存,保持降级可用 + } finally { + this.isLoading = false; + } + } + + /** + * 在扫描文本中查找首个命中的关键词路由规则 + * + * @param texts - 按来源分类的待扫描文本 + * @param requestedModel - 客户端请求的模型名(可能为 null) + * @returns 首个命中的规则及命中位置,未命中返回 null + */ + match(texts: KeywordRoutingScanTexts, requestedModel: string | null): KeywordRoutingMatch | null { + return findMatchingKeywordRoutingRule(this.rules, texts, requestedModel); + } + + /** + * 检查缓存是否为空 + */ + isEmpty(): boolean { + return this.rules.length === 0; + } + + /** + * 获取缓存统计信息 + */ + getStats() { + return { + ruleCount: this.rules.length, + lastReloadTime: this.lastReloadTime, + isLoading: this.isLoading, + }; + } +} + +// Use globalThis to guarantee a single instance across workers +const g = globalThis as unknown as { __CCH_KEYWORD_ROUTING_ENGINE__?: KeywordRoutingRuleCache }; +if (!g.__CCH_KEYWORD_ROUTING_ENGINE__) { + g.__CCH_KEYWORD_ROUTING_ENGINE__ = new KeywordRoutingRuleCache(); +} +export const keywordRoutingEngine = g.__CCH_KEYWORD_ROUTING_ENGINE__; diff --git a/tests/unit/lib/hot-reload-singleton.test.ts b/tests/unit/lib/hot-reload-singleton.test.ts index 1990bcce1..66837562a 100644 --- a/tests/unit/lib/hot-reload-singleton.test.ts +++ b/tests/unit/lib/hot-reload-singleton.test.ts @@ -17,6 +17,7 @@ describe("globalThis singleton pattern", () => { delete g.__CCH_EVENT_EMITTER__; delete g.__CCH_REQUEST_FILTER_ENGINE__; delete g.__CCH_SENSITIVE_WORD_DETECTOR__; + delete g.__CCH_KEYWORD_ROUTING_ENGINE__; }); test("eventEmitter: multiple imports return same instance", async () => { @@ -93,6 +94,31 @@ describe("globalThis singleton pattern", () => { const { sensitiveWordDetector } = await import("@/lib/sensitive-word-detector"); expect(g.__CCH_SENSITIVE_WORD_DETECTOR__).toBe(sensitiveWordDetector); }); + + test("keywordRoutingEngine: multiple imports return same instance", async () => { + // First import + const { keywordRoutingEngine: engine1 } = await import("@/lib/keyword-routing/engine"); + + // Reset module cache + vi.resetModules(); + + // Second import + const { keywordRoutingEngine: engine2 } = await import("@/lib/keyword-routing/engine"); + + // Should be the exact same instance + expect(engine1).toBe(engine2); + }); + + test("keywordRoutingEngine: globalThis stores the singleton", async () => { + const g = globalThis as Record; + + // Before import, should not exist + expect(g.__CCH_KEYWORD_ROUTING_ENGINE__).toBeUndefined(); + + // After import, should exist + const { keywordRoutingEngine } = await import("@/lib/keyword-routing/engine"); + expect(g.__CCH_KEYWORD_ROUTING_ENGINE__).toBe(keywordRoutingEngine); + }); }); describe("event propagation between singleton instances", () => { @@ -106,6 +132,7 @@ describe("event propagation between singleton instances", () => { delete g.__CCH_EVENT_EMITTER__; delete g.__CCH_REQUEST_FILTER_ENGINE__; delete g.__CCH_SENSITIVE_WORD_DETECTOR__; + delete g.__CCH_KEYWORD_ROUTING_ENGINE__; }); afterEach(() => { @@ -114,6 +141,7 @@ describe("event propagation between singleton instances", () => { delete g.__CCH_EVENT_EMITTER__; delete g.__CCH_REQUEST_FILTER_ENGINE__; delete g.__CCH_SENSITIVE_WORD_DETECTOR__; + delete g.__CCH_KEYWORD_ROUTING_ENGINE__; }); test("events emitted in one context should be received in another", async () => { @@ -139,6 +167,7 @@ describe("event propagation between singleton instances", () => { errorRules: vi.fn(), sensitiveWords: vi.fn(), requestFilters: vi.fn(), + keywordRoutingRules: vi.fn(), }; // Subscribe in context A @@ -146,6 +175,7 @@ describe("event propagation between singleton instances", () => { emitterA.on("errorRulesUpdated", handlers.errorRules); emitterA.on("sensitiveWordsUpdated", handlers.sensitiveWords); emitterA.on("requestFiltersUpdated", handlers.requestFilters); + emitterA.on("keywordRoutingRulesUpdated", handlers.keywordRoutingRules); vi.resetModules(); @@ -154,9 +184,11 @@ describe("event propagation between singleton instances", () => { emitterB.emitErrorRulesUpdated(); emitterB.emitSensitiveWordsUpdated(); emitterB.emitRequestFiltersUpdated(); + emitterB.emitKeywordRoutingRulesUpdated(); expect(handlers.errorRules).toHaveBeenCalledTimes(1); expect(handlers.sensitiveWords).toHaveBeenCalledTimes(1); expect(handlers.requestFilters).toHaveBeenCalledTimes(1); + expect(handlers.keywordRoutingRules).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/lib/keyword-routing/engine.test.ts b/tests/unit/lib/keyword-routing/engine.test.ts new file mode 100644 index 000000000..06836e35e --- /dev/null +++ b/tests/unit/lib/keyword-routing/engine.test.ts @@ -0,0 +1,228 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { KeywordRoutingScanTexts } from "@/lib/message-extractor"; +import type { KeywordRoutingRule } from "@/repository/keyword-routing-rules"; + +const mocks = vi.hoisted(() => { + const listeners = new Map void>>(); + + return { + getActiveKeywordRoutingRules: vi.fn(), + subscribeCacheInvalidation: vi.fn(async () => undefined), + eventEmitter: { + on(event: string, handler: (...args: unknown[]) => void) { + const current = listeners.get(event) ?? new Set<(...args: unknown[]) => void>(); + current.add(handler); + listeners.set(event, current); + }, + off(event: string, handler: (...args: unknown[]) => void) { + listeners.get(event)?.delete(handler); + }, + emit(event: string, ...args: unknown[]) { + for (const handler of listeners.get(event) ?? []) { + handler(...args); + } + }, + removeAllListeners() { + listeners.clear(); + }, + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, + }; +}); + +vi.mock("@/repository/keyword-routing-rules", () => ({ + getActiveKeywordRoutingRules: mocks.getActiveKeywordRoutingRules, +})); + +vi.mock("@/lib/event-emitter", () => ({ + eventEmitter: mocks.eventEmitter, +})); + +vi.mock("@/lib/redis/pubsub", () => ({ + CHANNEL_KEYWORD_ROUTING_RULES_UPDATED: "keywordRoutingRulesUpdated", + subscribeCacheInvalidation: mocks.subscribeCacheInvalidation, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mocks.logger, +})); + +let nextRuleId = 1; + +/** 构建测试规则的工厂函数(id 自增,提供合理默认值) */ +function makeRule(overrides: Partial = {}): KeywordRoutingRule { + const now = new Date("2026-06-01T00:00:00.000Z"); + return { + id: nextRuleId++, + keyword: "EXAMPLE DIALOGE", + sourceModel: null, + targetModel: "claude-haiku-4-5", + caseSensitive: true, + priority: 0, + description: null, + isEnabled: true, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +/** 构建扫描文本的便捷函数 */ +function makeTexts(overrides: Partial = {}): KeywordRoutingScanTexts { + return { + systemTexts: [], + lastUserTexts: [], + ...overrides, + }; +} + +/** 导入全新的引擎单例(先清理 globalThis 缓存) */ +async function importFreshEngine() { + const { keywordRoutingEngine } = await import("@/lib/keyword-routing/engine"); + // 等待构造函数中异步的事件监听注册完成 + await new Promise((resolve) => setTimeout(resolve, 0)); + return keywordRoutingEngine; +} + +describe("KeywordRoutingRuleCache (engine)", () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.eventEmitter.removeAllListeners(); + // 引擎是 globalThis 单例,可跨 resetModules 存活;删除后下个测试 + // 重新导入会构造新实例并重新订阅 mocks.eventEmitter + delete (globalThis as Record).__CCH_KEYWORD_ROUTING_ENGINE__; + nextRuleId = 1; + }); + + test("reload() populates rules from repository; match() delegates to matcher", async () => { + const rule = makeRule({ keyword: "magic-token" }); + mocks.getActiveKeywordRoutingRules.mockResolvedValueOnce([rule]); + + const engine = await importFreshEngine(); + await engine.reload(); + + expect(mocks.getActiveKeywordRoutingRules).toHaveBeenCalledTimes(1); + + // 正向:关键词命中 lastUserTexts + const hit = engine.match(makeTexts({ lastUserTexts: ["please use magic-token here"] }), null); + expect(hit?.rule).toBe(rule); + expect(hit?.matchedIn).toBe("user"); + + // 反向:无关键词命中 + const miss = engine.match(makeTexts({ lastUserTexts: ["nothing relevant"] }), null); + expect(miss).toBeNull(); + }); + + test("reload() failure keeps previous rules and lastReloadTime, logs error", async () => { + const rule = makeRule({ keyword: "magic-token" }); + mocks.getActiveKeywordRoutingRules + .mockResolvedValueOnce([rule]) + .mockRejectedValueOnce(new Error("db down")); + + const engine = await importFreshEngine(); + await engine.reload(); + const statsAfterSuccess = engine.getStats(); + + await engine.reload(); // 第二次 reload 失败 + + expect(mocks.logger.error).toHaveBeenCalledWith( + "[KeywordRoutingRuleCache] Failed to reload keyword routing rules:", + expect.any(Error) + ); + + // 旧缓存保留,匹配仍可用(降级可用语义) + const hit = engine.match(makeTexts({ lastUserTexts: ["with magic-token inside"] }), null); + expect(hit?.rule).toBe(rule); + + // lastReloadTime 仅在成功时更新(与敏感词引擎语义一致) + const statsAfterFailure = engine.getStats(); + expect(statsAfterFailure.ruleCount).toBe(1); + expect(statsAfterFailure.lastReloadTime).toBe(statsAfterSuccess.lastReloadTime); + expect(statsAfterFailure.isLoading).toBe(false); + }); + + test("concurrent reload while loading is skipped (detector semantics)", async () => { + let resolveFirstLoad: ((value: KeywordRoutingRule[]) => void) | undefined; + mocks.getActiveKeywordRoutingRules.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ); + + const engine = await importFreshEngine(); + + const firstReload = engine.reload(); // 启动加载(挂起中) + await new Promise((resolve) => setTimeout(resolve, 0)); + const secondReload = engine.reload(); // isLoading 守卫直接跳过 + + await secondReload; + expect(mocks.logger.warn).toHaveBeenCalledWith( + "[KeywordRoutingRuleCache] Reload already in progress, skipping" + ); + + resolveFirstLoad?.([makeRule()]); + await firstReload; + + // 第二次 reload 被跳过,仓库只读了一次 + expect(mocks.getActiveKeywordRoutingRules).toHaveBeenCalledTimes(1); + expect(engine.getStats().ruleCount).toBe(1); + }); + + test("isEmpty() is true before load, false after; getStats() shape", async () => { + mocks.getActiveKeywordRoutingRules.mockResolvedValueOnce([makeRule(), makeRule()]); + + const engine = await importFreshEngine(); + + expect(engine.isEmpty()).toBe(true); + expect(engine.getStats()).toEqual({ + ruleCount: 0, + lastReloadTime: 0, + isLoading: false, + }); + + await engine.reload(); + + expect(engine.isEmpty()).toBe(false); + expect(engine.getStats()).toEqual({ + ruleCount: 2, + lastReloadTime: expect.any(Number), + isLoading: false, + }); + expect(engine.getStats().lastReloadTime).toBeGreaterThan(0); + }); + + test("local keywordRoutingRulesUpdated event triggers reload", async () => { + mocks.getActiveKeywordRoutingRules.mockResolvedValueOnce([makeRule()]); + + const engine = await importFreshEngine(); + expect(engine.isEmpty()).toBe(true); + + mocks.eventEmitter.emit("keywordRoutingRulesUpdated"); + // 等待事件触发的异步 reload 完成 + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.getActiveKeywordRoutingRules).toHaveBeenCalledTimes(1); + expect(engine.isEmpty()).toBe(false); + }); + + test("destroy() unsubscribes the local event handler", async () => { + mocks.getActiveKeywordRoutingRules.mockResolvedValue([makeRule()]); + + const engine = await importFreshEngine(); + engine.destroy(); + + mocks.eventEmitter.emit("keywordRoutingRulesUpdated"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.getActiveKeywordRoutingRules).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/repository/keyword-routing-events.test.ts b/tests/unit/repository/keyword-routing-events.test.ts index 41b8bb65d..d71a759ee 100644 --- a/tests/unit/repository/keyword-routing-events.test.ts +++ b/tests/unit/repository/keyword-routing-events.test.ts @@ -53,12 +53,16 @@ function createRow(overrides: Record = {}) { } function mockModules(db: unknown, emitKeywordRoutingRulesUpdated: ReturnType) { + const columns = { id: {}, priority: {}, isEnabled: {} }; vi.doMock("@/drizzle/db", () => ({ db })); vi.doMock("@/drizzle/schema", () => ({ - keywordRoutingRules: { id: {}, priority: {}, isEnabled: {} }, + keywordRoutingRules: columns, + })); + vi.doMock("drizzle-orm", () => ({ + eq: vi.fn((column: unknown, value: unknown) => ({ column, value })), })); - vi.doMock("drizzle-orm", () => ({ eq: vi.fn(() => ({})) })); vi.doMock("@/lib/emit-event", () => ({ emitKeywordRoutingRulesUpdated })); + return columns; } describe("Keyword routing rules repository events", () => { @@ -209,4 +213,67 @@ describe("Keyword routing rules repository events", () => { expect(deleted).toBe(false); expect(emitKeywordRoutingRulesUpdated).not.toHaveBeenCalled(); }); + + test("getActiveKeywordRoutingRules: should filter isEnabled=true, order by [priority, id] and map rows", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db } = createDbMock({ + insertReturning: [], + updateReturning: [], + deleteReturning: [], + }); + const row = createRow(); + db.query.keywordRoutingRules.findMany.mockResolvedValue([row]); + + const columns = mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + const rules = await repo.getActiveKeywordRoutingRules(); + + expect(db.query.keywordRoutingRules.findMany).toHaveBeenCalledWith({ + where: { column: columns.isEnabled, value: true }, + orderBy: [columns.priority, columns.id], + }); + expect(rules).toEqual([ + { + id: row.id, + keyword: row.keyword, + sourceModel: row.sourceModel, + targetModel: row.targetModel, + caseSensitive: row.caseSensitive, + priority: row.priority, + description: row.description, + isEnabled: row.isEnabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + ]); + }); + + test("getAllKeywordRoutingRules: should order by [priority, id] without isEnabled filter", async () => { + vi.resetModules(); + + const emitKeywordRoutingRulesUpdated = vi.fn(async () => undefined); + const { db } = createDbMock({ + insertReturning: [], + updateReturning: [], + deleteReturning: [], + }); + const enabledRow = createRow(); + const disabledRow = createRow({ id: 2, isEnabled: false }); + db.query.keywordRoutingRules.findMany.mockResolvedValue([enabledRow, disabledRow]); + + const columns = mockModules(db, emitKeywordRoutingRulesUpdated); + + const repo = await import("@/repository/keyword-routing-rules"); + const rules = await repo.getAllKeywordRoutingRules(); + + expect(db.query.keywordRoutingRules.findMany).toHaveBeenCalledWith({ + orderBy: [columns.priority, columns.id], + }); + expect(rules).toHaveLength(2); + expect(rules.map((rule) => rule.id)).toEqual([1, 2]); + expect(rules.map((rule) => rule.isEnabled)).toEqual([true, false]); + }); }); From 27e64504c492595dbb4a544ea38609e3e7458e16 Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 01:42:03 +0800 Subject: [PATCH 06/13] fix(lib): queue keyword routing reloads requested mid-flight and cover redis invalidation path --- src/app/v1/[...route]/route.ts | 4 +- src/lib/keyword-routing/engine.ts | 64 +++++++++---- tests/unit/lib/keyword-routing/engine.test.ts | 91 ++++++++++++++----- 3 files changed, 120 insertions(+), 39 deletions(-) diff --git a/src/app/v1/[...route]/route.ts b/src/app/v1/[...route]/route.ts index 0eadf9a09..99721ed68 100644 --- a/src/app/v1/[...route]/route.ts +++ b/src/app/v1/[...route]/route.ts @@ -34,7 +34,9 @@ if (hasDsn) { logger.error("[App] KeywordRoutingEngine initialization failed:", err); }); } else if (canSkipDsnWarmup) { - logger.info("[App] SensitiveWordDetector warmup skipped: DSN not configured"); + logger.info( + "[App] SensitiveWordDetector and KeywordRoutingEngine warmup skipped: DSN not configured" + ); } else { throw new Error("[App] DSN is required for SensitiveWordDetector warmup"); } diff --git a/src/lib/keyword-routing/engine.ts b/src/lib/keyword-routing/engine.ts index 2a9b19596..dfd9b35da 100644 --- a/src/lib/keyword-routing/engine.ts +++ b/src/lib/keyword-routing/engine.ts @@ -25,6 +25,8 @@ class KeywordRoutingRuleCache { private rules: KeywordRoutingRule[] = []; private lastReloadTime = 0; private isLoading = false; + private activeReloadPromise: Promise | null = null; // 合并并发 reload + private reloadRequestedWhileLoading = false; // reload 期间收到的补跑请求 private eventEmitterCleanup: (() => void) | null = null; private redisPubSubCleanup: (() => void) | null = null; @@ -78,30 +80,58 @@ class KeywordRoutingRuleCache { /** * 从数据库重新加载关键词路由规则 + * + * 并发语义(与 RequestFilterEngine 保持一致): + * - 已有 reload 在途时不丢弃本次请求,而是排队补跑一轮, + * 避免“管理端已保存规则但缓存永远没刷新”的丢更新窗口 + * (缓存无 TTL,失效完全依赖事件,丢一次就丢到下次重启)。 + * - queue=false 时直接复用在途 reload,供“事件已触发 reload 后再显式调用”的 + * 场景避免对同一次写入做两次冗余 DB 读。 */ - async reload(): Promise { - if (this.isLoading) { - logger.warn("[KeywordRoutingRuleCache] Reload already in progress, skipping"); - return; + async reload(queue = true): Promise { + if (this.activeReloadPromise) { + if (queue) { + this.reloadRequestedWhileLoading = true; + logger.info("[KeywordRoutingRuleCache] Reload already in progress, queueing another pass"); + } + return this.activeReloadPromise; } - this.isLoading = true; + const reloadLoop = (async () => { + do { + // reload 期间若又收到补跑请求,本轮结束后立刻再跑一轮,避免新规则落库却没进缓存。 + this.reloadRequestedWhileLoading = false; + this.isLoading = true; - try { - logger.info("[KeywordRoutingRuleCache] Reloading keyword routing rules from database..."); + try { + logger.info("[KeywordRoutingRuleCache] Reloading keyword routing rules from database..."); - const rules = await getActiveKeywordRoutingRules(); + const rules = await getActiveKeywordRoutingRules(); - this.rules = rules; - this.lastReloadTime = Date.now(); + this.rules = rules; + this.lastReloadTime = Date.now(); - logger.info(`[KeywordRoutingRuleCache] Loaded ${rules.length} keyword routing rules`); - } catch (error) { - logger.error("[KeywordRoutingRuleCache] Failed to reload keyword routing rules:", error); - // 失败时不清空现有缓存,保持降级可用 - } finally { - this.isLoading = false; - } + logger.info(`[KeywordRoutingRuleCache] Loaded ${rules.length} keyword routing rules`); + } catch (error) { + logger.error("[KeywordRoutingRuleCache] Failed to reload keyword routing rules:", error); + // 失败时不清空现有缓存,保持降级可用 + } finally { + this.isLoading = false; + } + } while (this.reloadRequestedWhileLoading); + })(); + + this.activeReloadPromise = reloadLoop.finally(() => { + // 极窄窗口:do/while 已判定无需继续,但 finally 微任务执行前又来了新请求, + // 此处再检查一次,避免晚到的补跑被静默吞掉。 + const shouldRestart = this.reloadRequestedWhileLoading; + this.activeReloadPromise = null; + if (shouldRestart) { + return this.reload(); + } + }); + + return this.activeReloadPromise; } /** diff --git a/tests/unit/lib/keyword-routing/engine.test.ts b/tests/unit/lib/keyword-routing/engine.test.ts index 06836e35e..5e6be42dc 100644 --- a/tests/unit/lib/keyword-routing/engine.test.ts +++ b/tests/unit/lib/keyword-routing/engine.test.ts @@ -5,9 +5,25 @@ import type { KeywordRoutingRule } from "@/repository/keyword-routing-rules"; const mocks = vi.hoisted(() => { const listeners = new Map void>>(); + // 捕获 Redis pub/sub 订阅参数,供失效通道与 destroy 清理路径的测试断言 + const redisSubscription: { + channel: string | null; + handler: (() => void) | null; + cleanup: ReturnType; + } = { + channel: null, + handler: null, + cleanup: vi.fn(), + }; + return { getActiveKeywordRoutingRules: vi.fn(), - subscribeCacheInvalidation: vi.fn(async () => undefined), + redisSubscription, + subscribeCacheInvalidation: vi.fn(async (channel: string, handler: () => void) => { + redisSubscription.channel = channel; + redisSubscription.handler = handler; + return redisSubscription.cleanup; + }), eventEmitter: { on(event: string, handler: (...args: unknown[]) => void) { const current = listeners.get(event) ?? new Set<(...args: unknown[]) => void>(); @@ -46,7 +62,7 @@ vi.mock("@/lib/event-emitter", () => ({ })); vi.mock("@/lib/redis/pubsub", () => ({ - CHANNEL_KEYWORD_ROUTING_RULES_UPDATED: "keywordRoutingRulesUpdated", + CHANNEL_KEYWORD_ROUTING_RULES_UPDATED: "cch:cache:keyword_routing_rules:updated", subscribeCacheInvalidation: mocks.subscribeCacheInvalidation, })); @@ -96,6 +112,9 @@ describe("KeywordRoutingRuleCache (engine)", () => { vi.resetModules(); vi.clearAllMocks(); mocks.eventEmitter.removeAllListeners(); + // 重置上一个测试捕获的 Redis 订阅参数(cleanup 的调用记录由 clearAllMocks 清除) + mocks.redisSubscription.channel = null; + mocks.redisSubscription.handler = null; // 引擎是 globalThis 单例,可跨 resetModules 存活;删除后下个测试 // 重新导入会构造新实例并重新订阅 mocks.eventEmitter delete (globalThis as Record).__CCH_KEYWORD_ROUTING_ENGINE__; @@ -149,32 +168,32 @@ describe("KeywordRoutingRuleCache (engine)", () => { expect(statsAfterFailure.isLoading).toBe(false); }); - test("concurrent reload while loading is skipped (detector semantics)", async () => { + test("a reload requested while another is in-flight is queued, not dropped", async () => { let resolveFirstLoad: ((value: KeywordRoutingRule[]) => void) | undefined; - mocks.getActiveKeywordRoutingRules.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirstLoad = resolve; - }) - ); + + // 第一次加载挂起并返回旧快照(1 条规则),第二次加载返回用户刚保存的新快照(2 条规则) + mocks.getActiveKeywordRoutingRules + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ) + .mockResolvedValueOnce([makeRule(), makeRule()]); const engine = await importFreshEngine(); const firstReload = engine.reload(); // 启动加载(挂起中) - await new Promise((resolve) => setTimeout(resolve, 0)); - const secondReload = engine.reload(); // isLoading 守卫直接跳过 - - await secondReload; - expect(mocks.logger.warn).toHaveBeenCalledWith( - "[KeywordRoutingRuleCache] Reload already in progress, skipping" - ); + const secondReload = engine.reload(); // 在途中再次请求 -> 排队补跑 + await new Promise((resolve) => setTimeout(resolve, 0)); resolveFirstLoad?.([makeRule()]); - await firstReload; + await Promise.all([firstReload, secondReload]); - // 第二次 reload 被跳过,仓库只读了一次 - expect(mocks.getActiveKeywordRoutingRules).toHaveBeenCalledTimes(1); - expect(engine.getStats().ruleCount).toBe(1); + // 在途中的 reload 请求不能被静默丢弃:仓库总共读取两次, + // 最终缓存反映最新快照(2 条规则)而非旧快照(1 条) + expect(mocks.getActiveKeywordRoutingRules).toHaveBeenCalledTimes(2); + expect(engine.getStats().ruleCount).toBe(2); }); test("isEmpty() is true before load, false after; getStats() shape", async () => { @@ -214,12 +233,42 @@ describe("KeywordRoutingRuleCache (engine)", () => { expect(engine.isEmpty()).toBe(false); }); - test("destroy() unsubscribes the local event handler", async () => { + test("engine subscribes to the keyword routing Redis invalidation channel", async () => { + mocks.getActiveKeywordRoutingRules.mockResolvedValue([makeRule()]); + + await importFreshEngine(); + + expect(mocks.subscribeCacheInvalidation).toHaveBeenCalledTimes(1); + expect(mocks.redisSubscription.channel).toBe("cch:cache:keyword_routing_rules:updated"); + expect(mocks.redisSubscription.handler).toBeTypeOf("function"); + }); + + test("Redis invalidation message triggers reload via the subscribed handler", async () => { + mocks.getActiveKeywordRoutingRules.mockResolvedValueOnce([makeRule()]); + + const engine = await importFreshEngine(); + expect(engine.isEmpty()).toBe(true); + + // 模拟收到 Redis 失效通知(跨进程路径,绕过本地 eventEmitter) + mocks.redisSubscription.handler?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.getActiveKeywordRoutingRules).toHaveBeenCalledTimes(1); + expect(engine.isEmpty()).toBe(false); + }); + + test("destroy() removes the local event listener and invokes the Redis cleanup", async () => { mocks.getActiveKeywordRoutingRules.mockResolvedValue([makeRule()]); const engine = await importFreshEngine(); + expect(mocks.redisSubscription.cleanup).not.toHaveBeenCalled(); + engine.destroy(); + // Redis 订阅清理函数被调用 + expect(mocks.redisSubscription.cleanup).toHaveBeenCalledTimes(1); + + // 本地事件监听已移除:再发事件不会触发 reload mocks.eventEmitter.emit("keywordRoutingRulesUpdated"); await new Promise((resolve) => setTimeout(resolve, 0)); From 9d610ba1369324b9823d3551a0a5ace5a283f130 Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 02:13:08 +0800 Subject: [PATCH 07/13] feat(settings): wire enableKeywordModelRouting through system settings stack --- src/actions/system-config.ts | 2 + src/app/api/admin/system-config/route.ts | 1 + src/lib/api-client/v1/openapi-types.gen.ts | 6 ++ src/lib/api/v1/schemas/system-config.ts | 3 + src/lib/config/index.ts | 1 + src/lib/config/system-settings-cache.ts | 14 ++++ src/lib/validation/schemas.ts | 2 + src/repository/_shared/transformers.test.ts | 1 + src/repository/_shared/transformers.ts | 1 + src/repository/system-config.ts | 13 ++++ src/types/system-config.ts | 7 ++ tests/api/v1/system/system-config.test.ts | 1 + tests/unit/actions/system-config-save.test.ts | 13 ++++ .../lib/config/system-settings-cache.test.ts | 12 +++ .../system-config-degradation-ladder.test.ts | 34 +++++---- ...stem-config-update-missing-columns.test.ts | 75 +++++++++++++++++-- 16 files changed, 163 insertions(+), 23 deletions(-) diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index c40a7302d..2f571355c 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -65,6 +65,7 @@ export async function saveSystemSettings(formData: { cleanupSchedule?: string; cleanupBatchSize?: number; enableClientVersionCheck?: boolean; + enableKeywordModelRouting?: boolean; verboseProviderError?: boolean; passThroughUpstreamErrorMessage?: boolean; enableHttp2?: boolean; @@ -118,6 +119,7 @@ export async function saveSystemSettings(formData: { cleanupSchedule: validated.cleanupSchedule, cleanupBatchSize: validated.cleanupBatchSize, enableClientVersionCheck: validated.enableClientVersionCheck, + enableKeywordModelRouting: validated.enableKeywordModelRouting, verboseProviderError: validated.verboseProviderError, passThroughUpstreamErrorMessage: validated.passThroughUpstreamErrorMessage, enableHttp2: validated.enableHttp2, diff --git a/src/app/api/admin/system-config/route.ts b/src/app/api/admin/system-config/route.ts index 93cc54a68..722410ddb 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -68,6 +68,7 @@ export async function POST(req: Request) { cleanupSchedule: validated.cleanupSchedule, cleanupBatchSize: validated.cleanupBatchSize, enableClientVersionCheck: validated.enableClientVersionCheck, + enableKeywordModelRouting: validated.enableKeywordModelRouting, verboseProviderError: validated.verboseProviderError, passThroughUpstreamErrorMessage: validated.passThroughUpstreamErrorMessage, enableHttp2: validated.enableHttp2, diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index ef097e7bf..2f45e3612 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -11659,6 +11659,8 @@ export interface operations { cleanupBatchSize?: number; /** @description Whether client version checks are enabled. */ enableClientVersionCheck: boolean; + /** @description Whether keyword-based model routing is enabled. */ + enableKeywordModelRouting: boolean; /** @description Whether provider errors include extra diagnostics. */ verboseProviderError: boolean; /** @description Whether sanitized upstream error messages are passed through. */ @@ -11919,6 +11921,8 @@ export interface operations { cleanupBatchSize?: number; /** @description Whether client version checks are enabled. */ enableClientVersionCheck?: boolean; + /** @description Whether keyword-based model routing is enabled. */ + enableKeywordModelRouting?: boolean; /** @description Whether provider errors include extra diagnostics. */ verboseProviderError?: boolean; /** @description Whether sanitized upstream error messages are passed through. */ @@ -12052,6 +12056,8 @@ export interface operations { cleanupBatchSize?: number; /** @description Whether client version checks are enabled. */ enableClientVersionCheck: boolean; + /** @description Whether keyword-based model routing is enabled. */ + enableKeywordModelRouting: boolean; /** @description Whether provider errors include extra diagnostics. */ verboseProviderError: boolean; /** @description Whether sanitized upstream error messages are passed through. */ diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index 944699f0c..301d5385a 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -106,6 +106,9 @@ export const SystemSettingsSchema = z cleanupSchedule: z.string().optional().describe("Cleanup cron schedule."), cleanupBatchSize: z.number().int().optional().describe("Cleanup batch size."), enableClientVersionCheck: z.boolean().describe("Whether client version checks are enabled."), + enableKeywordModelRouting: z + .boolean() + .describe("Whether keyword-based model routing is enabled."), verboseProviderError: z .boolean() .describe("Whether provider errors include extra diagnostics."), diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index f27619049..7fa7fd801 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -9,4 +9,5 @@ export { getCachedSystemSettingsOnlyCache, invalidateSystemSettingsCache, isHttp2Enabled, + isKeywordModelRoutingEnabled, } from "./system-settings-cache"; diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 2b4043460..708ab9f4d 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -48,6 +48,7 @@ const DEFAULT_SETTINGS: Pick< | "fakeStreamingWhitelist" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" + | "enableKeywordModelRouting" | "enableResponseFixer" | "responseFixerConfig" | "passThroughUpstreamErrorMessage" @@ -71,6 +72,8 @@ const DEFAULT_SETTINGS: Pick< fakeStreamingWhitelist: [], enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, + // 关键词模型路由在冷缓存 / DB 读取失败时 fail-closed,避免在不确定状态下重写请求模型。 + enableKeywordModelRouting: false, enableResponseFixer: true, passThroughUpstreamErrorMessage: true, responseFixerConfig: { @@ -144,6 +147,7 @@ export async function getCachedSystemSettings(): Promise { cleanupSchedule: "0 2 * * *", cleanupBatchSize: 10000, enableClientVersionCheck: false, + enableKeywordModelRouting: DEFAULT_SETTINGS.enableKeywordModelRouting, enableHttp2: DEFAULT_SETTINGS.enableHttp2, enableOpenaiResponsesWebsocket: DEFAULT_SETTINGS.enableOpenaiResponsesWebsocket, enableHighConcurrencyMode: DEFAULT_SETTINGS.enableHighConcurrencyMode, @@ -198,6 +202,16 @@ export async function isOpenaiResponsesWebsocketEnabled(): Promise { return settings.enableOpenaiResponsesWebsocket; } +/** + * Get only the keyword model routing enabled setting (optimized for proxy path) + * + * @returns Whether keyword-based model routing is enabled + */ +export async function isKeywordModelRoutingEnabled(): Promise { + const settings = await getCachedSystemSettings(); + return settings.enableKeywordModelRouting; +} + /** * Invalidate the settings cache * diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 5640440ff..c5b14dc6e 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -985,6 +985,8 @@ export const UpdateSystemSettingsSchema = z.object({ .optional(), // 客户端版本检查配置(可选) enableClientVersionCheck: z.boolean().optional(), + // 关键词模型路由配置(可选) + enableKeywordModelRouting: z.boolean().optional(), // 供应商不可用时是否返回详细错误信息(可选) verboseProviderError: z.boolean().optional(), // 标准代理错误响应是否透传安全脱敏后的上游错误 message(可选) diff --git a/src/repository/_shared/transformers.test.ts b/src/repository/_shared/transformers.test.ts index 69ac7e8cb..e7ed6dd6b 100644 --- a/src/repository/_shared/transformers.test.ts +++ b/src/repository/_shared/transformers.test.ts @@ -284,6 +284,7 @@ describe("src/repository/_shared/transformers.ts", () => { expect(result.cleanupSchedule).toBe("0 2 * * *"); expect(result.cleanupBatchSize).toBe(10000); expect(result.enableClientVersionCheck).toBe(false); + expect(result.enableKeywordModelRouting).toBe(false); expect(result.verboseProviderError).toBe(false); expect(result.passThroughUpstreamErrorMessage).toBe(true); expect(result.enableHttp2).toBe(false); diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 353b5e5d6..adee370d0 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -258,6 +258,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { cleanupSchedule: dbSettings?.cleanupSchedule ?? "0 2 * * *", cleanupBatchSize: dbSettings?.cleanupBatchSize ?? 10000, enableClientVersionCheck: dbSettings?.enableClientVersionCheck ?? false, + enableKeywordModelRouting: dbSettings?.enableKeywordModelRouting ?? false, verboseProviderError: dbSettings?.verboseProviderError ?? false, passThroughUpstreamErrorMessage: dbSettings?.passThroughUpstreamErrorMessage ?? true, enableHttp2: dbSettings?.enableHttp2 ?? false, diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index a5225e7b9..431c847a2 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -156,6 +156,7 @@ function createFallbackSettings(): SystemSettings { cleanupSchedule: "0 2 * * *", cleanupBatchSize: 10000, enableClientVersionCheck: false, + enableKeywordModelRouting: false, verboseProviderError: false, passThroughUpstreamErrorMessage: true, enableHttp2: false, @@ -263,6 +264,13 @@ const RECENT_COLUMN_LADDER: ReadonlyArray<{ // 本层更新失败(仍有列缺失)时记录的告警 updateWarn: string; }> = [ + { + key: "enableKeywordModelRouting", + column: systemSettings.enableKeywordModelRouting, + selectWarn: + "system_settings 表除 enableKeywordModelRouting 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: "system_settings 表除 enableKeywordModelRouting 外仍有列缺失,继续降级更新。", + }, { key: "enableThinkingEffortConflictRectifier", column: systemSettings.enableThinkingEffortConflictRectifier, @@ -636,6 +644,11 @@ export async function updateSystemSettings( updates.enableClientVersionCheck = payload.enableClientVersionCheck; } + // 关键词模型路由开关(如果提供) + if (payload.enableKeywordModelRouting !== undefined) { + updates.enableKeywordModelRouting = payload.enableKeywordModelRouting; + } + // 供应商错误详情配置字段(如果提供) if (payload.verboseProviderError !== undefined) { updates.verboseProviderError = payload.verboseProviderError; diff --git a/src/types/system-config.ts b/src/types/system-config.ts index d0d168ed7..7724ec989 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -62,6 +62,10 @@ export interface SystemSettings { // 客户端版本检查配置 enableClientVersionCheck: boolean; + // 关键词模型路由(默认关闭) + // 开启后:当请求的 system 提示或最后一条用户消息命中配置的关键词时,在供应商选择前将请求模型重写为目标模型 + enableKeywordModelRouting: boolean; + // 供应商不可用时是否返回详细错误信息 verboseProviderError: boolean; @@ -177,6 +181,9 @@ export interface UpdateSystemSettingsInput { // 客户端版本检查配置(可选) enableClientVersionCheck?: boolean; + // 关键词模型路由(可选) + enableKeywordModelRouting?: boolean; + // 供应商不可用时是否返回详细错误信息(可选) verboseProviderError?: boolean; diff --git a/tests/api/v1/system/system-config.test.ts b/tests/api/v1/system/system-config.test.ts index dfd4b5fc2..99b575601 100644 --- a/tests/api/v1/system/system-config.test.ts +++ b/tests/api/v1/system/system-config.test.ts @@ -48,6 +48,7 @@ const settings: SystemSettings = { cleanupSchedule: "0 2 * * *", cleanupBatchSize: 10000, enableClientVersionCheck: true, + enableKeywordModelRouting: false, verboseProviderError: false, passThroughUpstreamErrorMessage: true, enableHttp2: false, diff --git a/tests/unit/actions/system-config-save.test.ts b/tests/unit/actions/system-config-save.test.ts index a77a8f91f..5a9c9938b 100644 --- a/tests/unit/actions/system-config-save.test.ts +++ b/tests/unit/actions/system-config-save.test.ts @@ -364,4 +364,17 @@ describe("saveSystemSettings", () => { }) ); }); + + it("should pass enableKeywordModelRouting through validation and save", async () => { + const result = await saveSystemSettings({ + enableKeywordModelRouting: true, + }); + + expect(result.ok).toBe(true); + expect(updateSystemSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + enableKeywordModelRouting: true, + }) + ); + }); }); diff --git a/tests/unit/lib/config/system-settings-cache.test.ts b/tests/unit/lib/config/system-settings-cache.test.ts index 8bea01ee3..96987ebf6 100644 --- a/tests/unit/lib/config/system-settings-cache.test.ts +++ b/tests/unit/lib/config/system-settings-cache.test.ts @@ -37,6 +37,7 @@ function createSettings(overrides: Partial = {}): SystemSettings cleanupSchedule: "0 2 * * *", cleanupBatchSize: 10000, enableClientVersionCheck: false, + enableKeywordModelRouting: false, verboseProviderError: false, passThroughUpstreamErrorMessage: true, enableHttp2: false, @@ -73,6 +74,7 @@ async function loadCache() { return { getCachedSystemSettings: mod.getCachedSystemSettings, isHttp2Enabled: mod.isHttp2Enabled, + isKeywordModelRoutingEnabled: mod.isKeywordModelRoutingEnabled, invalidateSystemSettingsCache: mod.invalidateSystemSettingsCache, }; } @@ -149,6 +151,7 @@ describe("SystemSettingsCache", () => { enableHttp2: false, enableHighConcurrencyMode: false, interceptAnthropicWarmupRequests: false, + enableKeywordModelRouting: false, codexPriorityBillingSource: "requested", passThroughUpstreamErrorMessage: true, }) @@ -178,4 +181,13 @@ describe("SystemSettingsCache", () => { expect(await isHttp2Enabled()).toBe(true); }); + + test("isKeywordModelRoutingEnabled 应读取缓存并返回 enableKeywordModelRouting", async () => { + getSystemSettingsMock.mockResolvedValueOnce( + createSettings({ id: 601, enableKeywordModelRouting: true }) + ); + const { isKeywordModelRoutingEnabled } = await loadCache(); + + expect(await isKeywordModelRoutingEnabled()).toBe(true); + }); }); diff --git a/tests/unit/repository/system-config-degradation-ladder.test.ts b/tests/unit/repository/system-config-degradation-ladder.test.ts index 15afff84f..c4f58f1f8 100644 --- a/tests/unit/repository/system-config-degradation-ladder.test.ts +++ b/tests/unit/repository/system-config-degradation-ladder.test.ts @@ -7,6 +7,7 @@ import type { UpdateSystemSettingsInput } from "@/types/system-config"; // 近代新增列(最新在前),降级链按引入顺序逐层累计剥离。 const RECENT_COLUMNS = [ + "enableKeywordModelRouting", "enableThinkingEffortConflictRectifier", "billHedgeLosers", "billNonSuccessfulRequests", @@ -15,8 +16,9 @@ const RECENT_COLUMNS = [ "allowNonConversationEndpointProviderFallback", ] as const; -// 全量字段集(43 列)。 +// 全量字段集(44 列)。 const FULL_COLUMNS = [ + "enableKeywordModelRouting", "billHedgeLosers", "billNonSuccessfulRequests", "passThroughUpstreamErrorMessage", @@ -122,7 +124,7 @@ function createResolvingSelectQuery(rows: unknown[]) { } describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { - test("getSystemSettings 全部列缺失时按既定顺序尝试 11 套字段集", async () => { + test("getSystemSettings 全部列缺失时按既定顺序尝试 12 套字段集", async () => { vi.resetModules(); const selections: string[][] = []; @@ -165,7 +167,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const selectMock = vi.fn((selection: Record) => { selections.push(sortedKeys(selection)); callIndex += 1; - if (callIndex < 8) { + if (callIndex < 9) { return createRejectingSelectQuery({ code: "42703" }); } return createResolvingSelectQuery([ @@ -198,14 +200,14 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const result = await getSystemSettings(); - expect(selectMock).toHaveBeenCalledTimes(8); - // 第 7 次(近代链末层)不含这两列;第 8 次(passThrough 世代)重新包含。 - expect(selections[6]).not.toContain("enableThinkingEffortConflictRectifier"); - expect(selections[6]).not.toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[6]).toContain("passThroughUpstreamErrorMessage"); - expect(selections[7]).toContain("enableThinkingEffortConflictRectifier"); - expect(selections[7]).toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[7]).not.toContain("passThroughUpstreamErrorMessage"); + expect(selectMock).toHaveBeenCalledTimes(9); + // 第 8 次(近代链末层)不含这两列;第 9 次(passThrough 世代)重新包含。 + expect(selections[7]).not.toContain("enableThinkingEffortConflictRectifier"); + expect(selections[7]).not.toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[7]).toContain("passThroughUpstreamErrorMessage"); + expect(selections[8]).toContain("enableThinkingEffortConflictRectifier"); + expect(selections[8]).toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[8]).not.toContain("passThroughUpstreamErrorMessage"); // 世代字段集选出的真实值要透传,缺失列由 transformer 落默认值。 expect(result.siteTitle).toBe("Era Row"); @@ -216,7 +218,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { expect(result.passThroughUpstreamErrorMessage).toBe(true); }); - test("updateSystemSettings 全部列缺失时按既定顺序尝试 10 套 set/returning 组合", async () => { + test("updateSystemSettings 全部列缺失时按既定顺序尝试 11 套 set/returning 组合", async () => { vi.resetModules(); const now = new Date("2026-01-04T00:00:00.000Z"); @@ -270,6 +272,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { enableOpenaiResponsesWebsocket: false, enableHighConcurrencyMode: true, enableThinkingEffortConflictRectifier: false, + enableKeywordModelRouting: true, allowNonConversationEndpointProviderFallback: false, fakeStreamingWhitelist: [], publicStatusWindowHours: 48, @@ -282,7 +285,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { "system_settings 表列缺失,请执行数据库迁移以升级数据库结构。" ); - expect(updateMock).toHaveBeenCalledTimes(10); + expect(updateMock).toHaveBeenCalledTimes(11); const expectedReturningSequence = [ [...FULL_COLUMNS], @@ -303,6 +306,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { "enableOpenaiResponsesWebsocket", "enableHighConcurrencyMode", "enableThinkingEffortConflictRectifier", + "enableKeywordModelRouting", "allowNonConversationEndpointProviderFallback", "fakeStreamingWhitelist", "publicStatusWindowHours", @@ -350,7 +354,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { let updateCallIndex = 0; const updateMock = vi.fn(() => { updateCallIndex += 1; - const shouldResolve = updateCallIndex === 9; + const shouldResolve = updateCallIndex === 10; const query: Record = {}; query.set = vi.fn(() => query); query.where = vi.fn(() => query); @@ -389,7 +393,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { codexPriorityBillingSource: "actual", }); - expect(updateMock).toHaveBeenCalledTimes(9); + expect(updateMock).toHaveBeenCalledTimes(10); 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 addafccb0..cf059dab1 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_thinking_effort_conflict_rectifier 新列时应降级读取并默认开启", async () => { + test("getSystemSettings 在仅缺 enable_keyword_model_routing 新列时应降级读取并默认关闭", 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(selectionWithoutEffortConflict) 命中——验证新列已加入降级链最外层。 + // 第二次 select(selectionWithoutKeywordModelRouting) 命中——验证新列已加入降级链最外层。 const selectMock = vi .fn() .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) @@ -313,8 +313,7 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { billingModelSource: "original", codexPriorityBillingSource: "requested", enableHttp2: true, - enableThinkingSignatureRectifier: true, - enableThinkingBudgetRectifier: true, + enableThinkingEffortConflictRectifier: true, createdAt: now, updatedAt: now, }, @@ -334,16 +333,76 @@ 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.enableKeywordModelRouting).toBe(false); // 关键回归保护:第二次 select 必须恰好剥离了新列(最外层降级), - // 而非旧行为先剥离 billHedgeLosers。若新列未加入降级链最外层,下面两条断言会失败。 + // 而非旧行为先剥离 enableThinkingEffortConflictRectifier。若新列未加入降级链最外层,下面两条断言会失败。 const secondSelection = selectMock.mock.calls[1]?.[0] as Record; - expect(secondSelection).not.toHaveProperty("enableThinkingEffortConflictRectifier"); - expect(secondSelection).toHaveProperty("billHedgeLosers"); + expect(secondSelection).not.toHaveProperty("enableKeywordModelRouting"); + expect(secondSelection).toHaveProperty("enableThinkingEffortConflictRectifier"); + + vi.useRealTimers(); + }); + + test("getSystemSettings 在仅缺 enable_thinking_effort_conflict_rectifier 新列时应降级读取并默认开启", async () => { + vi.resetModules(); + + const now = new Date("2026-01-04T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + // 第一次 select(fullSelection) 与第二次(仅剥离 enableKeywordModelRouting)均因列缺失而抛 42703; + // 第三次(继续剥离 enableThinkingEffortConflictRectifier)命中——验证该列位于降级链第二层。 + const selectMock = vi + .fn() + .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) + .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) + .mockReturnValueOnce( + createThenableQuery([ + { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + codexPriorityBillingSource: "requested", + enableHttp2: true, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + createdAt: now, + updatedAt: now, + }, + ]) + ); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: vi.fn(() => createThenableQuery([])), + insert: vi.fn(() => createThenableQuery([])), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { getSystemSettings } = await import("@/repository/system-config"); + + const result = await getSystemSettings(); + + // 降级读取成功(未抛错)。 + expect(selectMock).toHaveBeenCalledTimes(3); + expect(result.siteTitle).toBe("Claude Code Hub"); + expect(result.enableHttp2).toBe(true); + + // 关键回归保护:第三次 select 必须恰好累计剥离了最外两层新列, + // 而非提前剥离 billHedgeLosers。若降级顺序被改变,下面的断言会失败。 + const thirdSelection = selectMock.mock.calls[2]?.[0] as Record; + expect(thirdSelection).not.toHaveProperty("enableKeywordModelRouting"); + expect(thirdSelection).not.toHaveProperty("enableThinkingEffortConflictRectifier"); + expect(thirdSelection).toHaveProperty("billHedgeLosers"); vi.useRealTimers(); }); From b7d924ef652bfcb8163f2d898e1fb088a91bf39a Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 02:43:16 +0800 Subject: [PATCH 08/13] feat(proxy): add keyword routing guard rewriting model before provider selection --- src/app/v1/_lib/proxy/guard-pipeline.ts | 9 + .../v1/_lib/proxy/keyword-routing-guard.ts | 107 +++++++ src/app/v1/_lib/proxy/session.ts | 28 ++ src/types/message.ts | 9 + .../guard-pipeline-keyword-routing.test.ts | 30 ++ .../unit/proxy/keyword-routing-guard.test.ts | 265 ++++++++++++++++++ 6 files changed, 448 insertions(+) create mode 100644 src/app/v1/_lib/proxy/keyword-routing-guard.ts create mode 100644 tests/unit/proxy/guard-pipeline-keyword-routing.test.ts create mode 100644 tests/unit/proxy/keyword-routing-guard.test.ts diff --git a/src/app/v1/_lib/proxy/guard-pipeline.ts b/src/app/v1/_lib/proxy/guard-pipeline.ts index d372f9df1..1c3ade9c6 100644 --- a/src/app/v1/_lib/proxy/guard-pipeline.ts +++ b/src/app/v1/_lib/proxy/guard-pipeline.ts @@ -1,6 +1,7 @@ import { ProxyAuthenticator } from "./auth-guard"; import { ProxyClientGuard } from "./client-guard"; import type { EndpointPolicy } from "./endpoint-policy"; +import { ProxyKeywordRoutingGuard } from "./keyword-routing-guard"; import { ProxyMessageService } from "./message-service"; import { ProxyModelGuard } from "./model-guard"; import { ProxyProviderRequestFilter } from "./provider-request-filter"; @@ -35,6 +36,7 @@ export type GuardStepKey = | "session" | "warmup" | "requestFilter" + | "keywordRouting" | "sensitive" | "rateLimit" | "provider" @@ -107,6 +109,12 @@ const Steps: Record = { return null; }, }, + keywordRouting: { + name: "keywordRouting", + async execute(session) { + return ProxyKeywordRoutingGuard.ensure(session); + }, + }, sensitive: { name: "sensitive", async execute(session) { @@ -209,6 +217,7 @@ export const CHAT_PIPELINE: GuardConfig = { "session", "warmup", "requestFilter", + "keywordRouting", "rateLimit", "provider", "providerRequestFilter", diff --git a/src/app/v1/_lib/proxy/keyword-routing-guard.ts b/src/app/v1/_lib/proxy/keyword-routing-guard.ts new file mode 100644 index 000000000..ee87de1e9 --- /dev/null +++ b/src/app/v1/_lib/proxy/keyword-routing-guard.ts @@ -0,0 +1,107 @@ +/** + * 关键词路由守卫 + * + * 职责: + * - 扫描请求的系统提示词与最后一条用户消息,匹配关键词路由规则 + * - 命中规则时在供应商选择之前改写请求模型,使改写结果参与供应商路由决策 + * - 将审计信息记录到 session(决策链展示),保留用户原始请求模型 + * + * 调用时机: + * - requestFilter 之后、rateLimit / provider 选择之前 + * - 必须早于供应商选择,否则改写无法影响供应商路由 + * + * 重要约束: + * - 不调用 setOriginalModel:改写后 getOriginalModel() 返回目标模型, + * 供应商选择与 ModelRedirector 均以目标模型为基准(否则会被静默回退); + * 用户原始请求模型通过 keywordRoutingAudit 单独保留用于审计 + * - 该守卫永不拦截请求(始终返回 null),任何异常均降级放行 + */ + +import { isKeywordModelRoutingEnabled } from "@/lib/config/system-settings-cache"; +import { keywordRoutingEngine } from "@/lib/keyword-routing/engine"; +import { logger } from "@/lib/logger"; +import { extractKeywordRoutingTexts } from "@/lib/message-extractor"; +import type { ProxySession } from "./session"; + +export class ProxyKeywordRoutingGuard { + /** + * 应用关键词路由(命中规则时改写请求模型) + * + * @returns 始终返回 null(该守卫不拦截请求) + */ + static async ensure(session: ProxySession): Promise { + try { + // Gemini 格式的模型名通过 URL 路径传递,不在请求体中,暂不支持 + if (session.originalFormat === "gemini" || session.originalFormat === "gemini-cli") { + return null; + } + + // multipart 图片请求(请求体非 JSON),暂不支持 + if (session.isOpenAIImageMultipartRequest()) { + return null; + } + + // 总开关关闭时直接放行(fail-closed) + if (!(await isKeywordModelRoutingEnabled())) { + return null; + } + + // 快速路径:规则缓存为空时跳过文本提取(提取是昂贵步骤) + if (keywordRoutingEngine.isEmpty()) { + return null; + } + + const requestedModel = session.request.model; + if (!requestedModel) { + return null; + } + + // 提取待扫描文本(系统提示词 + 最后一条用户消息) + const texts = extractKeywordRoutingTexts(session.request.message); + if (texts.systemTexts.length === 0 && texts.lastUserTexts.length === 0) { + return null; + } + + // 匹配关键词路由规则(首个命中即返回) + const match = keywordRoutingEngine.match(texts, requestedModel); + if (!match || match.rule.targetModel === requestedModel) { + return null; + } + + // 改写请求模型(在供应商选择之前生效) + session.request.message.model = match.rule.targetModel; + session.request.model = match.rule.targetModel; + + // 重新生成请求 buffer(使用 TextEncoder) + const updatedBody = JSON.stringify(session.request.message); + const encoder = new TextEncoder(); + session.request.buffer = encoder.encode(updatedBody).buffer; + + // 记录审计信息(不调用 setOriginalModel,见文件头注释) + session.setKeywordRoutingAudit({ + userRequestedModel: requestedModel, + routedModel: match.rule.targetModel, + ruleId: match.rule.id, + keyword: match.rule.keyword, + matchedIn: match.matchedIn, + }); + + // 更新日志(记录路由改写) + session.request.note = `[Keyword Routed: ${requestedModel} -> ${match.rule.targetModel}, rule#${match.rule.id}] ${session.request.note || ""}`; + + logger.info("[KeywordRoutingGuard] Model rewritten by keyword rule", { + ruleId: match.rule.id, + keyword: match.rule.keyword, + matchedIn: match.matchedIn, + from: requestedModel, + to: match.rule.targetModel, + sessionId: session.sessionId, + }); + + return null; + } catch (error) { + logger.error("[KeywordRoutingGuard] Routing error:", error); + return null; // 降级:路由失败时放行,不阻塞正常请求 + } + } +} diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 10d6b4740..124e66e5c 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -135,6 +135,15 @@ export class ProxySession { // 模型重定向追踪:保存原始模型名(重定向前) private originalModelName: string | null = null; + // 关键词路由审计信息:保存用户原始请求模型与改写结果(用于决策链展示,不参与计费) + private keywordRoutingAudit: { + userRequestedModel: string; + routedModel: string; + ruleId: number; + keyword: string; + matchedIn: "system" | "user"; + } | null = null; + // 原始 URL 路径(用于 Gemini 模型重定向重置) private originalUrlPathname: string | null = null; @@ -663,6 +672,8 @@ export class ProxySession { strictBlockCause: metadata?.strictBlockCause, endpointFilterStats: metadata?.endpointFilterStats, modelRedirect: metadata?.modelRedirect ?? this.getCurrentModelRedirect(provider.id), + // 关键词路由审计信息(请求级别,发生过改写时附加到每个链路项) + keywordRouting: this.keywordRoutingAudit ?? undefined, rawCrossProviderFallbackEnabled: metadata?.rawCrossProviderFallbackEnabled, }; @@ -799,6 +810,23 @@ export class ProxySession { return this.originalModelName !== null && this.originalModelName !== this.request.model; } + /** + * 记录关键词路由审计信息(在关键词路由改写模型后调用) + * 只能设置一次,避免重试链路重复覆盖 + */ + setKeywordRoutingAudit(info: NonNullable): void { + if (this.keywordRoutingAudit === null) { + this.keywordRoutingAudit = info; + } + } + + /** + * 获取关键词路由审计信息(未发生关键词路由时返回 null) + */ + getKeywordRoutingAudit(): NonNullable | null { + return this.keywordRoutingAudit; + } + /** * 获取原始 URL 路径(用于 Gemini 模型重定向重置) */ diff --git a/src/types/message.ts b/src/types/message.ts index 07f74ba05..dd5281f47 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -106,6 +106,15 @@ export interface ProviderChainItem { }; }; + // 关键词路由信息(在请求级别记录,标记请求模型在供应商选择前被关键词规则改写) + keywordRouting?: { + userRequestedModel: string; // 用户原始请求的模型 + routedModel: string; // 关键词路由改写后的模型 + ruleId: number; // 命中的规则 ID + keyword: string; // 命中的关键词 + matchedIn: "system" | "user"; // 命中位置(系统提示词 / 最后一条用户消息) + }; + // 错误信息(记录失败时的上游报错) errorMessage?: string; diff --git a/tests/unit/proxy/guard-pipeline-keyword-routing.test.ts b/tests/unit/proxy/guard-pipeline-keyword-routing.test.ts new file mode 100644 index 000000000..cc031926d --- /dev/null +++ b/tests/unit/proxy/guard-pipeline-keyword-routing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + CHAT_PIPELINE, + RAW_PASSTHROUGH_PIPELINE, + RAW_SAFE_SESSION_PIPELINE, +} from "@/app/v1/_lib/proxy/guard-pipeline"; + +describe("guard pipeline keyword routing registration", () => { + it("places keywordRouting immediately after requestFilter and before rateLimit in CHAT_PIPELINE", () => { + const steps = CHAT_PIPELINE.steps; + const keywordRoutingIndex = steps.indexOf("keywordRouting"); + const requestFilterIndex = steps.indexOf("requestFilter"); + const rateLimitIndex = steps.indexOf("rateLimit"); + + expect(keywordRoutingIndex).toBeGreaterThan(-1); + // 紧跟在 requestFilter 之后 + expect(keywordRoutingIndex).toBe(requestFilterIndex + 1); + // 位于 rateLimit(进而位于 provider 选择)之前 + expect(keywordRoutingIndex).toBeLessThan(rateLimitIndex); + }); + + it("does not include keywordRouting in RAW_PASSTHROUGH_PIPELINE", () => { + expect(RAW_PASSTHROUGH_PIPELINE.steps).not.toContain("keywordRouting"); + }); + + it("does not include keywordRouting in RAW_SAFE_SESSION_PIPELINE", () => { + expect(RAW_SAFE_SESSION_PIPELINE.steps).not.toContain("keywordRouting"); + }); +}); diff --git a/tests/unit/proxy/keyword-routing-guard.test.ts b/tests/unit/proxy/keyword-routing-guard.test.ts new file mode 100644 index 000000000..4c7200f72 --- /dev/null +++ b/tests/unit/proxy/keyword-routing-guard.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/drizzle/db", () => ({ + db: { + insert: vi.fn(() => ({ + values: vi.fn(async () => undefined), + })), + }, +})); + +vi.mock("@/drizzle/schema", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + messageRequest: {}, + }; +}); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/keyword-routing/engine", () => ({ + keywordRoutingEngine: { + isEmpty: vi.fn(() => false), + match: vi.fn(() => null), + }, +})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + isKeywordModelRoutingEnabled: vi.fn(async () => true), +})); + +import { ProxyKeywordRoutingGuard } from "@/app/v1/_lib/proxy/keyword-routing-guard"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { isKeywordModelRoutingEnabled } from "@/lib/config/system-settings-cache"; +import { keywordRoutingEngine } from "@/lib/keyword-routing/engine"; +import { logger } from "@/lib/logger"; +import type { KeywordRoutingRule } from "@/repository/keyword-routing-rules"; + +function createContext(request: Request) { + return { + req: { + method: request.method, + url: request.url, + raw: request, + header(name?: string) { + if (name) { + return request.headers.get(name) ?? undefined; + } + return Object.fromEntries(request.headers.entries()); + }, + }, + } as any; +} + +async function createJsonSession(body: Record): Promise { + const request = new Request("https://proxy.example.com/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return ProxySession.fromContext(createContext(request)); +} + +function createRule(overrides: Partial = {}): KeywordRoutingRule { + return { + id: 7, + keyword: "ultrathink", + sourceModel: null, + targetModel: "claude-opus-4-6", + caseSensitive: false, + priority: 0, + description: null, + isEnabled: true, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +const DEFAULT_BODY = { + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: "please ultrathink about this" }], +}; + +describe("ProxyKeywordRoutingGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isKeywordModelRoutingEnabled).mockResolvedValue(true); + vi.mocked(keywordRoutingEngine.isEmpty).mockReturnValue(false); + vi.mocked(keywordRoutingEngine.match).mockReturnValue(null); + }); + + it("skips when the master toggle is off and does not consult the engine", async () => { + vi.mocked(isKeywordModelRoutingEnabled).mockResolvedValue(false); + const session = await createJsonSession(DEFAULT_BODY); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBe("claude-sonnet-4-5"); + expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); + }); + + it("skips when the rule cache is empty (toggle on)", async () => { + vi.mocked(keywordRoutingEngine.isEmpty).mockReturnValue(true); + const session = await createJsonSession(DEFAULT_BODY); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBe("claude-sonnet-4-5"); + expect(session.request.message.model).toBe("claude-sonnet-4-5"); + expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); + }); + + it("rewrites the model on a keyword match and records the audit", async () => { + const rule = createRule(); + vi.mocked(keywordRoutingEngine.match).mockReturnValue({ rule, matchedIn: "user" }); + const session = await createJsonSession(DEFAULT_BODY); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + + // 引擎收到的是提取后的文本与原始模型 + expect(keywordRoutingEngine.match).toHaveBeenCalledWith( + expect.objectContaining({ + systemTexts: [], + lastUserTexts: ["please ultrathink about this"], + }), + "claude-sonnet-4-5" + ); + + // request.model 与 message.model 均被改写 + expect(session.request.model).toBe("claude-opus-4-6"); + expect(session.request.message.model).toBe("claude-opus-4-6"); + + // buffer 重新生成并包含目标模型 + const decoded = JSON.parse(new TextDecoder().decode(session.request.buffer as ArrayBuffer)); + expect(decoded.model).toBe("claude-opus-4-6"); + expect(decoded.messages).toEqual(DEFAULT_BODY.messages); + + // 审计信息完整 + expect(session.getKeywordRoutingAudit()).toEqual({ + userRequestedModel: "claude-sonnet-4-5", + routedModel: "claude-opus-4-6", + ruleId: 7, + keyword: "ultrathink", + matchedIn: "user", + }); + + // 回归守卫:不得调用 setOriginalModel —— 改写后 getOriginalModel() 必须返回目标模型, + // 否则供应商选择会使用改写前模型,且 ModelRedirector 会静默回退本次改写 + expect(session.getOriginalModel()).toBe("claude-opus-4-6"); + + // note 记录改写 + expect(session.request.note).toContain( + "[Keyword Routed: claude-sonnet-4-5 -> claude-opus-4-6, rule#7]" + ); + }); + + it("does not mutate anything when the matched target equals the requested model", async () => { + const rule = createRule({ targetModel: "claude-sonnet-4-5" }); + vi.mocked(keywordRoutingEngine.match).mockReturnValue({ rule, matchedIn: "user" }); + const session = await createJsonSession(DEFAULT_BODY); + const bufferBefore = session.request.buffer; + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBe("claude-sonnet-4-5"); + expect(session.request.message.model).toBe("claude-sonnet-4-5"); + expect(session.request.buffer).toBe(bufferBefore); + expect(session.getKeywordRoutingAudit()).toBeNull(); + expect(session.request.note ?? "").not.toContain("Keyword Routed"); + }); + + it("leaves the request untouched when no rule matches", async () => { + vi.mocked(keywordRoutingEngine.match).mockReturnValue(null); + const session = await createJsonSession(DEFAULT_BODY); + const bufferBefore = session.request.buffer; + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBe("claude-sonnet-4-5"); + expect(session.request.buffer).toBe(bufferBefore); + expect(session.getKeywordRoutingAudit()).toBeNull(); + }); + + it.each([ + "gemini", + "gemini-cli", + ] as const)("skips %s requests without consulting the engine", async (format) => { + const session = await createJsonSession(DEFAULT_BODY); + session.setOriginalFormat(format); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBe("claude-sonnet-4-5"); + expect(keywordRoutingEngine.isEmpty).not.toHaveBeenCalled(); + expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); + }); + + it("skips OpenAI multipart image requests without consulting the engine", async () => { + const formData = new FormData(); + formData.append("model", "gpt-image-1.5"); + formData.append("prompt", "ultrathink this image"); + formData.append( + "image[]", + new File([new Uint8Array([1, 2, 3])], "image.png", { type: "image/png" }), + "image.png" + ); + + const request = new Request("https://proxy.example.com/v1/images/edits", { + method: "POST", + body: formData, + }); + const session = await ProxySession.fromContext(createContext(request)); + expect(session.isOpenAIImageMultipartRequest()).toBe(true); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBe("gpt-image-1.5"); + expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); + }); + + it("fails open when the engine throws", async () => { + vi.mocked(keywordRoutingEngine.match).mockImplementation(() => { + throw new Error("boom"); + }); + const session = await createJsonSession(DEFAULT_BODY); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBe("claude-sonnet-4-5"); + expect(session.getKeywordRoutingAudit()).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + + it("skips when the request has no model", async () => { + const session = await createJsonSession({ + messages: [{ role: "user", content: "please ultrathink about this" }], + }); + expect(session.request.model).toBeNull(); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + expect(session.request.model).toBeNull(); + expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); + }); +}); From 3ed808bda0a6fb1bdb0ef90e43cdd5c35d48aba4 Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 03:11:37 +0800 Subject: [PATCH 09/13] fix(proxy): scan string responses input and harden keyword routing guard tests --- .../v1/_lib/proxy/keyword-routing-guard.ts | 10 +- src/app/v1/_lib/proxy/session.ts | 8 +- src/lib/message-extractor.ts | 5 +- .../message-extractor-keyword-routing.test.ts | 10 ++ .../unit/proxy/keyword-routing-guard.test.ts | 110 +++++++++++++++++- 5 files changed, 127 insertions(+), 16 deletions(-) diff --git a/src/app/v1/_lib/proxy/keyword-routing-guard.ts b/src/app/v1/_lib/proxy/keyword-routing-guard.ts index ee87de1e9..d656d9ef6 100644 --- a/src/app/v1/_lib/proxy/keyword-routing-guard.ts +++ b/src/app/v1/_lib/proxy/keyword-routing-guard.ts @@ -9,6 +9,8 @@ * 调用时机: * - requestFilter 之后、rateLimit / provider 选择之前 * - 必须早于供应商选择,否则改写无法影响供应商路由 + * - 每个请求最多执行一次(GuardPipeline 对每个步骤只执行一次);若被重复执行, + * 同规则命中为无副作用空操作,但 sourceModel 限定规则可能基于改写结果链式二次改写 * * 重要约束: * - 不调用 setOriginalModel:改写后 getOriginalModel() 返回目标模型, @@ -41,13 +43,13 @@ export class ProxyKeywordRoutingGuard { return null; } - // 总开关关闭时直接放行(fail-closed) - if (!(await isKeywordModelRoutingEnabled())) { + // 快速路径:规则缓存为空时直接放行,连总开关查询都跳过(零规则部署零额外开销) + if (keywordRoutingEngine.isEmpty()) { return null; } - // 快速路径:规则缓存为空时跳过文本提取(提取是昂贵步骤) - if (keywordRoutingEngine.isEmpty()) { + // 总开关关闭时直接放行(功能未启用) + if (!(await isKeywordModelRoutingEnabled())) { return null; } diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 124e66e5c..029dd7324 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -136,13 +136,7 @@ export class ProxySession { private originalModelName: string | null = null; // 关键词路由审计信息:保存用户原始请求模型与改写结果(用于决策链展示,不参与计费) - private keywordRoutingAudit: { - userRequestedModel: string; - routedModel: string; - ruleId: number; - keyword: string; - matchedIn: "system" | "user"; - } | null = null; + private keywordRoutingAudit: NonNullable | null = null; // 原始 URL 路径(用于 Gemini 模型重定向重置) private originalUrlPathname: string | null = null; diff --git a/src/lib/message-extractor.ts b/src/lib/message-extractor.ts index fc0546a42..4c7a3eb75 100644 --- a/src/lib/message-extractor.ts +++ b/src/lib/message-extractor.ts @@ -238,9 +238,12 @@ export function extractKeywordRoutingTexts( collectRoleScanTexts(message.messages, systemTexts, lastUserTexts); } - // 4. 提取 input 数组(Codex / Response API 格式) + // 4. 提取 input 字段(Codex / Response API 格式,数组或纯字符串) if (Array.isArray(message.input)) { collectRoleScanTexts(message.input, systemTexts, lastUserTexts); + } else if (typeof message.input === "string") { + // input 为纯字符串时属于用户输入,进入 lastUserTexts + lastUserTexts.push(message.input); } // 5. 提取图片接口等顶层 prompt 字段(string 或 string 数组) diff --git a/tests/unit/lib/message-extractor-keyword-routing.test.ts b/tests/unit/lib/message-extractor-keyword-routing.test.ts index 7d43b8378..b05a0cd24 100644 --- a/tests/unit/lib/message-extractor-keyword-routing.test.ts +++ b/tests/unit/lib/message-extractor-keyword-routing.test.ts @@ -115,6 +115,16 @@ describe("extractKeywordRoutingTexts", () => { expect(result.lastUserTexts).toEqual(["last input"]); expect(result.lastUserTexts).not.toContain("first input"); }); + + it("字符串形式的 input 进入 lastUserTexts", () => { + const result = extractKeywordRoutingTexts({ + model: "gpt-5.2", + input: "please ultrathink about this", + }); + + expect(result.systemTexts).toEqual([]); + expect(result.lastUserTexts).toEqual(["please ultrathink about this"]); + }); }); describe("顶层 prompt 字段", () => { diff --git a/tests/unit/proxy/keyword-routing-guard.test.ts b/tests/unit/proxy/keyword-routing-guard.test.ts index 4c7200f72..c2f6e1f6b 100644 --- a/tests/unit/proxy/keyword-routing-guard.test.ts +++ b/tests/unit/proxy/keyword-routing-guard.test.ts @@ -44,6 +44,7 @@ import { isKeywordModelRoutingEnabled } from "@/lib/config/system-settings-cache import { keywordRoutingEngine } from "@/lib/keyword-routing/engine"; import { logger } from "@/lib/logger"; import type { KeywordRoutingRule } from "@/repository/keyword-routing-rules"; +import type { Provider } from "@/types/provider"; function createContext(request: Request) { return { @@ -110,7 +111,7 @@ describe("ProxyKeywordRoutingGuard", () => { expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); }); - it("skips when the rule cache is empty (toggle on)", async () => { + it("skips when the rule cache is empty without consulting the master toggle", async () => { vi.mocked(keywordRoutingEngine.isEmpty).mockReturnValue(true); const session = await createJsonSession(DEFAULT_BODY); @@ -119,6 +120,8 @@ describe("ProxyKeywordRoutingGuard", () => { expect(response).toBeNull(); expect(session.request.model).toBe("claude-sonnet-4-5"); expect(session.request.message.model).toBe("claude-sonnet-4-5"); + // 空规则快速路径在总开关检查之前,settings 查询被完全跳过 + expect(isKeywordModelRoutingEnabled).not.toHaveBeenCalled(); expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); }); @@ -126,6 +129,7 @@ describe("ProxyKeywordRoutingGuard", () => { const rule = createRule(); vi.mocked(keywordRoutingEngine.match).mockReturnValue({ rule, matchedIn: "user" }); const session = await createJsonSession(DEFAULT_BODY); + session.request.note = "existing note"; const response = await ProxyKeywordRoutingGuard.ensure(session); @@ -162,10 +166,46 @@ describe("ProxyKeywordRoutingGuard", () => { // 否则供应商选择会使用改写前模型,且 ModelRedirector 会静默回退本次改写 expect(session.getOriginalModel()).toBe("claude-opus-4-6"); - // note 记录改写 - expect(session.request.note).toContain( - "[Keyword Routed: claude-sonnet-4-5 -> claude-opus-4-6, rule#7]" + // note 记录改写:前缀为路由标记,且保留原有 note 内容 + expect(session.request.note).toMatch( + /^\[Keyword Routed: claude-sonnet-4-5 -> claude-opus-4-6, rule#7\] / ); + expect(session.request.note).toContain("existing note"); + }); + + it("rewrites the model when the keyword matches in system texts and audits matchedIn=system", async () => { + const rule = createRule({ keyword: "deepdive" }); + vi.mocked(keywordRoutingEngine.match).mockReturnValue({ rule, matchedIn: "system" }); + const session = await createJsonSession({ + model: "claude-sonnet-4-5", + system: "always deepdive into the problem", + messages: [{ role: "user", content: "hello" }], + }); + + const response = await ProxyKeywordRoutingGuard.ensure(session); + + expect(response).toBeNull(); + + // 引擎收到的扫描文本中包含 system 来源 + expect(keywordRoutingEngine.match).toHaveBeenCalledWith( + expect.objectContaining({ + systemTexts: ["always deepdive into the problem"], + lastUserTexts: ["hello"], + }), + "claude-sonnet-4-5" + ); + + expect(session.request.model).toBe("claude-opus-4-6"); + expect(session.request.message.model).toBe("claude-opus-4-6"); + + // 审计信息反映 system 命中 + expect(session.getKeywordRoutingAudit()).toEqual({ + userRequestedModel: "claude-sonnet-4-5", + routedModel: "claude-opus-4-6", + ruleId: 7, + keyword: "deepdive", + matchedIn: "system", + }); }); it("does not mutate anything when the matched target equals the requested model", async () => { @@ -263,3 +303,65 @@ describe("ProxyKeywordRoutingGuard", () => { expect(keywordRoutingEngine.match).not.toHaveBeenCalled(); }); }); + +describe("ProxySession keyword routing audit on the provider chain", () => { + const makeProvider = (id: number, name: string): Provider => + ({ + id, + name, + providerVendorId: 100, + providerType: "claude", + priority: 10, + weight: 1, + costMultiplier: 1, + groupTag: null, + isEnabled: true, + }) as unknown as Provider; + + const AUDIT = { + userRequestedModel: "claude-sonnet-4-5", + routedModel: "claude-opus-4-6", + ruleId: 7, + keyword: "ultrathink", + matchedIn: "user", + } as const; + + it("attaches the audit to chain items added after setKeywordRoutingAudit", async () => { + const session = await createJsonSession(DEFAULT_BODY); + session.setKeywordRoutingAudit({ ...AUDIT }); + + session.addProviderToChain(makeProvider(1, "Provider A"), { reason: "initial_selection" }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0].keywordRouting).toEqual(AUDIT); + }); + + it("leaves keywordRouting undefined on chain items when no audit was set", async () => { + const session = await createJsonSession(DEFAULT_BODY); + + session.addProviderToChain(makeProvider(1, "Provider A"), { reason: "initial_selection" }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0].keywordRouting).toBeUndefined(); + }); + + it("retains the first audit when setKeywordRoutingAudit is called twice", async () => { + const session = await createJsonSession(DEFAULT_BODY); + session.setKeywordRoutingAudit({ ...AUDIT }); + session.setKeywordRoutingAudit({ + userRequestedModel: "claude-haiku-4-5", + routedModel: "claude-sonnet-4-5", + ruleId: 99, + keyword: "other", + matchedIn: "system", + }); + + expect(session.getKeywordRoutingAudit()).toEqual(AUDIT); + + // 链路项同样携带首次写入的审计信息 + session.addProviderToChain(makeProvider(2, "Provider B"), { reason: "initial_selection" }); + expect(session.getProviderChain()[0].keywordRouting).toEqual(AUDIT); + }); +}); From a66d5033499411e1ec9b1cfb2a9ff9d3bad215b0 Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 03:39:17 +0800 Subject: [PATCH 10/13] feat(api): add keyword routing rules management actions and rest endpoints --- messages/en/auditLogs.json | 6 + messages/ja/auditLogs.json | 6 + messages/ru/auditLogs.json | 6 + messages/zh-CN/auditLogs.json | 6 + messages/zh-TW/auditLogs.json | 6 + src/actions/audit-logs.ts | 1 + src/actions/keyword-routing.ts | 362 +++++ .../_components/audit-logs-view.tsx | 1 + src/app/api/v1/_root/app.ts | 2 + .../v1/resources/keyword-routing/handlers.ts | 108 ++ .../v1/resources/keyword-routing/router.ts | 189 +++ .../api-client/v1/actions/keyword-routing.ts | 35 + src/lib/api-client/v1/openapi-types.gen.ts | 1255 ++++++++++++++++- src/lib/api/v1/schemas/audit-logs.ts | 1 + src/lib/api/v1/schemas/keyword-routing.ts | 76 + src/types/audit-log.ts | 1 + .../keyword-routing.authz.test.ts | 11 + .../keyword-routing.crud.test.ts | 12 + .../keyword-routing/keyword-routing.test.ts | 174 +++ tests/setup.ts | 1 + tests/unit/actions/keyword-routing.test.ts | 273 ++++ 21 files changed, 2529 insertions(+), 3 deletions(-) create mode 100644 src/actions/keyword-routing.ts create mode 100644 src/app/api/v1/resources/keyword-routing/handlers.ts create mode 100644 src/app/api/v1/resources/keyword-routing/router.ts create mode 100644 src/lib/api-client/v1/actions/keyword-routing.ts create mode 100644 src/lib/api/v1/schemas/keyword-routing.ts create mode 100644 tests/api/v1/keyword-routing/keyword-routing.authz.test.ts create mode 100644 tests/api/v1/keyword-routing/keyword-routing.crud.test.ts create mode 100644 tests/api/v1/keyword-routing/keyword-routing.test.ts create mode 100644 tests/unit/actions/keyword-routing.test.ts diff --git a/messages/en/auditLogs.json b/messages/en/auditLogs.json index 4bb77e766..62930c43d 100644 --- a/messages/en/auditLogs.json +++ b/messages/en/auditLogs.json @@ -18,6 +18,7 @@ "key": "Keys", "notification": "Notifications", "sensitive_word": "Sensitive words", + "keyword_routing_rule": "Keyword routing", "model_price": "Model prices" }, "columns": { @@ -70,6 +71,11 @@ "update": "Update sensitive word", "delete": "Delete sensitive word" }, + "keyword_routing_rule": { + "create": "Create keyword routing rule", + "update": "Update keyword routing rule", + "delete": "Delete keyword routing rule" + }, "model_price": { "bulk_upload": "Bulk upload model prices", "sync_litellm": "Sync LiteLLM model prices", diff --git a/messages/ja/auditLogs.json b/messages/ja/auditLogs.json index 6e21cdf4e..afaed9fd6 100644 --- a/messages/ja/auditLogs.json +++ b/messages/ja/auditLogs.json @@ -18,6 +18,7 @@ "key": "キー", "notification": "通知", "sensitive_word": "機密ワード", + "keyword_routing_rule": "キーワードルーティング", "model_price": "モデル価格" }, "columns": { @@ -70,6 +71,11 @@ "update": "機密ワード更新", "delete": "機密ワード削除" }, + "keyword_routing_rule": { + "create": "キーワードルーティングルール作成", + "update": "キーワードルーティングルール更新", + "delete": "キーワードルーティングルール削除" + }, "model_price": { "bulk_upload": "モデル価格の一括アップロード", "sync_litellm": "LiteLLM モデル価格の同期", diff --git a/messages/ru/auditLogs.json b/messages/ru/auditLogs.json index e05922d7c..1d16d072d 100644 --- a/messages/ru/auditLogs.json +++ b/messages/ru/auditLogs.json @@ -18,6 +18,7 @@ "key": "Ключи", "notification": "Уведомления", "sensitive_word": "Запрещённые слова", + "keyword_routing_rule": "Маршрутизация по ключевым словам", "model_price": "Цены моделей" }, "columns": { @@ -70,6 +71,11 @@ "update": "Обновление запрещённого слова", "delete": "Удаление запрещённого слова" }, + "keyword_routing_rule": { + "create": "Создание правила маршрутизации по ключевым словам", + "update": "Обновление правила маршрутизации по ключевым словам", + "delete": "Удаление правила маршрутизации по ключевым словам" + }, "model_price": { "bulk_upload": "Массовая загрузка цен моделей", "sync_litellm": "Синхронизация цен моделей LiteLLM", diff --git a/messages/zh-CN/auditLogs.json b/messages/zh-CN/auditLogs.json index 8fad548e4..bb75f579b 100644 --- a/messages/zh-CN/auditLogs.json +++ b/messages/zh-CN/auditLogs.json @@ -18,6 +18,7 @@ "key": "密钥", "notification": "通知", "sensitive_word": "敏感词", + "keyword_routing_rule": "关键词路由", "model_price": "模型价格" }, "columns": { @@ -70,6 +71,11 @@ "update": "更新敏感词", "delete": "删除敏感词" }, + "keyword_routing_rule": { + "create": "创建关键词路由规则", + "update": "更新关键词路由规则", + "delete": "删除关键词路由规则" + }, "model_price": { "bulk_upload": "批量上传模型价格", "sync_litellm": "同步 LiteLLM 模型价格", diff --git a/messages/zh-TW/auditLogs.json b/messages/zh-TW/auditLogs.json index c63323140..712618655 100644 --- a/messages/zh-TW/auditLogs.json +++ b/messages/zh-TW/auditLogs.json @@ -18,6 +18,7 @@ "key": "金鑰", "notification": "通知", "sensitive_word": "敏感詞", + "keyword_routing_rule": "關鍵詞路由", "model_price": "模型價格" }, "columns": { @@ -70,6 +71,11 @@ "update": "更新敏感詞", "delete": "刪除敏感詞" }, + "keyword_routing_rule": { + "create": "建立關鍵詞路由規則", + "update": "更新關鍵詞路由規則", + "delete": "刪除關鍵詞路由規則" + }, "model_price": { "bulk_upload": "批次上傳模型價格", "sync_litellm": "同步 LiteLLM 模型價格", diff --git a/src/actions/audit-logs.ts b/src/actions/audit-logs.ts index 36ac6fd3c..1ff57498a 100644 --- a/src/actions/audit-logs.ts +++ b/src/actions/audit-logs.ts @@ -21,6 +21,7 @@ const AUDIT_CATEGORY_VALUES = [ "key", "notification", "sensitive_word", + "keyword_routing_rule", "model_price", ] as const satisfies readonly AuditCategory[]; diff --git a/src/actions/keyword-routing.ts b/src/actions/keyword-routing.ts new file mode 100644 index 000000000..decd9b93f --- /dev/null +++ b/src/actions/keyword-routing.ts @@ -0,0 +1,362 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { emitActionAudit } from "@/lib/audit/emit"; +import { getSession } from "@/lib/auth"; +import { keywordRoutingEngine } from "@/lib/keyword-routing/engine"; +import { logger } from "@/lib/logger"; +import * as repo from "@/repository/keyword-routing-rules"; +import type { ActionResult } from "./types"; + +const KEYWORD_MAX_LENGTH = 500; +const MODEL_MAX_LENGTH = 128; +const DESCRIPTION_MAX_LENGTH = 500; + +/** + * 校验创建/更新规则的字段,返回错误信息(合法时返回 null) + */ +function validateRuleFields(fields: { + keyword?: string; + sourceModel?: string | null; + targetModel?: string; + description?: string | null; + priority?: number; +}): string | null { + if (fields.keyword !== undefined) { + const keyword = fields.keyword?.trim() ?? ""; + if (keyword.length === 0) { + return "关键词不能为空"; + } + if (keyword.length > KEYWORD_MAX_LENGTH) { + return `关键词长度不能超过 ${KEYWORD_MAX_LENGTH} 个字符`; + } + } + + if (fields.targetModel !== undefined) { + const targetModel = fields.targetModel?.trim() ?? ""; + if (targetModel.length === 0) { + return "目标模型不能为空"; + } + if (targetModel.length > MODEL_MAX_LENGTH) { + return `目标模型长度不能超过 ${MODEL_MAX_LENGTH} 个字符`; + } + } + + if (fields.sourceModel != null && fields.sourceModel.trim().length > MODEL_MAX_LENGTH) { + return `来源模型长度不能超过 ${MODEL_MAX_LENGTH} 个字符`; + } + + if (fields.description != null && fields.description.length > DESCRIPTION_MAX_LENGTH) { + return `描述长度不能超过 ${DESCRIPTION_MAX_LENGTH} 个字符`; + } + + if (fields.priority !== undefined && !Number.isInteger(fields.priority)) { + return "优先级必须为整数"; + } + + return null; +} + +/** + * 获取所有关键词路由规则列表 + */ +export async function listKeywordRoutingRules(): Promise { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + logger.warn("[KeywordRoutingAction] Unauthorized access attempt"); + return []; + } + + return await repo.getAllKeywordRoutingRules(); + } catch (error) { + logger.error("[KeywordRoutingAction] Failed to list keyword routing rules:", error); + return []; + } +} + +/** + * 创建关键词路由规则 + */ +export async function createKeywordRoutingRuleAction(data: { + keyword: string; + sourceModel?: string | null; + targetModel: string; + caseSensitive?: boolean; + priority?: number; + description?: string | null; +}): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: "权限不足", + }; + } + + // 验证必填字段与长度限制 + const validationError = validateRuleFields({ + keyword: data.keyword ?? "", + targetModel: data.targetModel ?? "", + sourceModel: data.sourceModel, + description: data.description, + priority: data.priority, + }); + if (validationError) { + return { + ok: false, + error: validationError, + }; + } + + const result = await repo.createKeywordRoutingRule(data); + + revalidatePath("/settings/keyword-routing"); + + logger.info("[KeywordRoutingAction] Created keyword routing rule", { + keyword: data.keyword, + targetModel: data.targetModel, + userId: session.user.id, + }); + + emitActionAudit({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.create", + targetType: "keyword_routing_rule", + targetId: String(result.id), + targetName: result.keyword, + after: { + id: result.id, + keyword: result.keyword, + sourceModel: result.sourceModel, + targetModel: result.targetModel, + caseSensitive: result.caseSensitive, + priority: result.priority, + description: result.description, + isEnabled: result.isEnabled, + }, + success: true, + }); + + return { + ok: true, + data: result, + }; + } catch (error) { + logger.error("[KeywordRoutingAction] Failed to create keyword routing rule:", error); + emitActionAudit({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.create", + targetType: "keyword_routing_rule", + targetName: data.keyword ?? null, + success: false, + errorMessage: "CREATE_FAILED", + }); + return { + ok: false, + error: "创建关键词路由规则失败", + }; + } +} + +/** + * 更新关键词路由规则 + */ +export async function updateKeywordRoutingRuleAction( + id: number, + updates: Partial<{ + keyword: string; + sourceModel: string | null; + targetModel: string; + caseSensitive: boolean; + priority: number; + description: string | null; + isEnabled: boolean; + }> +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: "权限不足", + }; + } + + // 仅校验本次提供的字段 + const validationError = validateRuleFields(updates); + if (validationError) { + return { + ok: false, + error: validationError, + }; + } + + const result = await repo.updateKeywordRoutingRule(id, updates); + + if (!result) { + return { + ok: false, + error: "关键词路由规则不存在", + }; + } + + revalidatePath("/settings/keyword-routing"); + + logger.info("[KeywordRoutingAction] Updated keyword routing rule", { + id, + updates, + userId: session.user.id, + }); + + emitActionAudit({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.update", + targetType: "keyword_routing_rule", + targetId: String(id), + targetName: result.keyword, + after: { + id: result.id, + keyword: result.keyword, + sourceModel: result.sourceModel, + targetModel: result.targetModel, + caseSensitive: result.caseSensitive, + priority: result.priority, + description: result.description, + isEnabled: result.isEnabled, + }, + success: true, + }); + + return { + ok: true, + data: result, + }; + } catch (error) { + logger.error("[KeywordRoutingAction] Failed to update keyword routing rule:", error); + emitActionAudit({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.update", + targetType: "keyword_routing_rule", + targetId: String(id), + success: false, + errorMessage: "UPDATE_FAILED", + }); + return { + ok: false, + error: "更新关键词路由规则失败", + }; + } +} + +/** + * 删除关键词路由规则 + */ +export async function deleteKeywordRoutingRuleAction(id: number): Promise { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: "权限不足", + }; + } + + const deleted = await repo.deleteKeywordRoutingRule(id); + + if (!deleted) { + return { + ok: false, + error: "关键词路由规则不存在", + }; + } + + revalidatePath("/settings/keyword-routing"); + + logger.info("[KeywordRoutingAction] Deleted keyword routing rule", { + id, + userId: session.user.id, + }); + + emitActionAudit({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.delete", + targetType: "keyword_routing_rule", + targetId: String(id), + success: true, + }); + + return { + ok: true, + }; + } catch (error) { + logger.error("[KeywordRoutingAction] Failed to delete keyword routing rule:", error); + emitActionAudit({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.delete", + targetType: "keyword_routing_rule", + targetId: String(id), + success: false, + errorMessage: "DELETE_FAILED", + }); + return { + ok: false, + error: "删除关键词路由规则失败", + }; + } +} + +/** + * 手动刷新缓存 + */ +export async function refreshKeywordRoutingCacheAction(): Promise< + ActionResult<{ stats: ReturnType }> +> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: "权限不足", + }; + } + + await keywordRoutingEngine.reload(); + + const stats = keywordRoutingEngine.getStats(); + + logger.info("[KeywordRoutingAction] Cache refreshed", { + stats, + userId: session.user.id, + }); + + return { + ok: true, + data: { stats }, + }; + } catch (error) { + logger.error("[KeywordRoutingAction] Failed to refresh cache:", error); + return { + ok: false, + error: "刷新缓存失败", + }; + } +} + +/** + * 获取缓存统计信息 + */ +export async function getKeywordRoutingCacheStats() { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return null; + } + + return keywordRoutingEngine.getStats(); + } catch (error) { + logger.error("[KeywordRoutingAction] Failed to get cache stats:", error); + return null; + } +} diff --git a/src/app/[locale]/dashboard/audit-logs/_components/audit-logs-view.tsx b/src/app/[locale]/dashboard/audit-logs/_components/audit-logs-view.tsx index 58807e4dd..8e8928e28 100644 --- a/src/app/[locale]/dashboard/audit-logs/_components/audit-logs-view.tsx +++ b/src/app/[locale]/dashboard/audit-logs/_components/audit-logs-view.tsx @@ -38,6 +38,7 @@ const CATEGORIES: AuditCategory[] = [ "key", "notification", "sensitive_word", + "keyword_routing_rule", "model_price", ]; diff --git a/src/app/api/v1/_root/app.ts b/src/app/api/v1/_root/app.ts index 2c852c84f..46132cd6b 100644 --- a/src/app/api/v1/_root/app.ts +++ b/src/app/api/v1/_root/app.ts @@ -9,6 +9,7 @@ import { auditLogsRouter } from "../resources/audit-logs/router"; import { dashboardRouter } from "../resources/dashboard/router"; import { errorRulesRouter } from "../resources/error-rules/router"; import { keysRouter } from "../resources/keys/router"; +import { keywordRoutingRouter } from "../resources/keyword-routing/router"; import { meRouter } from "../resources/me/router"; import { modelPricesRouter } from "../resources/model-prices/router"; import { notificationsRouter } from "../resources/notifications/router"; @@ -141,6 +142,7 @@ app.route("/", providersRouter); app.route("/", notificationsRouter); app.route("/", systemRouter); app.route("/", sensitiveWordsRouter); +app.route("/", keywordRoutingRouter); app.route("/", errorRulesRouter); app.route("/", requestFiltersRouter); app.route("/", publicRouter); diff --git a/src/app/api/v1/resources/keyword-routing/handlers.ts b/src/app/api/v1/resources/keyword-routing/handlers.ts new file mode 100644 index 000000000..023953d61 --- /dev/null +++ b/src/app/api/v1/resources/keyword-routing/handlers.ts @@ -0,0 +1,108 @@ +import type { Context } from "hono"; +import type { ActionResult } from "@/actions/types"; +import { callAction } from "@/lib/api/v1/_shared/action-bridge"; +import { + createProblemResponse, + fromZodError, + publicActionErrorDetail, +} from "@/lib/api/v1/_shared/error-envelope"; +import { parseHonoJsonBody } from "@/lib/api/v1/_shared/request-body"; +import { + createdResponse, + jsonResponse, + noContentResponse, +} from "@/lib/api/v1/_shared/response-helpers"; +import { + KeywordRoutingRuleCreateSchema, + KeywordRoutingRuleIdParamSchema, + KeywordRoutingRuleUpdateSchema, +} from "@/lib/api/v1/schemas/keyword-routing"; + +export async function listKeywordRoutingRules(c: Context): Promise { + const actions = await import("@/actions/keyword-routing"); + const result = await callAction(c, actions.listKeywordRoutingRules, [], c.get("auth")); + if (!result.ok) return actionError(c, result); + return jsonResponse({ items: result.data }); +} + +export async function createKeywordRoutingRule(c: Context): Promise { + const body = await parseHonoJsonBody(c, KeywordRoutingRuleCreateSchema); + if (!body.ok) return body.response; + const actions = await import("@/actions/keyword-routing"); + const result = await callAction( + c, + actions.createKeywordRoutingRuleAction, + [body.data] as never[], + c.get("auth") + ); + if (!result.ok) return actionError(c, result); + return createdResponse(result.data, `/api/v1/keyword-routing-rules/${result.data.id}`); +} + +export async function updateKeywordRoutingRule(c: Context): Promise { + const params = KeywordRoutingRuleIdParamSchema.safeParse({ id: c.req.param("id") }); + if (!params.success) return fromZodError(params.error, new URL(c.req.url).pathname); + const body = await parseHonoJsonBody(c, KeywordRoutingRuleUpdateSchema); + if (!body.ok) return body.response; + const actions = await import("@/actions/keyword-routing"); + const result = await callAction( + c, + actions.updateKeywordRoutingRuleAction, + [params.data.id, body.data] as never[], + c.get("auth") + ); + if (!result.ok) return actionError(c, result); + return jsonResponse(result.data); +} + +export async function deleteKeywordRoutingRule(c: Context): Promise { + const params = KeywordRoutingRuleIdParamSchema.safeParse({ id: c.req.param("id") }); + if (!params.success) return fromZodError(params.error, new URL(c.req.url).pathname); + + const actions = await import("@/actions/keyword-routing"); + const result = await callAction( + c, + actions.deleteKeywordRoutingRuleAction, + [params.data.id] as never[], + c.get("auth") + ); + if (!result.ok) return actionError(c, result); + return noContentResponse(); +} + +export async function refreshKeywordRoutingCache(c: Context): Promise { + const actions = await import("@/actions/keyword-routing"); + const result = await callAction(c, actions.refreshKeywordRoutingCacheAction, [], c.get("auth")); + if (!result.ok) return actionError(c, result); + return jsonResponse(result.data); +} + +export async function getKeywordRoutingCacheStats(c: Context): Promise { + const actions = await import("@/actions/keyword-routing"); + const result = await callAction(c, actions.getKeywordRoutingCacheStats, [], c.get("auth")); + if (!result.ok) return actionError(c, result); + if (result.data == null) { + return createProblemResponse({ + status: 403, + instance: new URL(c.req.url).pathname, + errorCode: "auth.forbidden", + detail: "Admin access is required.", + }); + } + return jsonResponse(result.data); +} + +function actionError(c: Context, result: Extract, { ok: false }>): Response { + const detail = result.error || "Request failed."; + const status = detail.includes("不存在") ? 404 : detail.includes("权限") ? 403 : 400; + return createProblemResponse({ + status, + instance: new URL(c.req.url).pathname, + errorCode: + status === 404 + ? "keyword_routing_rule.not_found" + : (result.errorCode ?? "keyword_routing_rule.action_failed"), + errorParams: result.errorParams, + detail: publicActionErrorDetail(status), + }); +} diff --git a/src/app/api/v1/resources/keyword-routing/router.ts b/src/app/api/v1/resources/keyword-routing/router.ts new file mode 100644 index 000000000..27d13cf17 --- /dev/null +++ b/src/app/api/v1/resources/keyword-routing/router.ts @@ -0,0 +1,189 @@ +import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import { requireAuth } from "@/lib/api/v1/_shared/auth-middleware"; +import { fromZodError } from "@/lib/api/v1/_shared/error-envelope"; +import { ProblemJsonSchema } from "@/lib/api/v1/schemas/_common"; +import { + KeywordRoutingCacheRefreshResponseSchema, + KeywordRoutingCacheStatsSchema, + KeywordRoutingRuleCreateSchema, + KeywordRoutingRuleIdParamSchema, + KeywordRoutingRuleListResponseSchema, + KeywordRoutingRuleSchema, + KeywordRoutingRuleUpdateSchema, +} from "@/lib/api/v1/schemas/keyword-routing"; +import { + createKeywordRoutingRule, + deleteKeywordRoutingRule, + getKeywordRoutingCacheStats, + listKeywordRoutingRules, + refreshKeywordRoutingCache, + updateKeywordRoutingRule, +} from "./handlers"; + +export const keywordRoutingRouter = new OpenAPIHono({ + defaultHook: (result, c) => { + if (!result.success) return fromZodError(result.error, new URL(c.req.url).pathname); + }, +}); + +const security: Array> = [ + { cookieAuth: [] }, + { bearerAuth: [] }, + { apiKeyAuth: [] }, +]; + +const problemResponses = { + 400: { + description: "Invalid request.", + content: { "application/problem+json": { schema: ProblemJsonSchema } }, + }, + 401: { + description: "Authentication required.", + content: { "application/problem+json": { schema: ProblemJsonSchema } }, + }, + 403: { + description: "Admin access required.", + content: { "application/problem+json": { schema: ProblemJsonSchema } }, + }, + 404: { + description: "Keyword routing rule not found.", + content: { "application/problem+json": { schema: ProblemJsonSchema } }, + }, +} as const; + +keywordRoutingRouter.openapi( + createRoute({ + method: "get", + path: "/keyword-routing-rules", + middleware: requireAuth("admin"), + tags: ["Keyword Routing"], + summary: "List keyword routing rules", + description: "Lists all keyword routing rules, including disabled rules.", + "x-required-access": "admin", + security, + responses: { + 200: { + description: "Keyword routing rules.", + content: { "application/json": { schema: KeywordRoutingRuleListResponseSchema } }, + }, + ...problemResponses, + }, + }), + listKeywordRoutingRules as never +); + +keywordRoutingRouter.openapi( + createRoute({ + method: "post", + path: "/keyword-routing-rules", + middleware: requireAuth("admin"), + tags: ["Keyword Routing"], + summary: "Create keyword routing rule", + description: "Creates a keyword routing rule that rewrites the requested model on match.", + "x-required-access": "admin", + security, + request: { + body: { + required: true, + content: { "application/json": { schema: KeywordRoutingRuleCreateSchema } }, + }, + }, + responses: { + 201: { + description: "Created keyword routing rule.", + content: { "application/json": { schema: KeywordRoutingRuleSchema } }, + }, + ...problemResponses, + }, + }), + createKeywordRoutingRule as never +); + +keywordRoutingRouter.openapi( + createRoute({ + method: "post", + path: "/keyword-routing-rules/cache:refresh", + middleware: requireAuth("admin"), + tags: ["Keyword Routing"], + summary: "Refresh keyword routing cache", + description: "Reloads the keyword routing engine cache.", + "x-required-access": "admin", + security, + responses: { + 200: { + description: "Refreshed engine stats.", + content: { "application/json": { schema: KeywordRoutingCacheRefreshResponseSchema } }, + }, + ...problemResponses, + }, + }), + refreshKeywordRoutingCache as never +); + +keywordRoutingRouter.openapi( + createRoute({ + method: "get", + path: "/keyword-routing-rules/cache/stats", + middleware: requireAuth("admin"), + tags: ["Keyword Routing"], + summary: "Get keyword routing cache stats", + description: "Returns current keyword routing engine cache statistics.", + "x-required-access": "admin", + security, + responses: { + 200: { + description: "Engine stats.", + content: { "application/json": { schema: KeywordRoutingCacheStatsSchema } }, + }, + ...problemResponses, + }, + }), + getKeywordRoutingCacheStats as never +); + +keywordRoutingRouter.openapi( + createRoute({ + method: "patch", + path: "/keyword-routing-rules/{id}", + middleware: requireAuth("admin"), + tags: ["Keyword Routing"], + summary: "Update keyword routing rule", + description: "Partially updates a keyword routing rule.", + "x-required-access": "admin", + security, + request: { + params: KeywordRoutingRuleIdParamSchema, + body: { + required: true, + content: { "application/json": { schema: KeywordRoutingRuleUpdateSchema } }, + }, + }, + responses: { + 200: { + description: "Updated keyword routing rule.", + content: { "application/json": { schema: KeywordRoutingRuleSchema } }, + }, + ...problemResponses, + }, + }), + updateKeywordRoutingRule as never +); + +keywordRoutingRouter.openapi( + createRoute({ + method: "delete", + path: "/keyword-routing-rules/{id}", + middleware: requireAuth("admin"), + tags: ["Keyword Routing"], + summary: "Delete keyword routing rule", + description: "Deletes a keyword routing rule.", + "x-required-access": "admin", + security, + request: { params: KeywordRoutingRuleIdParamSchema }, + responses: { + 204: { description: "Keyword routing rule deleted." }, + ...problemResponses, + }, + }), + deleteKeywordRoutingRule as never +); diff --git a/src/lib/api-client/v1/actions/keyword-routing.ts b/src/lib/api-client/v1/actions/keyword-routing.ts new file mode 100644 index 000000000..c92982e56 --- /dev/null +++ b/src/lib/api-client/v1/actions/keyword-routing.ts @@ -0,0 +1,35 @@ +import { + apiDelete, + apiGet, + apiPatch, + apiPost, + toActionResult, + toVoidActionResult, + unwrapItems, +} from "./_compat"; + +export function listKeywordRoutingRules() { + return toActionResult( + apiGet<{ items?: unknown[] }>("/api/v1/keyword-routing-rules").then(unwrapItems) + ); +} + +export function createKeywordRoutingRuleAction(data: unknown) { + return toActionResult(apiPost("/api/v1/keyword-routing-rules", data)); +} + +export function updateKeywordRoutingRuleAction(id: number, data: unknown) { + return toActionResult(apiPatch(`/api/v1/keyword-routing-rules/${id}`, data)); +} + +export function deleteKeywordRoutingRuleAction(id: number) { + return toVoidActionResult(apiDelete(`/api/v1/keyword-routing-rules/${id}`)); +} + +export function refreshKeywordRoutingCacheAction() { + return toActionResult(apiPost("/api/v1/keyword-routing-rules/cache:refresh")); +} + +export function getKeywordRoutingCacheStats() { + return toActionResult(apiGet("/api/v1/keyword-routing-rules/cache/stats")); +} diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 2f45e3612..0bfc40975 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -884,6 +884,94 @@ export interface paths { patch: operations["patchSensitiveWordsById"]; trace?: never; }; + "/api/v1/keyword-routing-rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List keyword routing rules + * @description Lists all keyword routing rules, including disabled rules. + */ + get: operations["getKeywordRoutingRules"]; + put?: never; + /** + * Create keyword routing rule + * @description Creates a keyword routing rule that rewrites the requested model on match. + */ + post: operations["postKeywordRoutingRules"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/keyword-routing-rules/cache:refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Refresh keyword routing cache + * @description Reloads the keyword routing engine cache. + */ + post: operations["postKeywordRoutingRulesCacheRefresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/keyword-routing-rules/cache/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get keyword routing cache stats + * @description Returns current keyword routing engine cache statistics. + */ + get: operations["getKeywordRoutingRulesCacheStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/keyword-routing-rules/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete keyword routing rule + * @description Deletes a keyword routing rule. + */ + delete: operations["deleteKeywordRoutingRulesById"]; + options?: never; + head?: never; + /** + * Update keyword routing rule + * @description Partially updates a keyword routing rule. + */ + patch: operations["patchKeywordRoutingRulesById"]; + trace?: never; + }; "/api/v1/error-rules": { parameters: { query?: never; @@ -13723,6 +13811,1167 @@ export interface operations { }; }; }; + getKeywordRoutingRules: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Keyword routing rules. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Keyword routing rules. */ + items: { + /** @description Keyword routing rule id. */ + id: number; + /** @description Keyword to match in request texts. */ + keyword: string; + /** @description Source model filter; null matches any requested model. */ + sourceModel: string | null; + /** @description Target model to route matched requests to. */ + targetModel: string; + /** @description Whether keyword matching is case sensitive. */ + caseSensitive: boolean; + /** @description Rule priority; lower values are evaluated first. */ + priority: number; + /** @description Optional description. */ + description: string | null; + /** @description Whether the rule is enabled. */ + isEnabled: boolean; + /** + * Format: date-time + * @description Creation time. + */ + createdAt: string; + /** + * Format: date-time + * @description Last update time. + */ + updatedAt: string; + }[]; + }; + }; + }; + /** @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 Keyword routing rule 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; + }[]; + }; + }; + }; + }; + }; + postKeywordRoutingRules: { + parameters: { + query?: never; + header?: { + /** @description Required only when authenticating with the auth-token cookie on mutation requests. */ + "X-CCH-CSRF"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Keyword to match in request texts. */ + keyword: string; + /** @description Source model filter; null or empty matches any requested model. */ + sourceModel?: string | null; + /** @description Target model to route matched requests to. */ + targetModel: string; + /** @description Whether keyword matching is case sensitive. */ + caseSensitive?: boolean; + /** @description Rule priority; lower values are evaluated first. */ + priority?: number; + /** @description Optional description. */ + description?: string | null; + }; + }; + }; + responses: { + /** @description Created keyword routing rule. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Keyword routing rule id. */ + id: number; + /** @description Keyword to match in request texts. */ + keyword: string; + /** @description Source model filter; null matches any requested model. */ + sourceModel: string | null; + /** @description Target model to route matched requests to. */ + targetModel: string; + /** @description Whether keyword matching is case sensitive. */ + caseSensitive: boolean; + /** @description Rule priority; lower values are evaluated first. */ + priority: number; + /** @description Optional description. */ + description: string | null; + /** @description Whether the rule is enabled. */ + isEnabled: boolean; + /** + * Format: date-time + * @description Creation time. + */ + createdAt: string; + /** + * Format: date-time + * @description Last update time. + */ + updatedAt: string; + }; + }; + }; + /** @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 Keyword routing rule 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; + }[]; + }; + }; + }; + }; + }; + postKeywordRoutingRulesCacheRefresh: { + parameters: { + query?: never; + header?: { + /** @description Required only when authenticating with the auth-token cookie on mutation requests. */ + "X-CCH-CSRF"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Refreshed engine stats. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Refreshed engine cache statistics. */ + stats: { + /** @description Number of cached enabled rules. */ + ruleCount: number; + /** @description Last reload time as a unix epoch in milliseconds. */ + lastReloadTime: number; + /** @description Whether a reload is currently in progress. */ + isLoading: boolean; + }; + }; + }; + }; + /** @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 Keyword routing rule 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; + }[]; + }; + }; + }; + }; + }; + getKeywordRoutingRulesCacheStats: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Engine stats. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Number of cached enabled rules. */ + ruleCount: number; + /** @description Last reload time as a unix epoch in milliseconds. */ + lastReloadTime: number; + /** @description Whether a reload is currently in progress. */ + isLoading: boolean; + }; + }; + }; + /** @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 Keyword routing rule 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; + }[]; + }; + }; + }; + }; + }; + deleteKeywordRoutingRulesById: { + parameters: { + query?: never; + header?: { + /** @description Required only when authenticating with the auth-token cookie on mutation requests. */ + "X-CCH-CSRF"?: string; + }; + path: { + /** @description Keyword routing rule id. */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Keyword routing rule deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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 Keyword routing rule 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; + }[]; + }; + }; + }; + }; + }; + patchKeywordRoutingRulesById: { + parameters: { + query?: never; + header?: { + /** @description Required only when authenticating with the auth-token cookie on mutation requests. */ + "X-CCH-CSRF"?: string; + }; + path: { + /** @description Keyword routing rule id. */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Keyword to match in request texts. */ + keyword?: string; + /** @description Source model filter; null or empty matches any requested model. */ + sourceModel?: string | null; + /** @description Target model to route matched requests to. */ + targetModel?: string; + /** @description Whether keyword matching is case sensitive. */ + caseSensitive?: boolean; + /** @description Rule priority; lower values are evaluated first. */ + priority?: number; + /** @description Optional description. */ + description?: string | null; + /** @description Whether the rule is enabled. */ + isEnabled?: boolean; + }; + }; + }; + responses: { + /** @description Updated keyword routing rule. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Keyword routing rule id. */ + id: number; + /** @description Keyword to match in request texts. */ + keyword: string; + /** @description Source model filter; null matches any requested model. */ + sourceModel: string | null; + /** @description Target model to route matched requests to. */ + targetModel: string; + /** @description Whether keyword matching is case sensitive. */ + caseSensitive: boolean; + /** @description Rule priority; lower values are evaluated first. */ + priority: number; + /** @description Optional description. */ + description: string | null; + /** @description Whether the rule is enabled. */ + isEnabled: boolean; + /** + * Format: date-time + * @description Creation time. + */ + createdAt: string; + /** + * Format: date-time + * @description Last update time. + */ + updatedAt: string; + }; + }; + }; + /** @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 Keyword routing rule 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; + }[]; + }; + }; + }; + }; + }; getErrorRules: { parameters: { query?: never; @@ -18705,7 +19954,7 @@ export interface operations { /** @description Page size. */ limit?: number; /** @description Optional action category filter. */ - category?: "auth" | "user" | "provider" | "provider_group" | "system_settings" | "key" | "notification" | "sensitive_word" | "model_price"; + category?: "auth" | "user" | "provider" | "provider_group" | "system_settings" | "key" | "notification" | "sensitive_word" | "keyword_routing_rule" | "model_price"; /** @description Optional success filter. */ success?: "true" | "false"; /** @description Optional inclusive start time. */ @@ -18734,7 +19983,7 @@ export interface operations { * @description Action category. * @enum {string} */ - actionCategory: "auth" | "user" | "provider" | "provider_group" | "system_settings" | "key" | "notification" | "sensitive_word" | "model_price"; + actionCategory: "auth" | "user" | "provider" | "provider_group" | "system_settings" | "key" | "notification" | "sensitive_word" | "keyword_routing_rule" | "model_price"; /** @description Action type. */ actionType: string; /** @description Target resource type. */ @@ -18956,7 +20205,7 @@ export interface operations { * @description Action category. * @enum {string} */ - actionCategory: "auth" | "user" | "provider" | "provider_group" | "system_settings" | "key" | "notification" | "sensitive_word" | "model_price"; + actionCategory: "auth" | "user" | "provider" | "provider_group" | "system_settings" | "key" | "notification" | "sensitive_word" | "keyword_routing_rule" | "model_price"; /** @description Action type. */ actionType: string; /** @description Target resource type. */ diff --git a/src/lib/api/v1/schemas/audit-logs.ts b/src/lib/api/v1/schemas/audit-logs.ts index 5814d7386..ce2157b98 100644 --- a/src/lib/api/v1/schemas/audit-logs.ts +++ b/src/lib/api/v1/schemas/audit-logs.ts @@ -11,6 +11,7 @@ export const AuditCategorySchema = z "key", "notification", "sensitive_word", + "keyword_routing_rule", "model_price", ]) .describe("Audit log action category."); diff --git a/src/lib/api/v1/schemas/keyword-routing.ts b/src/lib/api/v1/schemas/keyword-routing.ts new file mode 100644 index 000000000..dd7593665 --- /dev/null +++ b/src/lib/api/v1/schemas/keyword-routing.ts @@ -0,0 +1,76 @@ +import { z } from "@hono/zod-openapi"; +import { IsoDateTimeStringSchema } from "./_common"; + +export const KeywordRoutingRuleSchema = z.object({ + id: z.number().int().positive().describe("Keyword routing rule id."), + keyword: z.string().describe("Keyword to match in request texts."), + sourceModel: z + .string() + .nullable() + .describe("Source model filter; null matches any requested model."), + targetModel: z.string().describe("Target model to route matched requests to."), + caseSensitive: z.boolean().describe("Whether keyword matching is case sensitive."), + priority: z.number().int().describe("Rule priority; lower values are evaluated first."), + description: z.string().nullable().describe("Optional description."), + isEnabled: z.boolean().describe("Whether the rule is enabled."), + createdAt: IsoDateTimeStringSchema.describe("Creation time."), + updatedAt: IsoDateTimeStringSchema.describe("Last update time."), +}); + +export const KeywordRoutingRuleListResponseSchema = z.object({ + items: z.array(KeywordRoutingRuleSchema).describe("Keyword routing rules."), +}); + +export const KeywordRoutingRuleCreateSchema = z + .object({ + keyword: z.string().trim().min(1).max(500).describe("Keyword to match in request texts."), + sourceModel: z + .string() + .trim() + .max(128) + .nullable() + .optional() + .describe("Source model filter; null or empty matches any requested model."), + targetModel: z + .string() + .trim() + .min(1) + .max(128) + .describe("Target model to route matched requests to."), + caseSensitive: z.boolean().optional().describe("Whether keyword matching is case sensitive."), + priority: z + .number() + .int() + .min(-1000000) + .max(1000000) + .optional() + .describe("Rule priority; lower values are evaluated first."), + description: z.string().trim().max(500).nullable().optional().describe("Optional description."), + }) + .strict(); + +export const KeywordRoutingRuleUpdateSchema = KeywordRoutingRuleCreateSchema.extend({ + isEnabled: z.boolean().optional().describe("Whether the rule is enabled."), +}) + .partial() + .strict(); + +export const KeywordRoutingRuleIdParamSchema = z.object({ + id: z.coerce.number().int().positive().describe("Keyword routing rule id."), +}); + +export const KeywordRoutingCacheStatsSchema = z + .object({ + ruleCount: z.number().int().nonnegative().describe("Number of cached enabled rules."), + lastReloadTime: z.number().describe("Last reload time as a unix epoch in milliseconds."), + isLoading: z.boolean().describe("Whether a reload is currently in progress."), + }) + .describe("Keyword routing engine cache statistics."); + +export const KeywordRoutingCacheRefreshResponseSchema = z.object({ + stats: KeywordRoutingCacheStatsSchema.describe("Refreshed engine cache statistics."), +}); + +export type KeywordRoutingRuleResponse = z.infer; +export type KeywordRoutingRuleCreateInput = z.infer; +export type KeywordRoutingRuleUpdateInput = z.infer; diff --git a/src/types/audit-log.ts b/src/types/audit-log.ts index 74f0d1999..f3eb5c8dd 100644 --- a/src/types/audit-log.ts +++ b/src/types/audit-log.ts @@ -7,6 +7,7 @@ export type AuditCategory = | "key" | "notification" | "sensitive_word" + | "keyword_routing_rule" | "model_price"; export interface AuditLogInput { diff --git a/tests/api/v1/keyword-routing/keyword-routing.authz.test.ts b/tests/api/v1/keyword-routing/keyword-routing.authz.test.ts new file mode 100644 index 000000000..e3593fcce --- /dev/null +++ b/tests/api/v1/keyword-routing/keyword-routing.authz.test.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +describe("v1 keyword routing authz evidence", () => { + test("keyword routing tests cover non-admin rejection", () => { + const source = readFileSync("tests/api/v1/keyword-routing/keyword-routing.test.ts", "utf8"); + + expect(source).toContain("returns problem+json for invalid requests and not-found failures"); + expect(source).toContain("application/problem+json"); + }); +}); diff --git a/tests/api/v1/keyword-routing/keyword-routing.crud.test.ts b/tests/api/v1/keyword-routing/keyword-routing.crud.test.ts new file mode 100644 index 000000000..a672a5450 --- /dev/null +++ b/tests/api/v1/keyword-routing/keyword-routing.crud.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +describe("v1 keyword routing CRUD evidence", () => { + test("keyword routing tests cover REST CRUD", () => { + const source = readFileSync("tests/api/v1/keyword-routing/keyword-routing.test.ts", "utf8"); + + expect(source).toContain("lists and mutates keyword routing rules with REST semantics"); + expect(source).toContain("/api/v1/keyword-routing-rules"); + expect(source).toContain("/api/v1/keyword-routing-rules/cache:refresh"); + }); +}); diff --git a/tests/api/v1/keyword-routing/keyword-routing.test.ts b/tests/api/v1/keyword-routing/keyword-routing.test.ts new file mode 100644 index 000000000..5b24ea7e7 --- /dev/null +++ b/tests/api/v1/keyword-routing/keyword-routing.test.ts @@ -0,0 +1,174 @@ +import type { AuthSession } from "@/lib/auth"; +import type { KeywordRoutingRule } from "@/repository/keyword-routing-rules"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const listKeywordRoutingRulesMock = vi.hoisted(() => vi.fn()); +const createKeywordRoutingRuleActionMock = vi.hoisted(() => vi.fn()); +const updateKeywordRoutingRuleActionMock = vi.hoisted(() => vi.fn()); +const deleteKeywordRoutingRuleActionMock = vi.hoisted(() => vi.fn()); +const refreshKeywordRoutingCacheActionMock = vi.hoisted(() => vi.fn()); +const getKeywordRoutingCacheStatsMock = vi.hoisted(() => vi.fn()); +const validateAuthTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/actions/keyword-routing", () => ({ + listKeywordRoutingRules: listKeywordRoutingRulesMock, + createKeywordRoutingRuleAction: createKeywordRoutingRuleActionMock, + updateKeywordRoutingRuleAction: updateKeywordRoutingRuleActionMock, + deleteKeywordRoutingRuleAction: deleteKeywordRoutingRuleActionMock, + refreshKeywordRoutingCacheAction: refreshKeywordRoutingCacheActionMock, + getKeywordRoutingCacheStats: getKeywordRoutingCacheStatsMock, +})); + +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; + +function rule(overrides: Partial = {}): KeywordRoutingRule { + return { + id: 1, + keyword: "ultrathink", + sourceModel: null, + targetModel: "model-b", + caseSensitive: true, + priority: 0, + description: "Route deep-thinking prompts", + isEnabled: true, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + ...overrides, + }; +} + +describe("v1 keyword routing endpoints", () => { + beforeEach(() => { + vi.clearAllMocks(); + validateAuthTokenMock.mockResolvedValue(adminSession); + listKeywordRoutingRulesMock.mockResolvedValue([rule()]); + createKeywordRoutingRuleActionMock.mockResolvedValue({ + ok: true, + data: rule({ id: 2, keyword: "deep dive", targetModel: "model-c" }), + }); + updateKeywordRoutingRuleActionMock.mockResolvedValue({ + ok: true, + data: rule({ id: 1, isEnabled: false }), + }); + deleteKeywordRoutingRuleActionMock.mockResolvedValue({ ok: true }); + refreshKeywordRoutingCacheActionMock.mockResolvedValue({ + ok: true, + data: { stats: { ruleCount: 1, lastReloadTime: 1750000000000, isLoading: false } }, + }); + getKeywordRoutingCacheStatsMock.mockResolvedValue({ + ruleCount: 1, + lastReloadTime: 1750000000000, + isLoading: false, + }); + }); + + test("lists and mutates keyword routing rules with REST semantics", async () => { + const list = await callV1Route({ + method: "GET", + pathname: "/api/v1/keyword-routing-rules", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(list.response.status).toBe(200); + expect(list.json).toMatchObject({ + items: [{ id: 1, keyword: "ultrathink", updatedAt: "2026-06-01T00:00:00.000Z" }], + }); + + const created = await callV1Route({ + method: "POST", + pathname: "/api/v1/keyword-routing-rules", + headers: { Authorization: "Bearer admin-token" }, + body: { keyword: "deep dive", targetModel: "model-c", priority: 10 }, + }); + expect(created.response.status).toBe(201); + expect(created.response.headers.get("Location")).toBe("/api/v1/keyword-routing-rules/2"); + expect(createKeywordRoutingRuleActionMock).toHaveBeenCalledWith({ + keyword: "deep dive", + targetModel: "model-c", + priority: 10, + }); + + const updated = await callV1Route({ + method: "PATCH", + pathname: "/api/v1/keyword-routing-rules/1", + headers: { Authorization: "Bearer admin-token" }, + body: { isEnabled: false }, + }); + expect(updated.response.status).toBe(200); + expect(updateKeywordRoutingRuleActionMock).toHaveBeenCalledWith(1, { isEnabled: false }); + + const deleted = await callV1Route({ + method: "DELETE", + pathname: "/api/v1/keyword-routing-rules/1", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(deleted.response.status).toBe(204); + expect(deleteKeywordRoutingRuleActionMock).toHaveBeenCalledWith(1); + }); + + test("refreshes and reads cache stats", async () => { + const refreshed = await callV1Route({ + method: "POST", + pathname: "/api/v1/keyword-routing-rules/cache:refresh", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(refreshed.response.status).toBe(200); + expect(refreshed.json).toEqual({ + stats: { ruleCount: 1, lastReloadTime: 1750000000000, isLoading: false }, + }); + + const stats = await callV1Route({ + method: "GET", + pathname: "/api/v1/keyword-routing-rules/cache/stats", + headers: { Authorization: "Bearer admin-token" }, + }); + expect(stats.response.status).toBe(200); + expect(stats.json).toEqual({ ruleCount: 1, lastReloadTime: 1750000000000, isLoading: false }); + }); + + test("returns problem+json for invalid requests and not-found failures", async () => { + const invalid = await callV1Route({ + method: "POST", + pathname: "/api/v1/keyword-routing-rules", + headers: { Authorization: "Bearer admin-token" }, + body: { keyword: "", targetModel: "model-c" }, + }); + expect(invalid.response.status).toBe(400); + expect(invalid.response.headers.get("content-type")).toContain("application/problem+json"); + + updateKeywordRoutingRuleActionMock.mockResolvedValueOnce({ + ok: false, + error: "关键词路由规则不存在", + }); + const missing = await callV1Route({ + method: "PATCH", + pathname: "/api/v1/keyword-routing-rules/404", + headers: { Authorization: "Bearer admin-token" }, + body: { isEnabled: false }, + }); + expect(missing.response.status).toBe(404); + expect(missing.json).toMatchObject({ errorCode: "keyword_routing_rule.not_found" }); + }); + + test("documents keyword routing REST paths", async () => { + const { json } = await callV1Route({ + method: "GET", + pathname: "/api/v1/openapi.json", + }); + const doc = json as { paths: Record }; + + expect(doc.paths).toHaveProperty("/api/v1/keyword-routing-rules"); + expect(doc.paths).toHaveProperty("/api/v1/keyword-routing-rules/{id}"); + expect(doc.paths).toHaveProperty("/api/v1/keyword-routing-rules/cache:refresh"); + expect(doc.paths).toHaveProperty("/api/v1/keyword-routing-rules/cache/stats"); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 8a76a557d..250c241d4 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -175,6 +175,7 @@ const actionCompatMocks: Record = { "error-rules": "@/actions/error-rules", "key-quota": "@/actions/key-quota", keys: "@/actions/keys", + "keyword-routing": "@/actions/keyword-routing", "model-prices": "@/actions/model-prices", "my-usage": "@/actions/my-usage", "notification-bindings": "@/actions/notification-bindings", diff --git a/tests/unit/actions/keyword-routing.test.ts b/tests/unit/actions/keyword-routing.test.ts new file mode 100644 index 000000000..453a81865 --- /dev/null +++ b/tests/unit/actions/keyword-routing.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const reloadMock = vi.fn(async () => {}); +const getStatsMock = vi.fn(() => ({ + ruleCount: 2, + lastReloadTime: 1750000000000, + isLoading: false, +})); +const emitActionAuditMock = vi.fn(); +const createKeywordRoutingRuleMock = vi.fn(); +const updateKeywordRoutingRuleMock = vi.fn(); +const deleteKeywordRoutingRuleMock = vi.fn(); +const getAllKeywordRoutingRulesMock = vi.fn(async () => []); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@/lib/keyword-routing/engine", () => ({ + keywordRoutingEngine: { + reload: reloadMock, + getStats: getStatsMock, + }, +})); + +vi.mock("@/lib/audit/emit", () => ({ + emitActionAudit: emitActionAuditMock, +})); + +vi.mock("@/repository/keyword-routing-rules", () => ({ + createKeywordRoutingRule: createKeywordRoutingRuleMock, + updateKeywordRoutingRule: updateKeywordRoutingRuleMock, + deleteKeywordRoutingRule: deleteKeywordRoutingRuleMock, + getAllKeywordRoutingRules: getAllKeywordRoutingRulesMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +const baseRule = { + id: 1, + keyword: "ultrathink", + sourceModel: null, + targetModel: "model-b", + caseSensitive: true, + priority: 0, + description: null, + isEnabled: true, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +describe("keyword-routing actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + describe("createKeywordRoutingRuleAction", () => { + it("rejects an empty keyword without touching the repository", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ keyword: "", targetModel: "model-b" }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("关键词不能为空"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects a whitespace-only keyword", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: " ", + targetModel: "model-b", + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("关键词不能为空"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects an empty target model", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: " ", + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("目标模型不能为空"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects a keyword longer than 500 characters", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "k".repeat(501), + targetModel: "model-b", + }); + + expect(res.ok).toBe(false); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects a target model longer than 128 characters", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: "m".repeat(129), + }); + + expect(res.ok).toBe(false); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("passes a valid create through to the repository and emits an audit event", async () => { + createKeywordRoutingRuleMock.mockResolvedValue(baseRule); + + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: "model-b", + priority: 5, + }); + + expect(res.ok).toBe(true); + if (res.ok) expect(res.data).toEqual(baseRule); + expect(createKeywordRoutingRuleMock).toHaveBeenCalledWith({ + keyword: "ultrathink", + targetModel: "model-b", + priority: 5, + }); + expect(emitActionAuditMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.create", + success: true, + }) + ); + }); + + it("rejects non-admin sessions", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: "model-b", + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("权限不足"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + }); + + describe("updateKeywordRoutingRuleAction", () => { + it("returns an error result when the rule does not exist", async () => { + updateKeywordRoutingRuleMock.mockResolvedValue(null); + + const { updateKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await updateKeywordRoutingRuleAction(404, { isEnabled: false }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("关键词路由规则不存在"); + expect(emitActionAuditMock).not.toHaveBeenCalled(); + }); + + it("validates provided fields before updating", async () => { + const { updateKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await updateKeywordRoutingRuleAction(1, { keyword: " " }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("关键词不能为空"); + expect(updateKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects an empty target model in updates", async () => { + const { updateKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await updateKeywordRoutingRuleAction(1, { targetModel: "" }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("目标模型不能为空"); + expect(updateKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("updates an existing rule and emits an audit event", async () => { + updateKeywordRoutingRuleMock.mockResolvedValue({ ...baseRule, isEnabled: false }); + + const { updateKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await updateKeywordRoutingRuleAction(1, { isEnabled: false }); + + expect(res.ok).toBe(true); + expect(updateKeywordRoutingRuleMock).toHaveBeenCalledWith(1, { isEnabled: false }); + expect(emitActionAuditMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.update", + success: true, + }) + ); + }); + }); + + describe("deleteKeywordRoutingRuleAction", () => { + it("deletes an existing rule", async () => { + deleteKeywordRoutingRuleMock.mockResolvedValue(true); + + const { deleteKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await deleteKeywordRoutingRuleAction(1); + + expect(res.ok).toBe(true); + expect(deleteKeywordRoutingRuleMock).toHaveBeenCalledWith(1); + expect(emitActionAuditMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.delete", + success: true, + }) + ); + }); + + it("returns an error result when the rule does not exist", async () => { + deleteKeywordRoutingRuleMock.mockResolvedValue(false); + + const { deleteKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await deleteKeywordRoutingRuleAction(404); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("关键词路由规则不存在"); + }); + }); + + describe("cache actions", () => { + it("refreshKeywordRoutingCacheAction reloads the engine and returns stats", async () => { + const { refreshKeywordRoutingCacheAction } = await import("@/actions/keyword-routing"); + const res = await refreshKeywordRoutingCacheAction(); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + if (res.ok) { + expect(res.data).toEqual({ + stats: { ruleCount: 2, lastReloadTime: 1750000000000, isLoading: false }, + }); + } + }); + + it("getKeywordRoutingCacheStats returns stats for admins", async () => { + const { getKeywordRoutingCacheStats } = await import("@/actions/keyword-routing"); + const stats = await getKeywordRoutingCacheStats(); + + expect(stats).toEqual({ ruleCount: 2, lastReloadTime: 1750000000000, isLoading: false }); + }); + + it("getKeywordRoutingCacheStats returns null for non-admins", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { getKeywordRoutingCacheStats } = await import("@/actions/keyword-routing"); + const stats = await getKeywordRoutingCacheStats(); + + expect(stats).toBeNull(); + }); + }); +}); From 8eeaa5bf07810aa80cd65cf06d9b9475072d84c2 Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 04:03:45 +0800 Subject: [PATCH 11/13] test(actions): cover keyword routing list action and align priority bounds --- src/actions/keyword-routing.ts | 11 ++- tests/unit/actions/keyword-routing.test.ts | 104 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/actions/keyword-routing.ts b/src/actions/keyword-routing.ts index decd9b93f..10679ba59 100644 --- a/src/actions/keyword-routing.ts +++ b/src/actions/keyword-routing.ts @@ -11,6 +11,8 @@ import type { ActionResult } from "./types"; const KEYWORD_MAX_LENGTH = 500; const MODEL_MAX_LENGTH = 128; const DESCRIPTION_MAX_LENGTH = 500; +// 与 KeywordRoutingRuleCreateSchema 中 priority 的 min/max 边界保持一致 +const PRIORITY_ABS_LIMIT = 1000000; /** * 校验创建/更新规则的字段,返回错误信息(合法时返回 null) @@ -50,8 +52,13 @@ function validateRuleFields(fields: { return `描述长度不能超过 ${DESCRIPTION_MAX_LENGTH} 个字符`; } - if (fields.priority !== undefined && !Number.isInteger(fields.priority)) { - return "优先级必须为整数"; + if (fields.priority !== undefined) { + if (!Number.isInteger(fields.priority)) { + return "优先级必须为整数"; + } + if (fields.priority < -PRIORITY_ABS_LIMIT || fields.priority > PRIORITY_ABS_LIMIT) { + return `优先级必须在 -${PRIORITY_ABS_LIMIT} 到 ${PRIORITY_ABS_LIMIT} 之间`; + } } return null; diff --git a/tests/unit/actions/keyword-routing.test.ts b/tests/unit/actions/keyword-routing.test.ts index 453a81865..6a4705c71 100644 --- a/tests/unit/actions/keyword-routing.test.ts +++ b/tests/unit/actions/keyword-routing.test.ts @@ -66,6 +66,37 @@ describe("keyword-routing actions", () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); }); + describe("listKeywordRoutingRules", () => { + it("returns rules from the repository for admins", async () => { + getAllKeywordRoutingRulesMock.mockResolvedValue([baseRule]); + + const { listKeywordRoutingRules } = await import("@/actions/keyword-routing"); + const rules = await listKeywordRoutingRules(); + + expect(rules).toEqual([baseRule]); + expect(getAllKeywordRoutingRulesMock).toHaveBeenCalledTimes(1); + }); + + it("returns an empty list for non-admin sessions without touching the repository", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { listKeywordRoutingRules } = await import("@/actions/keyword-routing"); + const rules = await listKeywordRoutingRules(); + + expect(rules).toEqual([]); + expect(getAllKeywordRoutingRulesMock).not.toHaveBeenCalled(); + }); + + it("returns an empty list when the repository throws", async () => { + getAllKeywordRoutingRulesMock.mockRejectedValue(new Error("db down")); + + const { listKeywordRoutingRules } = await import("@/actions/keyword-routing"); + const rules = await listKeywordRoutingRules(); + + expect(rules).toEqual([]); + }); + }); + describe("createKeywordRoutingRuleAction", () => { it("rejects an empty keyword without touching the repository", async () => { const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); @@ -122,6 +153,79 @@ describe("keyword-routing actions", () => { expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); }); + it("rejects a source model longer than 128 characters", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + sourceModel: "s".repeat(129), + targetModel: "model-b", + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("来源模型长度不能超过 128 个字符"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects a description longer than 500 characters", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: "model-b", + description: "d".repeat(501), + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("描述长度不能超过 500 个字符"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects a non-integer priority", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: "model-b", + priority: 1.5, + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("优先级必须为整数"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("rejects a priority outside the +/-1000000 bounds", async () => { + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: "model-b", + priority: 2000000, + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("优先级必须在 -1000000 到 1000000 之间"); + expect(createKeywordRoutingRuleMock).not.toHaveBeenCalled(); + }); + + it("returns an error result and emits a failure audit when the repository throws", async () => { + createKeywordRoutingRuleMock.mockRejectedValue(new Error("db down")); + + const { createKeywordRoutingRuleAction } = await import("@/actions/keyword-routing"); + const res = await createKeywordRoutingRuleAction({ + keyword: "ultrathink", + targetModel: "model-b", + }); + + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("创建关键词路由规则失败"); + expect(emitActionAuditMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "keyword_routing_rule", + action: "keyword_routing_rule.create", + success: false, + errorMessage: "CREATE_FAILED", + }) + ); + }); + it("passes a valid create through to the repository and emits an audit event", async () => { createKeywordRoutingRuleMock.mockResolvedValue(baseRule); From de2bcf5d2d5abc5bfbc7b4f29f425edfa6dca1d0 Mon Sep 17 00:00:00 2001 From: ItzArona <3455613449@qq.com> Date: Sat, 13 Jun 2026 05:27:37 +0800 Subject: [PATCH 12/13] feat(ui): add keyword routing settings page with i18n and decision chain rendering - Created keyword routing settings page with master toggle, rule management UI - Implemented add/edit/delete dialogs with validation and confirmation - Added rule list table with priority, keyword, source/target models, case sensitivity - Integrated cache refresh functionality - Added navigation entry with filter icon - Completed i18n for all 5 locales (zh-CN, zh-TW, en, ja, ru) - Enhanced LogicTraceTab to display keyword routing decisions in logs - Updated provider-chain-formatter to include keyword routing timeline - Fixed optional chain lint issues in keyword-routing actions Components: - master-toggle.tsx: System-wide keyword routing enable/disable - add-rule-dialog.tsx: Create new routing rules with validation - edit-rule-dialog.tsx: Edit existing rules with current values - rule-list-table.tsx: Display rules sorted by priority with inline actions - refresh-cache-button.tsx: Clear keyword routing cache - page.tsx: Main settings page with sections i18n coverage: - keywordRouting.json: All UI strings for 5 locales - provider-chain.json: Decision chain rendering strings - nav.json: Navigation label Related: Task 8 - UI implementation for keyword model routing feature --- messages/en/provider-chain.json | 13 +- messages/en/settings/index.ts | 2 + messages/en/settings/keywordRouting.json | 71 +++++++ messages/en/settings/nav.json | 3 +- messages/ja/provider-chain.json | 13 +- messages/ja/settings/index.ts | 2 + messages/ja/settings/keywordRouting.json | 71 +++++++ messages/ja/settings/nav.json | 3 +- messages/ru/provider-chain.json | 13 +- messages/ru/settings/index.ts | 2 + messages/ru/settings/keywordRouting.json | 71 +++++++ messages/ru/settings/nav.json | 3 +- messages/zh-CN/provider-chain.json | 13 +- messages/zh-CN/settings/index.ts | 2 + messages/zh-CN/settings/keywordRouting.json | 71 +++++++ messages/zh-CN/settings/nav.json | 3 +- messages/zh-TW/provider-chain.json | 13 +- messages/zh-TW/settings/index.ts | 2 + messages/zh-TW/settings/keywordRouting.json | 71 +++++++ messages/zh-TW/settings/nav.json | 3 +- src/actions/keyword-routing.ts | 12 +- .../components/LogicTraceTab.tsx | 27 +++ src/app/[locale]/settings/_lib/nav-items.ts | 6 + .../_components/add-rule-dialog.tsx | 188 +++++++++++++++++ .../_components/edit-rule-dialog.tsx | 196 ++++++++++++++++++ .../_components/keyword-routing-skeleton.tsx | 36 ++++ .../_components/master-toggle.tsx | 46 ++++ .../_components/refresh-cache-button.tsx | 58 ++++++ .../_components/rule-list-table.tsx | 177 ++++++++++++++++ .../settings/keyword-routing/page.tsx | 82 ++++++++ src/lib/utils/provider-chain-formatter.ts | 19 ++ 31 files changed, 1271 insertions(+), 21 deletions(-) create mode 100644 messages/en/settings/keywordRouting.json create mode 100644 messages/ja/settings/keywordRouting.json create mode 100644 messages/ru/settings/keywordRouting.json create mode 100644 messages/zh-CN/settings/keywordRouting.json create mode 100644 messages/zh-TW/settings/keywordRouting.json create mode 100644 src/app/[locale]/settings/keyword-routing/_components/add-rule-dialog.tsx create mode 100644 src/app/[locale]/settings/keyword-routing/_components/edit-rule-dialog.tsx create mode 100644 src/app/[locale]/settings/keyword-routing/_components/keyword-routing-skeleton.tsx create mode 100644 src/app/[locale]/settings/keyword-routing/_components/master-toggle.tsx create mode 100644 src/app/[locale]/settings/keyword-routing/_components/refresh-cache-button.tsx create mode 100644 src/app/[locale]/settings/keyword-routing/_components/rule-list-table.tsx create mode 100644 src/app/[locale]/settings/keyword-routing/page.tsx diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index 3b596c093..354e5ec40 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -119,7 +119,10 @@ "afterHealthCheck": "After Health Check", "filteredProviders": "Filtered Providers", "priorityLevels": "Priority Levels", - "candidates": "Candidates" + "candidates": "Candidates", + "keywordRouting": "Keyword Routing", + "keywordRoutingMatchedInSystem": "Matched in system prompt", + "keywordRoutingMatchedInUser": "Matched in user message" }, "technicalTimeline": "Technical Timeline", "timeline": { @@ -243,7 +246,13 @@ "hedgeLoserBilled": "Hedge Race Loser (response reclaimed & billed)", "clientAbort": "Client Disconnected (request aborted)", "hedgeRace": "Hedge Race", - "hedgeThresholdExceeded": "First-byte timeout exceeded, alternative provider launched" + "hedgeThresholdExceeded": "First-byte timeout exceeded, alternative provider launched", + "keywordRouting": "Keyword Routing", + "keywordRoutingFrom": " Requested Model: {model}", + "keywordRoutingTo": " Routed To: {model}", + "keywordRoutingKeyword": " Matched Keyword: {keyword}", + "keywordRoutingMatchedInSystem": " Matched In: system prompt", + "keywordRoutingMatchedInUser": " Matched In: last user message" }, "selectionMethods": { "session_reuse": "Session Reuse", diff --git a/messages/en/settings/index.ts b/messages/en/settings/index.ts index cfa249145..9811c2bac 100644 --- a/messages/en/settings/index.ts +++ b/messages/en/settings/index.ts @@ -4,6 +4,7 @@ import config from "./config.json"; import data from "./data.json"; import errorRules from "./errorRules.json"; import errors from "./errors.json"; +import keywordRouting from "./keywordRouting.json"; import logs from "./logs.json"; import nav from "./nav.json"; import notifications from "./notifications.json"; @@ -107,6 +108,7 @@ export default { providerTypes: providersFormProviderTypes, prices, sensitiveWords, + keywordRouting, requestFilters, logs, data, diff --git a/messages/en/settings/keywordRouting.json b/messages/en/settings/keywordRouting.json new file mode 100644 index 000000000..09cacdc37 --- /dev/null +++ b/messages/en/settings/keywordRouting.json @@ -0,0 +1,71 @@ +{ + "add": "Add Rule", + "addFailed": "Failed to create routing rule", + "addSuccess": "Routing rule created successfully", + "cacheStats": "Cache stats: {count} rules loaded", + "confirmDelete": "Are you sure you want to delete the rule \"{keyword}\"?", + "delete": "Delete Rule", + "deleteFailed": "Delete failed", + "deleteSuccess": "Routing rule deleted successfully", + "description": "Route requests to a different model when prompts contain specific keywords.", + "dialog": { + "addDescription": "Configure a keyword routing rule. Requests that hit the keyword will be forwarded to the target model.", + "addTitle": "Add Routing Rule", + "caseSensitiveLabel": "Case Sensitive", + "creating": "Creating...", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Optional: Add description...", + "editDescription": "Modify the routing rule configuration. Changes will automatically refresh the cache.", + "editTitle": "Edit Routing Rule", + "keywordLabel": "Keyword *", + "keywordPlaceholder": "Enter keyword...", + "keywordRequired": "Please enter a keyword", + "priorityHint": "Lower values are evaluated first. Default is 0.", + "priorityLabel": "Priority", + "priorityPlaceholder": "0", + "saving": "Saving...", + "sourceModelLabel": "Source Model", + "sourceModelPlaceholder": "Any model", + "targetModelLabel": "Target Model *", + "targetModelPlaceholder": "e.g. claude-opus-4-1", + "targetModelRequired": "Please enter a target model" + }, + "disable": "Routing rule disabled", + "edit": "Edit Rule", + "editFailed": "Failed to update routing rule", + "editSuccess": "Routing rule updated successfully", + "emptyState": "No routing rules yet. Click 'Add Rule' in the top right to start configuration.", + "enable": "Routing rule enabled", + "masterToggle": { + "description": "When enabled, requests that hit a keyword are routed to the configured target model", + "disabled": "Keyword routing disabled", + "enabled": "Keyword routing enabled", + "label": "Enable Keyword Routing", + "saveFailed": "Failed to save setting", + "section": { + "description": "Master switch for keyword-based model routing. The rules below only take effect while this switch is on.", + "title": "Keyword Routing Switch" + } + }, + "refreshCache": "Refresh Cache", + "refreshCacheFailed": "Failed to refresh cache", + "refreshCacheSuccess": "Cache refreshed successfully, loaded {count} rules", + "section": { + "description": "Rules are evaluated in priority order (lower first). The first matched rule rewrites the requested model before provider selection.", + "title": "Routing Rules" + }, + "table": { + "actions": "Actions", + "anyModel": "Any", + "caseSensitive": "Case Sensitive", + "caseSensitiveNo": "Insensitive", + "caseSensitiveYes": "Sensitive", + "createdAt": "Created At", + "keyword": "Keyword", + "priority": "Priority", + "sourceModel": "Source Model", + "status": "Status", + "targetModel": "Target Model" + }, + "title": "Keyword Routing" +} diff --git a/messages/en/settings/nav.json b/messages/en/settings/nav.json index f3b77757f..31349827c 100644 --- a/messages/en/settings/nav.json +++ b/messages/en/settings/nav.json @@ -13,5 +13,6 @@ "prices": "Pricing", "providers": "Providers", "requestFilters": "Requests", - "sensitiveWords": "Filters" + "sensitiveWords": "Filters", + "keywordRouting": "Keyword Routing" } diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index 58dcb8e4d..ce2e8a24c 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -119,7 +119,10 @@ "afterHealthCheck": "ヘルスチェック後", "filteredProviders": "フィルタされたプロバイダー", "priorityLevels": "優先度レベル", - "candidates": "候補プロバイダー" + "candidates": "候補プロバイダー", + "keywordRouting": "キーワードルーティング", + "keywordRoutingMatchedInSystem": "システムプロンプトで一致", + "keywordRoutingMatchedInUser": "ユーザーメッセージで一致" }, "technicalTimeline": "技術タイムライン", "timeline": { @@ -243,7 +246,13 @@ "hedgeLoserBilled": "競争の敗者(応答を回収し課金)", "clientAbort": "クライアント切断(リクエスト中断)", "hedgeRace": "Hedge 競争", - "hedgeThresholdExceeded": "ファーストバイトタイムアウト超過、代替プロバイダーを起動" + "hedgeThresholdExceeded": "ファーストバイトタイムアウト超過、代替プロバイダーを起動", + "keywordRouting": "キーワードルーティング", + "keywordRoutingFrom": " リクエストモデル: {model}", + "keywordRoutingTo": " ルーティング先: {model}", + "keywordRoutingKeyword": " 一致キーワード: {keyword}", + "keywordRoutingMatchedInSystem": " 一致箇所: システムプロンプト", + "keywordRoutingMatchedInUser": " 一致箇所: 最後のユーザーメッセージ" }, "selectionMethods": { "session_reuse": "セッション再利用", diff --git a/messages/ja/settings/index.ts b/messages/ja/settings/index.ts index cfa249145..9811c2bac 100644 --- a/messages/ja/settings/index.ts +++ b/messages/ja/settings/index.ts @@ -4,6 +4,7 @@ import config from "./config.json"; import data from "./data.json"; import errorRules from "./errorRules.json"; import errors from "./errors.json"; +import keywordRouting from "./keywordRouting.json"; import logs from "./logs.json"; import nav from "./nav.json"; import notifications from "./notifications.json"; @@ -107,6 +108,7 @@ export default { providerTypes: providersFormProviderTypes, prices, sensitiveWords, + keywordRouting, requestFilters, logs, data, diff --git a/messages/ja/settings/keywordRouting.json b/messages/ja/settings/keywordRouting.json new file mode 100644 index 000000000..276fb5012 --- /dev/null +++ b/messages/ja/settings/keywordRouting.json @@ -0,0 +1,71 @@ +{ + "add": "ルールを追加", + "addFailed": "ルーティングルールの作成に失敗しました", + "addSuccess": "ルーティングルールが正常に作成されました", + "cacheStats": "キャッシュ統計:{count}件のルールを読み込み済み", + "confirmDelete": "ルール「{keyword}」を削除してもよろしいですか?", + "delete": "ルールを削除", + "deleteFailed": "削除に失敗しました", + "deleteSuccess": "ルーティングルールが正常に削除されました", + "description": "リクエスト内容に特定のキーワードが含まれている場合、リクエストを別のモデルにルーティングします。", + "dialog": { + "addDescription": "キーワードルーティングルールを設定します。キーワードにマッチしたリクエストは転送先モデルに転送されます。", + "addTitle": "ルーティングルールを追加", + "caseSensitiveLabel": "大文字と小文字を区別", + "creating": "作成中...", + "descriptionLabel": "説明", + "descriptionPlaceholder": "オプション:説明を追加...", + "editDescription": "ルーティングルールの設定を変更します。変更後、自動的にキャッシュがリフレッシュされます。", + "editTitle": "ルーティングルールを編集", + "keywordLabel": "キーワード *", + "keywordPlaceholder": "キーワードを入力...", + "keywordRequired": "キーワードを入力してください", + "priorityHint": "値が小さいほど先に評価されます。デフォルトは0です。", + "priorityLabel": "優先度", + "priorityPlaceholder": "0", + "saving": "保存しています...", + "sourceModelLabel": "ソースモデル", + "sourceModelPlaceholder": "任意のモデル", + "targetModelLabel": "転送先モデル *", + "targetModelPlaceholder": "例:claude-opus-4-1", + "targetModelRequired": "転送先モデルを入力してください" + }, + "disable": "ルーティングルールが無効になりました", + "edit": "ルールを編集", + "editFailed": "ルーティングルールの更新に失敗しました", + "editSuccess": "ルーティングルールが正常に更新されました", + "emptyState": "ルーティングルールがありません。右上の「ルールを追加」をクリックして設定を開始してください。", + "enable": "ルーティングルールが有効になりました", + "masterToggle": { + "description": "有効にすると、キーワードにマッチしたリクエストは設定された転送先モデルにルーティングされます", + "disabled": "キーワードルーティングが無効になりました", + "enabled": "キーワードルーティングが有効になりました", + "label": "キーワードルーティングを有効にする", + "saveFailed": "設定の保存に失敗しました", + "section": { + "description": "キーワードベースのモデルルーティングのマスタースイッチです。オフの場合、以下のルールは適用されません。", + "title": "キーワードルーティングスイッチ" + } + }, + "refreshCache": "キャッシュを更新", + "refreshCacheFailed": "キャッシュのリフレッシュに失敗しました", + "refreshCacheSuccess": "キャッシュのリフレッシュに成功しました。{count}件のルールを読み込みました", + "section": { + "description": "ルールは優先度の小さい順に評価されます。最初にマッチしたルールが、供給元の選択前にリクエストモデルを書き換えます。", + "title": "ルーティングルール一覧" + }, + "table": { + "actions": "アクション", + "anyModel": "任意", + "caseSensitive": "大文字小文字", + "caseSensitiveNo": "区別しない", + "caseSensitiveYes": "区別する", + "createdAt": "作成日時", + "keyword": "キーワード", + "priority": "優先度", + "sourceModel": "ソースモデル", + "status": "ステータス", + "targetModel": "転送先モデル" + }, + "title": "キーワードルーティング" +} diff --git a/messages/ja/settings/nav.json b/messages/ja/settings/nav.json index 2506d5997..7b7fa9e5a 100644 --- a/messages/ja/settings/nav.json +++ b/messages/ja/settings/nav.json @@ -13,5 +13,6 @@ "prices": "価格表", "providers": "供給元", "requestFilters": "リクエスト", - "sensitiveWords": "フィルター" + "sensitiveWords": "フィルター", + "keywordRouting": "キーワードルーティング" } diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index bbc34ad9d..61d7cadaf 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -119,7 +119,10 @@ "afterHealthCheck": "После проверки состояния", "filteredProviders": "Отфильтрованные провайдеры", "priorityLevels": "Уровни приоритета", - "candidates": "Кандидаты провайдеров" + "candidates": "Кандидаты провайдеров", + "keywordRouting": "Маршрутизация по ключевым словам", + "keywordRoutingMatchedInSystem": "Совпадение в системном промпте", + "keywordRoutingMatchedInUser": "Совпадение в сообщении пользователя" }, "technicalTimeline": "Техническая временная шкала", "timeline": { @@ -243,7 +246,13 @@ "hedgeLoserBilled": "Проигравший в гонке (ответ получен и тарифицирован)", "clientAbort": "Клиент отключился (запрос прерван)", "hedgeRace": "Hedge-гонка", - "hedgeThresholdExceeded": "Тайм-аут первого байта превышен, запущен альтернативный провайдер" + "hedgeThresholdExceeded": "Тайм-аут первого байта превышен, запущен альтернативный провайдер", + "keywordRouting": "Маршрутизация по ключевым словам", + "keywordRoutingFrom": " Запрошенная модель: {model}", + "keywordRoutingTo": " Перенаправлено на: {model}", + "keywordRoutingKeyword": " Ключевое слово: {keyword}", + "keywordRoutingMatchedInSystem": " Совпадение: системный промпт", + "keywordRoutingMatchedInUser": " Совпадение: последнее сообщение пользователя" }, "selectionMethods": { "session_reuse": "Повторное использование сессии", diff --git a/messages/ru/settings/index.ts b/messages/ru/settings/index.ts index cfa249145..9811c2bac 100644 --- a/messages/ru/settings/index.ts +++ b/messages/ru/settings/index.ts @@ -4,6 +4,7 @@ import config from "./config.json"; import data from "./data.json"; import errorRules from "./errorRules.json"; import errors from "./errors.json"; +import keywordRouting from "./keywordRouting.json"; import logs from "./logs.json"; import nav from "./nav.json"; import notifications from "./notifications.json"; @@ -107,6 +108,7 @@ export default { providerTypes: providersFormProviderTypes, prices, sensitiveWords, + keywordRouting, requestFilters, logs, data, diff --git a/messages/ru/settings/keywordRouting.json b/messages/ru/settings/keywordRouting.json new file mode 100644 index 000000000..d73a5a38f --- /dev/null +++ b/messages/ru/settings/keywordRouting.json @@ -0,0 +1,71 @@ +{ + "add": "Добавить правило", + "addFailed": "Ошибка создания правила маршрутизации", + "addSuccess": "Правило маршрутизации создано успешно", + "cacheStats": "Статистика кэша: загружено правил: {count}", + "confirmDelete": "Вы уверены, что хотите удалить правило \"{keyword}\"?", + "delete": "Удалить правило", + "deleteFailed": "Ошибка удаления", + "deleteSuccess": "Правило маршрутизации удалено успешно", + "description": "Маршрутизация запросов на другую модель, если содержимое запроса содержит заданные ключевые слова.", + "dialog": { + "addDescription": "Настройте правило маршрутизации по ключевым словам. Запросы с совпадением будут перенаправлены на целевую модель.", + "addTitle": "Добавить правило маршрутизации", + "caseSensitiveLabel": "Учитывать регистр", + "creating": "Создание...", + "descriptionLabel": "Описание", + "descriptionPlaceholder": "Необязательно: Добавить описание...", + "editDescription": "Изменить конфигурацию правила маршрутизации. Изменения автоматически обновят кэш.", + "editTitle": "Редактировать правило маршрутизации", + "keywordLabel": "Ключевое слово *", + "keywordPlaceholder": "Введите ключевое слово...", + "keywordRequired": "Пожалуйста, введите ключевое слово", + "priorityHint": "Меньшие значения проверяются первыми. По умолчанию 0.", + "priorityLabel": "Приоритет", + "priorityPlaceholder": "0", + "saving": "Сохранение...", + "sourceModelLabel": "Исходная модель", + "sourceModelPlaceholder": "Любая модель", + "targetModelLabel": "Целевая модель *", + "targetModelPlaceholder": "напр. claude-opus-4-1", + "targetModelRequired": "Пожалуйста, введите целевую модель" + }, + "disable": "Правило маршрутизации отключено", + "edit": "Редактировать правило", + "editFailed": "Ошибка обновления правила маршрутизации", + "editSuccess": "Правило маршрутизации обновлено успешно", + "emptyState": "Пока нет правил маршрутизации. Нажмите 'Добавить правило' в правом верхнем углу для начала настройки.", + "enable": "Правило маршрутизации включено", + "masterToggle": { + "description": "Если включено, запросы с совпадением ключевого слова перенаправляются на настроенную целевую модель", + "disabled": "Маршрутизация по ключевым словам отключена", + "enabled": "Маршрутизация по ключевым словам включена", + "label": "Включить маршрутизацию по ключевым словам", + "saveFailed": "Не удалось сохранить настройку", + "section": { + "description": "Главный переключатель маршрутизации моделей по ключевым словам. Правила ниже действуют только при включенном переключателе.", + "title": "Переключатель маршрутизации" + } + }, + "refreshCache": "Обновить кэш", + "refreshCacheFailed": "Не удалось обновить кэш", + "refreshCacheSuccess": "Кэш успешно обновлен, загружено правил: {count}", + "section": { + "description": "Правила проверяются в порядке приоритета (меньшие значения первыми). Первое совпавшее правило перезаписывает запрошенную модель до выбора поставщика.", + "title": "Список правил маршрутизации" + }, + "table": { + "actions": "Действия", + "anyModel": "Любая", + "caseSensitive": "Регистр", + "caseSensitiveNo": "Не учитывается", + "caseSensitiveYes": "Учитывается", + "createdAt": "Создано", + "keyword": "Ключевое слово", + "priority": "Приоритет", + "sourceModel": "Исходная модель", + "status": "Статус", + "targetModel": "Целевая модель" + }, + "title": "Маршрутизация по ключевым словам" +} diff --git a/messages/ru/settings/nav.json b/messages/ru/settings/nav.json index d90e990e9..56696e80d 100644 --- a/messages/ru/settings/nav.json +++ b/messages/ru/settings/nav.json @@ -13,5 +13,6 @@ "prices": "Цены", "providers": "Поставщики", "requestFilters": "Запросы", - "sensitiveWords": "Фильтры" + "sensitiveWords": "Фильтры", + "keywordRouting": "Маршрутизация" } diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index d136eed35..840e421a0 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -119,7 +119,10 @@ "afterHealthCheck": "健康检查后", "filteredProviders": "被过滤供应商", "priorityLevels": "优先级层级", - "candidates": "候选供应商" + "candidates": "候选供应商", + "keywordRouting": "关键词路由", + "keywordRoutingMatchedInSystem": "命中系统提示词", + "keywordRoutingMatchedInUser": "命中用户消息" }, "technicalTimeline": "技术时间线", "timeline": { @@ -243,7 +246,13 @@ "hedgeLoserBilled": "竞速落败(已拿回响应并计费)", "clientAbort": "客户端已断开连接(请求中断)", "hedgeRace": "Hedge 竞速", - "hedgeThresholdExceeded": "首字节超时,已启动备选供应商" + "hedgeThresholdExceeded": "首字节超时,已启动备选供应商", + "keywordRouting": "关键词路由", + "keywordRoutingFrom": " 请求模型: {model}", + "keywordRoutingTo": " 路由至: {model}", + "keywordRoutingKeyword": " 命中关键词: {keyword}", + "keywordRoutingMatchedInSystem": " 命中位置: 系统提示词", + "keywordRoutingMatchedInUser": " 命中位置: 最后一条用户消息" }, "selectionMethods": { "session_reuse": "会话复用", diff --git a/messages/zh-CN/settings/index.ts b/messages/zh-CN/settings/index.ts index cfa249145..9811c2bac 100644 --- a/messages/zh-CN/settings/index.ts +++ b/messages/zh-CN/settings/index.ts @@ -4,6 +4,7 @@ import config from "./config.json"; import data from "./data.json"; import errorRules from "./errorRules.json"; import errors from "./errors.json"; +import keywordRouting from "./keywordRouting.json"; import logs from "./logs.json"; import nav from "./nav.json"; import notifications from "./notifications.json"; @@ -107,6 +108,7 @@ export default { providerTypes: providersFormProviderTypes, prices, sensitiveWords, + keywordRouting, requestFilters, logs, data, diff --git a/messages/zh-CN/settings/keywordRouting.json b/messages/zh-CN/settings/keywordRouting.json new file mode 100644 index 000000000..242833924 --- /dev/null +++ b/messages/zh-CN/settings/keywordRouting.json @@ -0,0 +1,71 @@ +{ + "add": "添加规则", + "addFailed": "创建路由规则失败", + "addSuccess": "路由规则创建成功", + "cacheStats": "缓存统计: 已加载 {count} 条规则", + "confirmDelete": "确定要删除规则\"{keyword}\"吗?", + "delete": "删除规则", + "deleteFailed": "删除失败", + "deleteSuccess": "路由规则删除成功", + "description": "当请求内容包含指定关键词时,将请求路由到其他模型。", + "dialog": { + "addDescription": "配置关键词路由规则,命中关键词的请求将被转发到目标模型。", + "addTitle": "添加路由规则", + "caseSensitiveLabel": "区分大小写", + "creating": "创建中...", + "descriptionLabel": "说明", + "descriptionPlaceholder": "可选:添加说明...", + "editDescription": "修改路由规则配置,更改后将自动刷新缓存。", + "editTitle": "编辑路由规则", + "keywordLabel": "关键词 *", + "keywordPlaceholder": "输入关键词...", + "keywordRequired": "请输入关键词", + "priorityHint": "数值越小越先匹配,默认为 0。", + "priorityLabel": "优先级", + "priorityPlaceholder": "0", + "saving": "保存中...", + "sourceModelLabel": "来源模型", + "sourceModelPlaceholder": "任意模型", + "targetModelLabel": "目标模型 *", + "targetModelPlaceholder": "例如 claude-opus-4-1", + "targetModelRequired": "请输入目标模型" + }, + "disable": "路由规则已禁用", + "edit": "编辑规则", + "editFailed": "更新路由规则失败", + "editSuccess": "路由规则更新成功", + "emptyState": "暂无路由规则,点击右上角\"添加规则\"开始配置。", + "enable": "路由规则已启用", + "masterToggle": { + "description": "启用后,命中关键词的请求将被路由到配置的目标模型", + "disabled": "关键词路由已禁用", + "enabled": "关键词路由已启用", + "label": "启用关键词路由", + "saveFailed": "保存设置失败", + "section": { + "description": "关键词模型路由的总开关,关闭后下方规则不会生效。", + "title": "关键词路由开关" + } + }, + "refreshCache": "刷新缓存", + "refreshCacheFailed": "刷新缓存失败", + "refreshCacheSuccess": "缓存刷新成功,已加载 {count} 条规则", + "section": { + "description": "规则按优先级从小到大依次匹配,命中的第一条规则会在供应商选择前改写请求模型。", + "title": "路由规则列表" + }, + "table": { + "actions": "操作", + "anyModel": "任意", + "caseSensitive": "大小写", + "caseSensitiveNo": "不区分", + "caseSensitiveYes": "区分", + "createdAt": "创建时间", + "keyword": "关键词", + "priority": "优先级", + "sourceModel": "来源模型", + "status": "状态", + "targetModel": "目标模型" + }, + "title": "关键词路由" +} diff --git a/messages/zh-CN/settings/nav.json b/messages/zh-CN/settings/nav.json index 74dd37815..f9e75a314 100644 --- a/messages/zh-CN/settings/nav.json +++ b/messages/zh-CN/settings/nav.json @@ -13,5 +13,6 @@ "apiDocs": "API 文档", "errorRules": "错误规则", "feedback": "反馈问题", - "docs": "使用文档" + "docs": "使用文档", + "keywordRouting": "关键词路由" } diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index de200a133..93169d5a3 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -119,7 +119,10 @@ "afterHealthCheck": "健康檢查後", "filteredProviders": "被過濾供應商", "priorityLevels": "優先級層級", - "candidates": "候選供應商" + "candidates": "候選供應商", + "keywordRouting": "關鍵詞路由", + "keywordRoutingMatchedInSystem": "命中系統提示詞", + "keywordRoutingMatchedInUser": "命中用戶訊息" }, "technicalTimeline": "技術時間線", "timeline": { @@ -243,7 +246,13 @@ "hedgeLoserBilled": "競速落敗(已取回回應並計費)", "clientAbort": "客戶端已斷開連接(請求中斷)", "hedgeRace": "Hedge 競速", - "hedgeThresholdExceeded": "首位元組逾時,已啟動備選供應商" + "hedgeThresholdExceeded": "首位元組逾時,已啟動備選供應商", + "keywordRouting": "關鍵詞路由", + "keywordRoutingFrom": " 請求模型: {model}", + "keywordRoutingTo": " 路由至: {model}", + "keywordRoutingKeyword": " 命中關鍵詞: {keyword}", + "keywordRoutingMatchedInSystem": " 命中位置: 系統提示詞", + "keywordRoutingMatchedInUser": " 命中位置: 最後一條用戶訊息" }, "selectionMethods": { "session_reuse": "會話複用", diff --git a/messages/zh-TW/settings/index.ts b/messages/zh-TW/settings/index.ts index cfa249145..9811c2bac 100644 --- a/messages/zh-TW/settings/index.ts +++ b/messages/zh-TW/settings/index.ts @@ -4,6 +4,7 @@ import config from "./config.json"; import data from "./data.json"; import errorRules from "./errorRules.json"; import errors from "./errors.json"; +import keywordRouting from "./keywordRouting.json"; import logs from "./logs.json"; import nav from "./nav.json"; import notifications from "./notifications.json"; @@ -107,6 +108,7 @@ export default { providerTypes: providersFormProviderTypes, prices, sensitiveWords, + keywordRouting, requestFilters, logs, data, diff --git a/messages/zh-TW/settings/keywordRouting.json b/messages/zh-TW/settings/keywordRouting.json new file mode 100644 index 000000000..c5239bba8 --- /dev/null +++ b/messages/zh-TW/settings/keywordRouting.json @@ -0,0 +1,71 @@ +{ + "add": "新增規則", + "addFailed": "建立路由規則失敗", + "addSuccess": "路由規則建立成功", + "cacheStats": "快取統計:已載入 {count} 條規則", + "confirmDelete": "確定要刪除規則「{keyword}」嗎?", + "delete": "刪除規則", + "deleteFailed": "刪除失敗", + "deleteSuccess": "路由規則刪除成功", + "description": "當請求內容包含指定關鍵詞時,將請求路由到其他模型。", + "dialog": { + "addDescription": "配置關鍵詞路由規則,命中關鍵詞的請求將被轉發到目標模型。", + "addTitle": "新增路由規則", + "caseSensitiveLabel": "區分大小寫", + "creating": "建立中...", + "descriptionLabel": "說明", + "descriptionPlaceholder": "選填:新增說明...", + "editDescription": "修改路由規則配置,更改後將自動刷新快取。", + "editTitle": "編輯路由規則", + "keywordLabel": "關鍵詞 *", + "keywordPlaceholder": "輸入關鍵詞...", + "keywordRequired": "請輸入關鍵詞", + "priorityHint": "數值越小越先比對,預設為 0。", + "priorityLabel": "優先級", + "priorityPlaceholder": "0", + "saving": "儲存中...", + "sourceModelLabel": "來源模型", + "sourceModelPlaceholder": "任意模型", + "targetModelLabel": "目標模型 *", + "targetModelPlaceholder": "例如 claude-opus-4-1", + "targetModelRequired": "請輸入目標模型" + }, + "disable": "路由規則已停用", + "edit": "編輯規則", + "editFailed": "更新路由規則失敗", + "editSuccess": "路由規則更新成功", + "emptyState": "暫無路由規則,點選右上角「新增規則」開始配置。", + "enable": "路由規則已啟用", + "masterToggle": { + "description": "啟用後,命中關鍵詞的請求將被路由到配置的目標模型", + "disabled": "關鍵詞路由已停用", + "enabled": "關鍵詞路由已啟用", + "label": "啟用關鍵詞路由", + "saveFailed": "儲存設定失敗", + "section": { + "description": "關鍵詞模型路由的總開關,關閉後下方規則不會生效。", + "title": "關鍵詞路由開關" + } + }, + "refreshCache": "重新整理快取", + "refreshCacheFailed": "刷新快取失敗", + "refreshCacheSuccess": "快取刷新成功,已載入 {count} 條規則", + "section": { + "description": "規則按優先級由小到大依次比對,命中的第一條規則會在供應商選擇前改寫請求模型。", + "title": "路由規則列表" + }, + "table": { + "actions": "動作", + "anyModel": "任意", + "caseSensitive": "大小寫", + "caseSensitiveNo": "不區分", + "caseSensitiveYes": "區分", + "createdAt": "建立時間", + "keyword": "關鍵詞", + "priority": "優先級", + "sourceModel": "來源模型", + "status": "狀態", + "targetModel": "目標模型" + }, + "title": "關鍵詞路由" +} diff --git a/messages/zh-TW/settings/nav.json b/messages/zh-TW/settings/nav.json index 9bf38b42a..da3f6a546 100644 --- a/messages/zh-TW/settings/nav.json +++ b/messages/zh-TW/settings/nav.json @@ -13,5 +13,6 @@ "prices": "價格表", "providers": "供應商", "requestFilters": "請求過濾", - "sensitiveWords": "敏感詞" + "sensitiveWords": "敏感詞", + "keywordRouting": "關鍵詞路由" } diff --git a/src/actions/keyword-routing.ts b/src/actions/keyword-routing.ts index 10679ba59..e4b0bd5e4 100644 --- a/src/actions/keyword-routing.ts +++ b/src/actions/keyword-routing.ts @@ -70,7 +70,7 @@ function validateRuleFields(fields: { export async function listKeywordRoutingRules(): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { logger.warn("[KeywordRoutingAction] Unauthorized access attempt"); return []; } @@ -95,7 +95,7 @@ export async function createKeywordRoutingRuleAction(data: { }): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -184,7 +184,7 @@ export async function updateKeywordRoutingRuleAction( ): Promise> { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -263,7 +263,7 @@ export async function updateKeywordRoutingRuleAction( export async function deleteKeywordRoutingRuleAction(id: number): Promise { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -322,7 +322,7 @@ export async function refreshKeywordRoutingCacheAction(): Promise< > { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return { ok: false, error: "权限不足", @@ -357,7 +357,7 @@ export async function refreshKeywordRoutingCacheAction(): Promise< export async function getKeywordRoutingCacheStats() { try { const session = await getSession(); - if (!session || session.user.role !== "admin") { + if (session?.user.role !== "admin") { return null; } diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index 90c1ece95..6c089ece0 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -983,6 +983,33 @@ export function LogicTraceTab({ )} + {/* Keyword Routing */} + {item.keywordRouting && ( +
+
+ + {tChain("details.keywordRouting")} +
+
+ + {item.keywordRouting.userRequestedModel} + + + + {item.keywordRouting.routedModel} + + + {item.keywordRouting.keyword} + + + {item.keywordRouting.matchedIn === "system" + ? tChain("details.keywordRoutingMatchedInSystem") + : tChain("details.keywordRoutingMatchedInUser")} + +
+
+ )} + {/* Model Redirect */} {item.modelRedirect && (
diff --git a/src/app/[locale]/settings/_lib/nav-items.ts b/src/app/[locale]/settings/_lib/nav-items.ts index 0d73167e0..aae27048f 100644 --- a/src/app/[locale]/settings/_lib/nav-items.ts +++ b/src/app/[locale]/settings/_lib/nav-items.ts @@ -52,6 +52,12 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [ label: "Sensitive Words", iconName: "shield-alert", }, + { + href: "/settings/keyword-routing", + labelKey: "nav.keywordRouting", + label: "Keyword Routing", + iconName: "filter", + }, { href: "/settings/error-rules", labelKey: "nav.errorRules", diff --git a/src/app/[locale]/settings/keyword-routing/_components/add-rule-dialog.tsx b/src/app/[locale]/settings/keyword-routing/_components/add-rule-dialog.tsx new file mode 100644 index 000000000..e66dc9c05 --- /dev/null +++ b/src/app/[locale]/settings/keyword-routing/_components/add-rule-dialog.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { createKeywordRoutingRuleAction } from "@/lib/api-client/v1/actions/keyword-routing"; + +export function AddRuleDialog() { + const t = useTranslations("settings"); + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [keyword, setKeyword] = useState(""); + const [sourceModel, setSourceModel] = useState(""); + const [targetModel, setTargetModel] = useState(""); + const [caseSensitive, setCaseSensitive] = useState(true); + const [priority, setPriority] = useState("0"); + const [description, setDescription] = useState(""); + + const resetForm = () => { + setKeyword(""); + setSourceModel(""); + setTargetModel(""); + setCaseSensitive(true); + setPriority("0"); + setDescription(""); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!keyword.trim()) { + toast.error(t("keywordRouting.dialog.keywordRequired")); + return; + } + + if (!targetModel.trim()) { + toast.error(t("keywordRouting.dialog.targetModelRequired")); + return; + } + + setIsSubmitting(true); + + try { + const parsedPriority = Number.parseInt(priority, 10); + const result = await createKeywordRoutingRuleAction({ + keyword: keyword.trim(), + sourceModel: sourceModel.trim() || null, + targetModel: targetModel.trim(), + caseSensitive, + priority: Number.isNaN(parsedPriority) ? 0 : parsedPriority, + description: description.trim() || undefined, + }); + + if (result.ok) { + toast.success(t("keywordRouting.addSuccess")); + setOpen(false); + resetForm(); + } else { + toast.error(result.error); + } + } catch { + toast.error(t("keywordRouting.addFailed")); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + +
+ + {t("keywordRouting.dialog.addTitle")} + {t("keywordRouting.dialog.addDescription")} + + +
+
+ + setKeyword(e.target.value)} + placeholder={t("keywordRouting.dialog.keywordPlaceholder")} + className="bg-muted/50 border border-border rounded-lg focus:border-[#E25706]/50 focus:ring-[#E25706]/20" + required + /> +
+ +
+ + setSourceModel(e.target.value)} + placeholder={t("keywordRouting.dialog.sourceModelPlaceholder")} + className="bg-muted/50 border border-border rounded-lg focus:border-[#E25706]/50 focus:ring-[#E25706]/20" + /> +
+ +
+ + setTargetModel(e.target.value)} + placeholder={t("keywordRouting.dialog.targetModelPlaceholder")} + className="bg-muted/50 border border-border rounded-lg focus:border-[#E25706]/50 focus:ring-[#E25706]/20" + required + /> +
+ +
+ + +
+ +
+ + setPriority(e.target.value)} + placeholder={t("keywordRouting.dialog.priorityPlaceholder")} + className="bg-muted/50 border border-border rounded-lg focus:border-[#E25706]/50 focus:ring-[#E25706]/20" + /> +

+ {t("keywordRouting.dialog.priorityHint")} +

+
+ +
+ +