diff --git a/drizzle/0024_glossy_silvermane.sql b/drizzle/0024_glossy_silvermane.sql new file mode 100644 index 000000000..afe993ba1 --- /dev/null +++ b/drizzle/0024_glossy_silvermane.sql @@ -0,0 +1,2 @@ +ALTER TABLE "providers" ADD COLUMN "mcp_passthrough_type" varchar(20) DEFAULT 'none' NOT NULL;--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN "mcp_passthrough_url" varchar(512); \ No newline at end of file diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json new file mode 100644 index 000000000..9753cfee5 --- /dev/null +++ b/drizzle/meta/0021_snapshot.json @@ -0,0 +1,1526 @@ +{ + "id": "a0d7b238-d013-4831-9f8c-02e0532bf035", + "prevId": "2cca68f8-d8c7-4298-9f24-c8fd493d700e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "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_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_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_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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": {} + } + }, + "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 + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30000 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 600000 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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'" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false, + "default": "'100.00'" + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "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_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_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 + } + }, + "enums": {}, + "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 0578897c7..1d8dbe145 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -162,6 +162,20 @@ "when": 1763739167236, "tag": "0022_simple_stardust", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1763823720001, + "tag": "0023_daily_limit_partial_indexes", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1763823720002, + "tag": "0024_glossy_silvermane", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/settings.json b/messages/en/settings.json index a825d4cda..66bfdab4b 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -705,6 +705,22 @@ "codexStrategyForceDesc": "Always use official Codex CLI instructions (~4000+ chars)", "codexStrategyForceLabel": "Force Official", "codexStrategyHint": "Hint: Some strict Codex gateways (e.g. 88code, foxcode) require official instructions. Choose \"Auto\" or \"Force Official\" strategy", + "mcpPassthroughConfig": "MCP Passthrough Configuration", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "Custom (Reserved)", + "mcpPassthroughConfigNone": "Disabled", + "mcpPassthroughDesc": "When enabled, pass through MCP tool calls to specified AI provider (e.g. minimax for image recognition, web search)", + "mcpPassthroughSelect": "Passthrough Type", + "mcpPassthroughNoneLabel": "Disabled", + "mcpPassthroughNoneDesc": "Do not enable MCP passthrough (default)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "Pass through to minimax MCP service (supports image recognition, web search, etc.)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "Pass through to GLM MCP service (supports image analysis, video analysis, etc.)", + "mcpPassthroughCustomLabel": "Custom", + "mcpPassthroughCustomDesc": "Pass through to custom MCP service (reserved, not implemented yet)", + "mcpPassthroughHint": "Hint: MCP passthrough allows Claude Code client to use tool capabilities provided by third-party AI providers (e.g. image recognition, web search)", "codexStrategyKeepDesc": "Always pass through client instructions, no auto retry (for lenient gateways)", "codexStrategyKeepLabel": "Keep Original", "codexStrategySelect": "Strategy Selection", @@ -1479,5 +1495,25 @@ "cannotDelete": "Default rules cannot be deleted", "cannotDisable": "Recommend keeping default rules enabled" } - } + }, + "mcpPassthroughConfig": "MCP Passthrough Configuration", + "mcpPassthroughConfigNone": "Disabled", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "Custom (Reserved)", + "mcpPassthroughDesc": "When enabled, pass through MCP tool calls to specified AI provider (e.g. minimax for image recognition, web search)", + "mcpPassthroughSelect": "Passthrough Type", + "mcpPassthroughNoneLabel": "Disabled", + "mcpPassthroughNoneDesc": "Do not enable MCP passthrough (default)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "Pass through to minimax MCP service (supports image recognition, web search, etc.)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "Pass through to GLM MCP service (supports image analysis, video analysis, etc.)", + "mcpPassthroughCustomLabel": "Custom", + "mcpPassthroughCustomDesc": "Pass through to custom MCP service (reserved, not implemented yet)", + "mcpPassthroughHint": "Hint: MCP passthrough allows Claude Code client to use tool capabilities provided by third-party AI providers (e.g. image recognition, web search)", + "mcpPassthroughUrlLabel": "MCP Passthrough URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP service base URL. Leave empty to auto-extract from provider URL", + "mcpPassthroughUrlAuto": "Auto-extracted: {url}" } diff --git a/messages/ja/settings.json b/messages/ja/settings.json index c4cfb21f0..75cebaf7f 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -1421,5 +1421,25 @@ "cannotDelete": "デフォルトルールは削除できません", "cannotDisable": "デフォルトルールは有効のままにすることをお勧めします" } - } + }, + "mcpPassthroughConfig": "MCP パススルー設定", + "mcpPassthroughConfigNone": "無効", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "カスタム (予約)", + "mcpPassthroughDesc": "有効にすると、MCP ツール呼び出しを指定された AI プロバイダにパススルーします(例:minimax の画像認識、Web 検索)", + "mcpPassthroughSelect": "パススルータイプ", + "mcpPassthroughNoneLabel": "無効", + "mcpPassthroughNoneDesc": "MCP パススルーを有効にしません(デフォルト)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "minimax MCP サービスにパススルー(画像認識、Web 検索などをサポート)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "GLM MCP サービスにパススルー(画像分析、動画分析などをサポート)", + "mcpPassthroughCustomLabel": "カスタム", + "mcpPassthroughCustomDesc": "カスタム MCP サービスにパススルー(予約、未実装)", + "mcpPassthroughHint": "ヒント: MCP パススルーにより、Claude Code クライアントは第三者の AI プロバイダ提供的ツール機能(画像認識、Web 検索など)を使用できます", + "mcpPassthroughUrlLabel": "MCP パススルー URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP サービスベース URL。空のままにすると、プロバイダ URL から自動的に抽出されます", + "mcpPassthroughUrlAuto": "自動抽出: {url}" } diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 6169e95e5..6a22b1e37 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -1421,5 +1421,25 @@ "cannotDelete": "Правила по умолчанию не могут быть удалены", "cannotDisable": "Рекомендуется сохранить правила по умолчанию включенными" } - } + }, + "mcpPassthroughConfig": "Конфигурация сквозной передачи MCP", + "mcpPassthroughConfigNone": "Отключено", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "GLM", + "mcpPassthroughConfigCustom": "Пользовательский (Зарезервировано)", + "mcpPassthroughDesc": "При включении передаёт вызовы инструментов MCP указанному AI-провайдеру (например, minimax для распознавания изображений, веб-поиска)", + "mcpPassthroughSelect": "Тип сквозной передачи", + "mcpPassthroughNoneLabel": "Отключено", + "mcpPassthroughNoneDesc": "Не включать сквозную передачу MCP (по умолчанию)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "Сквозная передача в сервис minimax MCP (поддержка распознавания изображений, веб-поиска и т.д.)", + "mcpPassthroughGlmLabel": "GLM", + "mcpPassthroughGlmDesc": "Сквозная передача в сервис GLM MCP (поддержка анализа изображений, видео и т.д.)", + "mcpPassthroughCustomLabel": "Пользовательский", + "mcpPassthroughCustomDesc": "Сквозная передача в пользовательский сервис MCP (зарезервировано, не реализовано)", + "mcpPassthroughHint": "Подсказка: сквозная передача MCP позволяет клиенту Claude Code использовать возможности инструментов, предоставляемых сторонними AI-провайдерами (например, распознавание изображений, веб-поиск)", + "mcpPassthroughUrlLabel": "URL сквозной передачи MCP", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "Базовый URL сервиса MCP. Оставьте пустым для автоматического извлечения из URL провайдера", + "mcpPassthroughUrlAuto": "Автоматически извлечено: {url}" } diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index dc1a7aaa6..9a84b74a2 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -722,6 +722,41 @@ "placeholder": "选择策略" }, "hint": "提示: 部分严格的 Codex 中转站(如 88code、foxcode)需要官方 instructions,选择\"自动\"或\"强制官方\"策略" + }, + "mcpPassthrough": { + "title": "MCP 透传配置", + "summary": { + "none": "不启用", + "minimax": "Minimax", + "glm": "智谱 GLM", + "custom": "自定义 (预留)" + }, + "desc": "启用后,将 MCP 工具调用透传到指定的 AI 服务商(如 minimax 的图片识别、联网搜索)", + "select": { + "label": "透传类型", + "none": { + "label": "不启用", + "desc": "不启用 MCP 透传功能(默认)" + }, + "minimax": { + "label": "Minimax", + "desc": "透传到 minimax MCP 服务(支持图片识别、联网搜索等工具)" + }, + "glm": { + "label": "智谱 GLM", + "desc": "透传到智谱 MCP 服务(支持图片分析、视频分析等工具)" + }, + "custom": { + "label": "自定义", + "desc": "透传到自定义 MCP 服务(预留,暂未实现)" + }, + "placeholder": "选择透传类型" + }, + "hint": "提示: MCP 透传功能允许 Claude Code 客户端使用第三方 AI 服务商提供的工具能力(如图片识别、联网搜索)", + "urlLabel": "MCP 透传 URL", + "urlPlaceholder": "https://api.minimaxi.com", + "urlDesc": "MCP 服务的基础 URL。留空则自动从提供商 URL 提取基础域名", + "urlAuto": "自动提取: {url}" } }, "providerTypes": { @@ -1479,5 +1514,25 @@ "cannotDelete": "默认规则无法删除", "cannotDisable": "建议保留默认规则启用状态" } - } + }, + "mcpPassthroughConfig": "MCP 透传配置", + "mcpPassthroughConfigNone": "不启用", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "智谱 GLM", + "mcpPassthroughConfigCustom": "自定义 (预留)", + "mcpPassthroughDesc": "启用后,将 MCP 工具调用透传到指定的 AI 服务商(如 minimax 的图片识别、联网搜索)", + "mcpPassthroughSelect": "透传类型", + "mcpPassthroughNoneLabel": "不启用", + "mcpPassthroughNoneDesc": "不启用 MCP 透传功能(默认)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "透传到 minimax MCP 服务(支持图片识别、联网搜索等工具)", + "mcpPassthroughGlmLabel": "智谱 GLM", + "mcpPassthroughGlmDesc": "透传到智谱 GLM MCP 服务(支持图片分析、视频分析等工具)", + "mcpPassthroughCustomLabel": "自定义", + "mcpPassthroughCustomDesc": "透传到自定义 MCP 服务(预留,暂未实现)", + "mcpPassthroughHint": "提示: MCP 透传功能允许 Claude Code 客户端使用第三方 AI 服务商提供的工具能力(如图片识别、联网搜索)", + "mcpPassthroughUrlLabel": "MCP 透传 URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP 服务的基础 URL。留空则自动从提供商 URL 提取基础域名", + "mcpPassthroughUrlAuto": "自动提取: {url}" } diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 12fb94da2..ce56d8c58 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -1453,5 +1453,25 @@ "cannotDelete": "預設規則無法刪除", "cannotDisable": "建議保留預設規則啟用狀態" } - } + }, + "mcpPassthroughConfig": "MCP 透傳配置", + "mcpPassthroughConfigNone": "不啟用", + "mcpPassthroughConfigMinimax": "Minimax", + "mcpPassthroughConfigGlm": "智譜 GLM", + "mcpPassthroughConfigCustom": "自定義 (預留)", + "mcpPassthroughDesc": "啟用後,將 MCP 工具調用透傳到指定的 AI 服務商(如 minimax 的圖片識別、聯網搜索)", + "mcpPassthroughSelect": "透傳類型", + "mcpPassthroughNoneLabel": "不啟用", + "mcpPassthroughNoneDesc": "不啟用 MCP 透傳功能(默認)", + "mcpPassthroughMinimaxLabel": "Minimax", + "mcpPassthroughMinimaxDesc": "透傳到 minimax MCP 服務(支持圖片識別、聯網搜索等工具)", + "mcpPassthroughGlmLabel": "智譜 GLM", + "mcpPassthroughGlmDesc": "透傳到智譜 GLM MCP 服務(支持圖片分析、視頻分析等工具)", + "mcpPassthroughCustomLabel": "自定義", + "mcpPassthroughCustomDesc": "透傳到自定義 MCP 服務(預留,暫未實現)", + "mcpPassthroughHint": "提示: MCP 透傳功能允許 Claude Code 客戶端使用第三方 AI 服務商提供的工具能力(如圖片識別、聯網搜索)", + "mcpPassthroughUrlLabel": "MCP 透傳 URL", + "mcpPassthroughUrlPlaceholder": "https://api.minimaxi.com", + "mcpPassthroughUrlDesc": "MCP 服務的基礎 URL。留空則自動從提供商 URL 提取基礎域名", + "mcpPassthroughUrlAuto": "自動提取: {url}" } diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index 09a58ef36..47469358a 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -25,7 +25,7 @@ function isInternalUrl(urlString: string): boolean { // 解析 IPv4 地址 const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (ipv4Match) { - const [, a, b, c] = ipv4Match.map(Number); + const [, a, b] = ipv4Match.map(Number); // 私有 IP 范围 if (a === 127) return true; // 127.0.0.0/8 (loopback range) if (a === 10) return true; // 10.0.0.0/8 diff --git a/src/actions/providers.ts b/src/actions/providers.ts index dd9348468..62c57fba7 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -139,6 +139,8 @@ export async function getProviders(): Promise { allowedModels: provider.allowedModels, joinClaudePool: provider.joinClaudePool, codexInstructionsStrategy: provider.codexInstructionsStrategy, + mcpPassthroughType: provider.mcpPassthroughType, + mcpPassthroughUrl: provider.mcpPassthroughUrl, limit5hUsd: provider.limit5hUsd, limitDailyUsd: provider.limitDailyUsd, dailyResetMode: provider.dailyResetMode, @@ -213,6 +215,8 @@ export async function addProvider(data: { request_timeout_non_streaming_ms?: number; website_url?: string | null; codex_instructions_strategy?: "auto" | "force_official" | "keep_original"; + mcp_passthrough_type?: "none" | "minimax" | "glm" | "custom"; + mcp_passthrough_url?: string | null; tpm: number | null; rpm: number | null; rpd: number | null; @@ -356,6 +360,8 @@ export async function editProvider( request_timeout_non_streaming_ms?: number; website_url?: string | null; codex_instructions_strategy?: "auto" | "force_official" | "keep_original"; + mcp_passthrough_type?: "none" | "minimax" | "glm" | "custom"; + mcp_passthrough_url?: string | null; tpm?: number | null; rpm?: number | null; rpd?: number | null; @@ -1893,7 +1899,7 @@ export async function testProviderGemini( } return headers; }, - body: (model) => ({ + body: () => ({ contents: [{ parts: [{ text: API_TEST_CONFIG.TEST_PROMPT }] }], generationConfig: { maxOutputTokens: API_TEST_CONFIG.TEST_MAX_TOKENS, diff --git a/src/app/[locale]/dashboard/_components/user-quick-overview.tsx b/src/app/[locale]/dashboard/_components/user-quick-overview.tsx index 2a5eb9eea..76655b078 100644 --- a/src/app/[locale]/dashboard/_components/user-quick-overview.tsx +++ b/src/app/[locale]/dashboard/_components/user-quick-overview.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ArrowRight, Users, Key, TrendingUp, Clock } from "lucide-react"; import { Link } from "@/i18n/routing"; diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx index ebd53351a..f523b478e 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx @@ -26,8 +26,13 @@ import { AlertDialogTitle as AlertTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import type { ProviderDisplay, ProviderType, CodexInstructionsStrategy } from "@/types/provider"; -import { validateNumericField, isValidUrl } from "@/lib/utils/validation"; +import type { + ProviderDisplay, + ProviderType, + CodexInstructionsStrategy, + McpPassthroughType, +} from "@/types/provider"; +import { validateNumericField, isValidUrl, extractBaseUrl } from "@/lib/utils/validation"; import { PROVIDER_DEFAULTS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { toast } from "sonner"; import { ModelMultiSelect } from "../model-multi-select"; @@ -162,6 +167,14 @@ export function ProviderForm({ const [codexInstructionsStrategy, setCodexInstructionsStrategy] = useState(sourceProvider?.codexInstructionsStrategy ?? "auto"); + // MCP 透传配置 + const [mcpPassthroughType, setMcpPassthroughType] = useState( + sourceProvider?.mcpPassthroughType ?? "none" + ); + const [mcpPassthroughUrl, setMcpPassthroughUrl] = useState( + sourceProvider?.mcpPassthroughUrl || "" + ); + // 折叠区域状态管理 type SectionKey = | "routing" @@ -170,7 +183,8 @@ export function ProviderForm({ | "proxy" | "timeout" | "apiTest" - | "codexStrategy"; + | "codexStrategy" + | "mcpPassthrough"; const [openSections, setOpenSections] = useState>({ routing: false, rateLimit: false, @@ -179,6 +193,7 @@ export function ProviderForm({ timeout: false, apiTest: false, codexStrategy: false, + mcpPassthrough: false, }); // 从 localStorage 加载折叠偏好 @@ -223,6 +238,7 @@ export function ProviderForm({ timeout: true, apiTest: true, codexStrategy: true, + mcpPassthrough: true, }); }; @@ -236,6 +252,7 @@ export function ProviderForm({ timeout: false, apiTest: false, codexStrategy: false, + mcpPassthrough: false, }); }; @@ -292,6 +309,8 @@ export function ProviderForm({ request_timeout_non_streaming_ms?: number; website_url?: string | null; codex_instructions_strategy?: CodexInstructionsStrategy; + mcp_passthrough_type?: McpPassthroughType; + mcp_passthrough_url?: string | null; tpm?: number | null; rpm?: number | null; rpd?: number | null; @@ -334,6 +353,8 @@ export function ProviderForm({ : undefined, website_url: websiteUrl.trim() || null, codex_instructions_strategy: codexInstructionsStrategy, + mcp_passthrough_type: mcpPassthroughType, + mcp_passthrough_url: mcpPassthroughUrl.trim() || null, tpm: null, rpm: null, rpd: null, @@ -390,6 +411,8 @@ export function ProviderForm({ : PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS, website_url: websiteUrl.trim() || null, codex_instructions_strategy: codexInstructionsStrategy, + mcp_passthrough_type: mcpPassthroughType, + mcp_passthrough_url: mcpPassthroughUrl.trim() || null, tpm: null, rpm: null, rpd: null, @@ -1109,7 +1132,7 @@ export function ProviderForm({ {/* 超时配置 */} - toggleSection("timeout")}> + toggleSection("timeout")}> + + +
+
+

{t("sections.mcpPassthrough.desc")}

+
+ +
+ + +

{t("sections.mcpPassthrough.hint")}

+
+ + {/* MCP 透传 URL 配置 */} + {mcpPassthroughType !== "none" && ( +
+ + setMcpPassthroughUrl(e.target.value)} + placeholder={t("sections.mcpPassthrough.urlPlaceholder")} + disabled={isPending} + /> +

+ {t("sections.mcpPassthrough.urlDesc")} +

+ {!mcpPassthroughUrl && url && ( +

+ {t("sections.mcpPassthrough.urlAuto", { + url: extractBaseUrl(url), + })} +

+ )} +
+ )} +
+
+
+ {isEdit ? (
diff --git a/src/app/[locale]/usage-doc/page.tsx b/src/app/[locale]/usage-doc/page.tsx index 8bd7e3896..690efa481 100644 --- a/src/app/[locale]/usage-doc/page.tsx +++ b/src/app/[locale]/usage-doc/page.tsx @@ -724,14 +724,6 @@ source ${shellConfig.split(" ")[0]}`} * 渲染 Gemini CLI 配置 */ const renderGeminiConfiguration = (os: OS) => { - const configPath = os === "windows" ? "%USERPROFILE%\\.gemini" : "~/.gemini"; - const shellConfig = - os === "linux" - ? "~/.bashrc 或 ~/.zshrc" - : os === "macos" - ? "~/.zshrc 或 ~/.bash_profile" - : ""; - return (

{t("gemini.configuration.configFile.title")}

diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 81c0f194a..b0cf65e5d 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -21,10 +21,17 @@ import { CodexInstructionsCache } from "@/lib/codex-instructions-cache"; import { createProxyAgentForProvider } from "@/lib/proxy-agent"; import type { Dispatcher } from "undici"; import { getEnvConfig } from "@/lib/config/env.schema"; -import { GeminiAdapter } from "../gemini/adapter"; import { GEMINI_PROTOCOL } from "../gemini/protocol"; import { GeminiAuth } from "../gemini/auth"; +const STANDARD_ENDPOINTS = [ + "/v1/messages", + "/v1/messages/count_tokens", + "/v1/responses", + "/v1/chat/completions", + "/v1/models", +]; + const MAX_ATTEMPTS_PER_PROVIDER = 2; // 每个供应商最多尝试次数(首次 + 1次重试) const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(防止无限循环) @@ -733,15 +740,76 @@ export class ProxyForwarder { }); } + // ⭐ MCP 透传处理:检测是否为 MCP 请求,并使用相应的 URL + let effectiveBaseUrl = provider.url; + + // 检测是否为 MCP 请求(非标准 Claude/Codex/OpenAI 端点) + const requestPath = session.requestUrl.pathname; + // pathname does not include query params, so exact match is sufficient + const isStandardRequest = STANDARD_ENDPOINTS.includes(requestPath); + const isMcpRequest = !isStandardRequest; + + if (isMcpRequest && provider.mcpPassthroughType && provider.mcpPassthroughType !== "none") { + // MCP 透传已启用,且当前是 MCP 请求 + if (provider.mcpPassthroughUrl) { + // 使用配置的 MCP URL + effectiveBaseUrl = provider.mcpPassthroughUrl; + logger.debug("ProxyForwarder: Using configured MCP passthrough URL", { + providerId: provider.id, + providerName: provider.name, + mcpType: provider.mcpPassthroughType, + configuredUrl: provider.mcpPassthroughUrl, + requestPath, + }); + } else { + // 自动从 provider.url 提取基础域名(去掉路径部分) + // 例如:https://api.minimaxi.com/anthropic -> https://api.minimaxi.com + try { + const baseUrlObj = new URL(provider.url); + effectiveBaseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + logger.debug("ProxyForwarder: Extracted base domain for MCP passthrough", { + providerId: provider.id, + providerName: provider.name, + mcpType: provider.mcpPassthroughType, + originalUrl: provider.url, + extractedBaseDomain: effectiveBaseUrl, + requestPath, + }); + } catch (error) { + logger.error("ProxyForwarder: Invalid provider URL for MCP passthrough", { + providerId: provider.id, + providerUrl: provider.url, + error, + }); + throw new ProxyError(`Invalid provider URL configuration: ${provider.url}`, 500); + } + } + } else if ( + isMcpRequest && + (!provider.mcpPassthroughType || provider.mcpPassthroughType === "none") + ) { + // MCP 请求但未启用 MCP 透传 + logger.debug( + "ProxyForwarder: MCP request but passthrough not enabled, using provider URL", + { + providerId: provider.id, + providerName: provider.name, + requestPath, + } + ); + } + // ⭐ 直接使用原始请求路径,让 buildProxyUrl() 智能处理路径拼接 // 移除了强制 /v1/responses 路径重写,解决 Issue #139 // buildProxyUrl() 会检测 base_url 是否已包含完整路径,避免重复拼接 - proxyUrl = buildProxyUrl(provider.url, session.requestUrl); + proxyUrl = buildProxyUrl(effectiveBaseUrl, session.requestUrl); logger.debug("ProxyForwarder: Final proxy URL", { url: proxyUrl, originalPath: session.requestUrl.pathname, providerType: provider.providerType, + mcpPassthroughType: provider.mcpPassthroughType, + usedBaseUrl: effectiveBaseUrl, }); const hasBody = session.method !== "GET" && session.method !== "HEAD"; diff --git a/src/app/v1/_lib/proxy/mcp-passthrough-handler.ts b/src/app/v1/_lib/proxy/mcp-passthrough-handler.ts new file mode 100644 index 000000000..55392da14 --- /dev/null +++ b/src/app/v1/_lib/proxy/mcp-passthrough-handler.ts @@ -0,0 +1,319 @@ +/** + * MCP 透传处理器 + * + * 检测并处理 MCP 工具调用,将其透传到配置的第三方 AI 服务商 + * 例如:将 web_search、understand_image 等工具调用透传到 minimax + * 例如:将 analyze_image、analyze_video 等工具调用透传到 GLM + */ + +import type { Provider } from "@/types/provider"; +import { logger } from "@/lib/logger"; +import { MinimaxMcpClient } from "@/lib/mcp/minimax-client"; +import { GlmMcpClient } from "@/lib/mcp/glm-client"; +import type { McpClientConfig } from "@/lib/mcp/types"; +import { McpError } from "@/lib/mcp/types"; + +/** + * MCP 工具调用信息 + */ +interface McpToolCall { + type: "tool_use"; + id: string; + name: string; + input: Record; +} + +/** + * MCP 工具响应 + */ +interface McpToolResponse { + type: "tool_result"; + tool_use_id: string; + content: string | Array<{ type: string; text?: string }>; + is_error?: boolean; +} + +/** + * MCP 透传处理器 + */ +export class McpPassthroughHandler { + /** + * 检查是否应该处理该工具调用 + * + * @param provider - 供应商配置 + * @param toolName - 工具名称 + * @returns 是否应该处理 + */ + static shouldHandle(provider: Provider, toolName: string): boolean { + // 检查供应商是否配置了 MCP 透传 + if (!provider.mcpPassthroughType || provider.mcpPassthroughType === "none") { + return false; + } + + // 检查工具名称是否支持 + const supportedTools = this.getSupportedTools(provider.mcpPassthroughType); + return supportedTools.includes(toolName); + } + + /** + * 获取支持的工具列表 + * + * @param mcpType - MCP 透传类型 + * @returns 支持的工具名称列表 + */ + private static getSupportedTools(mcpType: string): string[] { + switch (mcpType) { + case "minimax": + return ["web_search", "understand_image"]; + case "glm": + // 智谱 GLM 支持的工具:图片分析和视频分析 + return ["analyze_image", "analyze_video"]; + case "custom": + // 预留:自定义 MCP 服务支持的工具 + return []; + default: + return []; + } + } + + /** + * 处理工具调用 + * + * @param provider - 供应商配置 + * @param toolCall - 工具调用信息 + * @returns 工具响应 + */ + static async handleToolCall(provider: Provider, toolCall: McpToolCall): Promise { + logger.info("[McpPassthroughHandler] Handling tool call", { + providerId: provider.id, + providerName: provider.name, + mcpType: provider.mcpPassthroughType, + toolName: toolCall.name, + toolId: toolCall.id, + }); + + try { + // 根据 MCP 类型选择客户端 + switch (provider.mcpPassthroughType) { + case "minimax": + return await this.handleMinimaxToolCall(provider, toolCall); + case "glm": + return await this.handleGlmToolCall(provider, toolCall); + case "custom": + throw new McpError("Custom MCP passthrough is not implemented yet"); + default: + throw new McpError(`Unsupported MCP type: ${provider.mcpPassthroughType}`); + } + } catch (error) { + logger.error("[McpPassthroughHandler] Tool call failed", { + providerId: provider.id, + toolName: toolCall.name, + toolId: toolCall.id, + error: error instanceof Error ? error.message : String(error), + }); + + // 返回错误响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: `MCP tool call failed: ${error instanceof Error ? error.message : String(error)}`, + is_error: true, + }; + } + } + + /** + * 处理 Minimax 工具调用 + * + * @param provider - 供应商配置 + * @param toolCall - 工具调用信息 + * @returns 工具响应 + */ + private static async handleMinimaxToolCall( + provider: Provider, + toolCall: McpToolCall + ): Promise { + // 创建 Minimax 客户端 + const config: McpClientConfig = { + baseUrl: provider.url, + apiKey: provider.key, + }; + const client = new MinimaxMcpClient(config); + + // 根据工具名称调用对应方法 + switch (toolCall.name) { + case "web_search": { + const query = toolCall.input.query; + if (!query || typeof query !== "string") { + throw new McpError("Invalid parameter: query must be a non-empty string"); + } + + const response = await client.webSearch(query); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: JSON.stringify(response.data?.results || [], null, 2), + }, + ], + }; + } + + case "understand_image": { + const imageUrl = toolCall.input.image_url; + const prompt = toolCall.input.prompt; + + if (!imageUrl || typeof imageUrl !== "string") { + throw new McpError("Invalid parameter: image_url must be a non-empty string"); + } + if (!prompt || typeof prompt !== "string") { + throw new McpError("Invalid parameter: prompt must be a non-empty string"); + } + + const response = await client.understandImage(imageUrl, prompt); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: response.data?.analysis || "No analysis result", + }, + ], + }; + } + + default: + throw new McpError(`Unsupported tool: ${toolCall.name}`); + } + } + + /** + * 处理 GLM 工具调用 + * + * @param provider - 供应商配置 + * @param toolCall - 工具调用信息 + * @returns 工具响应 + */ + private static async handleGlmToolCall( + provider: Provider, + toolCall: McpToolCall + ): Promise { + // 创建 GLM 客户端 + const config: McpClientConfig = { + baseUrl: provider.url, + apiKey: provider.key, + }; + const client = new GlmMcpClient(config); + + // 根据工具名称调用对应方法 + switch (toolCall.name) { + case "analyze_image": { + const imageSource = toolCall.input.image_source; + const prompt = toolCall.input.prompt; + + if (!imageSource || typeof imageSource !== "string") { + throw new McpError("Invalid parameter: image_source must be a non-empty string"); + } + if (!prompt || typeof prompt !== "string") { + throw new McpError("Invalid parameter: prompt must be a non-empty string"); + } + + const response = await client.analyzeImage(imageSource, prompt); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: response.result, + }, + ], + }; + } + + case "analyze_video": { + const videoSource = toolCall.input.video_source; + const prompt = toolCall.input.prompt; + + if (!videoSource || typeof videoSource !== "string") { + throw new McpError("Invalid parameter: video_source must be a non-empty string"); + } + if (!prompt || typeof prompt !== "string") { + throw new McpError("Invalid parameter: prompt must be a non-empty string"); + } + + const response = await client.analyzeVideo(videoSource, prompt); + + // 格式化响应 + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: [ + { + type: "text", + text: response.result, + }, + ], + }; + } + + default: + throw new McpError(`Unsupported tool: ${toolCall.name}`); + } + } + + /** + * 从请求中提取工具调用 + * + * @param requestBody - 请求体 + * @returns 工具调用列表(如果有) + */ + static extractToolCalls(requestBody: unknown): McpToolCall[] | null { + if (!requestBody || typeof requestBody !== "object") { + return null; + } + + const body = requestBody as Record; + + // 检查是否包含 messages 数组 + if (!Array.isArray(body.messages)) { + return null; + } + + // 查找包含 tool_use 的消息 + const toolCalls: McpToolCall[] = []; + + for (const message of body.messages) { + if (typeof message !== "object" || !message) { + continue; + } + + const msg = message as Record; + + // 检查 content 数组 + if (Array.isArray(msg.content)) { + for (const content of msg.content) { + if ( + typeof content === "object" && + content && + "type" in content && + content.type === "tool_use" + ) { + toolCalls.push(content as McpToolCall); + } + } + } + } + + return toolCalls.length > 0 ? toolCalls : null; + } +} diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 8f4b84fc3..afa9b7681 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -587,7 +587,7 @@ export class ProxyResponseHandler { const openAIChunk = GeminiAdapter.transformResponse(geminiResponse, true); const output = `data: ${JSON.stringify(openAIChunk)}\n\n`; controller.enqueue(new TextEncoder().encode(output)); - } catch (e) { + } catch { // Ignore parse errors } } diff --git a/src/app/v1beta/[...route]/route.ts b/src/app/v1beta/[...route]/route.ts index c4a9f9f91..afbe5fddf 100644 --- a/src/app/v1beta/[...route]/route.ts +++ b/src/app/v1beta/[...route]/route.ts @@ -1,6 +1,5 @@ import "@/lib/polyfills/file"; import { Hono } from "hono"; -import { logger } from "@/lib/logger"; import { handle } from "hono/vercel"; import { handleProxyRequest } from "@/app/v1/_lib/proxy-handler"; diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index cabd28aac..5e52dfbaf 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -26,7 +26,7 @@ export const users = pgTable('users', { rpmLimit: integer('rpm_limit').default(60), dailyLimitUsd: numeric('daily_limit_usd', { precision: 10, scale: 2 }).default('100.00'), providerGroup: varchar('provider_group', { length: 50 }), - + // New user-level quota fields (nullable for backward compatibility) limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }), limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }), @@ -126,6 +126,21 @@ export const providers = pgTable('providers', { .default('auto') .$type<'auto' | 'force_official' | 'keep_original'>(), + // MCP 透传类型:控制是否启用 MCP 透传功能 + // - 'none' (默认): 不启用 MCP 透传 + // - 'minimax': 透传到 minimax MCP 服务(图片识别、联网搜索) + // - 'glm': 透传到智谱 MCP 服务(预留) + // - 'custom': 自定义 MCP 服务(预留) + mcpPassthroughType: varchar('mcp_passthrough_type', { length: 20 }) + .notNull() + .default('none') + .$type<'none' | 'minimax' | 'glm' | 'custom'>(), + + // MCP 透传 URL:MCP 服务的基础 URL + // 如果未配置,则自动从 provider.url 提取基础域名 + // 例如:https://api.minimaxi.com/anthropic -> https://api.minimaxi.com + mcpPassthroughUrl: varchar('mcp_passthrough_url', { length: 512 }), + // 金额限流配置 limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }), limitDailyUsd: numeric('limit_daily_usd', { precision: 10, scale: 2 }), diff --git a/src/lib/mcp/glm-client.ts b/src/lib/mcp/glm-client.ts new file mode 100644 index 000000000..12a56cdc2 --- /dev/null +++ b/src/lib/mcp/glm-client.ts @@ -0,0 +1,176 @@ +/** + * GLM MCP 客户端 + * 实现图片分析和视频分析功能 + */ + +import { logger } from "@/lib/logger"; +import type { + McpClientConfig, + McpGlmImageAnalyzeRequest, + McpGlmImageAnalyzeResponse, + McpGlmVideoAnalyzeRequest, + McpGlmVideoAnalyzeResponse, +} from "./types"; +import { McpAuthError, McpRequestError } from "./types"; + +/** + * GLM MCP 客户端 + * 提供图片和视频分析功能 + */ +export class GlmMcpClient { + private baseUrl: string; + private apiKey: string; + + constructor(config: McpClientConfig) { + this.baseUrl = config.baseUrl; + this.apiKey = config.apiKey; + } + + /** + * 分析图片 + * @param imageSource 图片源(本地路径或远程 URL) + * @param prompt 提示词 + * @returns 图片分析结果 + */ + async analyzeImage(imageSource: string, prompt: string): Promise { + if (!imageSource) { + throw new McpRequestError("Image source is required"); + } + if (!prompt) { + throw new McpRequestError("Prompt is required"); + } + + const payload: McpGlmImageAnalyzeRequest = { + image_source: imageSource, + prompt, + }; + + logger.info("[GlmMcpClient] analyzeImage", { imageSource, prompt }); + + try { + // GLM 使用多模态接口处理图片分析 + // 这里模拟 GLM MCP 工具的调用方式 + const response = await this.makeRequest( + "/api/chat/completions", + payload + ); + + logger.info("[GlmMcpClient] analyzeImage success", { + imageSource, + prompt, + }); + + return response; + } catch (error) { + logger.error("[GlmMcpClient] analyzeImage failed", { + imageSource, + prompt, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 分析视频 + * @param videoSource 视频源(本地路径或远程 URL) + * @param prompt 提示词 + * @returns 视频分析结果 + */ + async analyzeVideo(videoSource: string, prompt: string): Promise { + if (!videoSource) { + throw new McpRequestError("Video source is required"); + } + if (!prompt) { + throw new McpRequestError("Prompt is required"); + } + + const payload: McpGlmVideoAnalyzeRequest = { + video_source: videoSource, + prompt, + }; + + logger.info("[GlmMcpClient] analyzeVideo", { videoSource, prompt }); + + try { + // GLM 使用多模态接口处理视频分析 + const response = await this.makeRequest( + "/api/chat/completions", + payload + ); + + logger.info("[GlmMcpClient] analyzeVideo success", { + videoSource, + prompt, + }); + + return response; + } catch (error) { + logger.error("[GlmMcpClient] analyzeVideo failed", { + videoSource, + prompt, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 发起 HTTP 请求 + * @param endpoint API 端点 + * @param payload 请求体 + * @returns 响应数据 + */ + private async makeRequest(endpoint: string, payload: unknown): Promise { + const url = `${this.baseUrl}${endpoint}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "GLM-API-Source": "Claude-Code-Hub-MCP", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new McpRequestError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response.headers.get("Trace-Id") ?? undefined + ); + } + + const data = (await response.json()) as T; + + // TODO: Implement GLM-specific error handling based on API docs + // For now, log response for debugging + logger.debug("[GlmMcpClient] API response", { endpoint, data }); + + return data; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + throw new McpRequestError("Request timeout after 30 seconds"); + } + if (error instanceof TypeError) { + throw new McpRequestError( + `Network error: ${error.message}. Failed to connect to ${this.baseUrl}. Check base URL, network connectivity, and firewall settings.` + ); + } + if (error instanceof McpAuthError || error instanceof McpRequestError) { + throw error; + } + + throw new McpRequestError( + `Request failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/src/lib/mcp/minimax-client.ts b/src/lib/mcp/minimax-client.ts new file mode 100644 index 000000000..f51581eed --- /dev/null +++ b/src/lib/mcp/minimax-client.ts @@ -0,0 +1,187 @@ +/** + * MiniMax MCP 客户端 + * 实现 Web 搜索和图片理解功能 + */ + +import { logger } from "@/lib/logger"; +import type { + McpClientConfig, + McpWebSearchRequest, + McpWebSearchResponse, + McpImageUnderstandRequest, + McpImageUnderstandResponse, +} from "./types"; +import { McpAuthError, McpRequestError } from "./types"; + +export class MinimaxMcpClient { + private baseUrl: string; + private apiKey: string; + + constructor(config: McpClientConfig) { + this.baseUrl = config.baseUrl; + this.apiKey = config.apiKey; + } + + /** + * Web 搜索 + * @param query 搜索查询 + * @returns 搜索结果 + */ + async webSearch(query: string): Promise { + if (!query) { + throw new McpRequestError("Query is required"); + } + + const payload: McpWebSearchRequest = { + q: query, + }; + + logger.info("[MinimaxMcpClient] webSearch", { query }); + + try { + const response = await this.makeRequest( + "/v1/coding_plan/search", + payload + ); + + logger.info("[MinimaxMcpClient] webSearch success", { + query, + resultsCount: response.data?.results?.length ?? 0, + }); + + return response; + } catch (error) { + logger.error("[MinimaxMcpClient] webSearch failed", { + query, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 图片理解 + * @param imageUrl 图片 URL + * @param prompt 提示词 + * @returns 图片理解结果 + */ + async understandImage(imageUrl: string, prompt: string): Promise { + if (!imageUrl) { + throw new McpRequestError("Image URL is required"); + } + if (!prompt) { + throw new McpRequestError("Prompt is required"); + } + + const payload: McpImageUnderstandRequest = { + image_url: imageUrl, + prompt, + }; + + logger.info("[MinimaxMcpClient] understandImage", { imageUrl, prompt }); + + try { + const response = await this.makeRequest( + "/v1/coding_plan/vlm", + payload + ); + + logger.info("[MinimaxMcpClient] understandImage success", { + imageUrl, + prompt, + }); + + return response; + } catch (error) { + logger.error("[MinimaxMcpClient] understandImage failed", { + imageUrl, + prompt, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * 发起 HTTP 请求 + * @param endpoint API 端点 + * @param payload 请求体 + * @returns 响应数据 + */ + private async makeRequest(endpoint: string, payload: unknown): Promise { + const url = `${this.baseUrl}${endpoint}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "MM-API-Source": "Claude-Code-Hub-MCP", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new McpRequestError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response.headers.get("Trace-Id") ?? undefined + ); + } + + const data = (await response.json()) as T & { + base_resp?: { status_code: number; status_msg: string }; + }; + + // 检查 API 特定的错误码 + if (data.base_resp && data.base_resp.status_code !== 0) { + const { status_code, status_msg } = data.base_resp; + const traceId = response.headers.get("Trace-Id") ?? undefined; + + switch (status_code) { + case 1004: + throw new McpAuthError( + `API Error: ${status_msg}, please check your API key and API host. Trace-Id: ${traceId}`, + traceId + ); + case 2038: + throw new McpRequestError( + `API Error: ${status_msg}, should complete real-name verification on the open-platform(https://platform.minimaxi.com/user-center/basic-information). Trace-Id: ${traceId}`, + status_code, + traceId + ); + default: + throw new McpRequestError( + `API Error: ${status_code}-${status_msg} Trace-Id: ${traceId}`, + status_code, + traceId + ); + } + } + + return data; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + throw new McpRequestError("Request timeout after 30 seconds"); + } + if (error instanceof TypeError) { + throw new McpRequestError( + `Network error: ${error.message}. Failed to connect to ${this.baseUrl}. Check base URL, network connectivity, and firewall settings.` + ); + } + if (error instanceof McpAuthError || error instanceof McpRequestError) { + throw error; + } + + throw new McpRequestError( + `Request failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/src/lib/mcp/types.ts b/src/lib/mcp/types.ts new file mode 100644 index 000000000..f70de11d5 --- /dev/null +++ b/src/lib/mcp/types.ts @@ -0,0 +1,112 @@ +/** + * MCP (Model Context Protocol) 类型定义 + * 用于 MiniMax、GLM 等第三方 AI 服务的工具调用透传 + */ + +// MCP 客户端配置 +export interface McpClientConfig { + baseUrl: string; + apiKey: string; +} + +// ==================== MiniMax MCP 类型 ==================== + +// Web 搜索请求 +export interface McpWebSearchRequest { + q: string; // 搜索查询 +} + +// Web 搜索响应 +export interface McpWebSearchResponse { + base_resp: { + status_code: number; + status_msg: string; + }; + data?: { + results: Array<{ + title: string; + url: string; + snippet: string; + }>; + }; +} + +// 图片理解请求 +export interface McpImageUnderstandRequest { + image_url: string; // 图片 URL + prompt: string; // 提示词 +} + +// 图片理解响应 +export interface McpImageUnderstandResponse { + base_resp: { + status_code: number; + status_msg: string; + }; + data?: { + description: string; + analysis: string; + }; +} + +// ==================== GLM MCP 类型 ==================== + +// GLM 图片分析请求 +export interface McpGlmImageAnalyzeRequest { + image_source: string; // 本地文件路径或远程 URL + prompt: string; // 提示词 +} + +// GLM 图片分析响应 +export interface McpGlmImageAnalyzeResponse { + result: string; // 分析结果 + metadata?: { + image_source?: string; + prompt?: string; + model?: string; + }; +} + +// GLM 视频分析请求 +export interface McpGlmVideoAnalyzeRequest { + video_source: string; // 本地文件路径或远程 URL + prompt: string; // 提示词 +} + +// GLM 视频分析响应 +export interface McpGlmVideoAnalyzeResponse { + result: string; // 分析结果 + metadata?: { + video_source?: string; + prompt?: string; + model?: string; + }; +} + +// MCP 错误类型 +export class McpError extends Error { + constructor( + message: string, + public statusCode?: number, + public traceId?: string + ) { + super(message); + this.name = "McpError"; + } +} + +// MCP 认证错误 +export class McpAuthError extends McpError { + constructor(message: string, traceId?: string) { + super(message, 1004, traceId); + this.name = "McpAuthError"; + } +} + +// MCP 请求错误 +export class McpRequestError extends McpError { + constructor(message: string, statusCode?: number, traceId?: string) { + super(message, statusCode, traceId); + this.name = "McpRequestError"; + } +} diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index a2d9cab7e..df2d4c05a 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -77,7 +77,6 @@ import { } from "@/lib/redis/lua-scripts"; import { sumUserCostToday } from "@/repository/statistics"; import { - getTimeRangeForPeriod, getTimeRangeForPeriodWithMode, getTTLForPeriod, getTTLForPeriodWithMode, diff --git a/src/lib/utils/validation/index.ts b/src/lib/utils/validation/index.ts index d0300adf1..ed3b96a82 100644 --- a/src/lib/utils/validation/index.ts +++ b/src/lib/utils/validation/index.ts @@ -5,6 +5,7 @@ export { clampTpm, formatTpmDisplay, } from "./provider"; +import { logger } from "@/lib/logger"; /** * 验证URL格式 @@ -29,3 +30,23 @@ export function maskKey(key: string): string { const tail = 4; return `${key.slice(0, head)}••••••${key.slice(-tail)}`; } + +/** + * 从URL中提取基础域名 + * @param url - 完整的URL + * @returns 基础域名(包含协议和主机名,不含路径) + * @example + * extractBaseUrl("https://api.minimaxi.com/anthropic/v1/messages") // "https://api.minimaxi.com" + * extractBaseUrl("http://localhost:3000/api") // "http://localhost:3000" + */ +export function extractBaseUrl(url: string): string { + try { + const parsedUrl = new URL(url); + // 返回协议 + 主机名(包含端口) + return parsedUrl.origin; + } catch (error) { + logger.warn("Failed to parse URL", { url, error }); + // 如果URL解析失败,返回原始URL + return url; + } +} diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index ad9d9f8f8..546f95981 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -189,6 +189,38 @@ export const CreateProviderSchema = z.object({ .enum(["auto", "force_official", "keep_original"]) .optional() .default("auto"), + // MCP 透传配置 + mcp_passthrough_type: z.enum(["none", "minimax", "glm", "custom"]).optional().default("none"), + mcp_passthrough_url: z + .string() + .max(512, "MCP透传URL长度不能超过512个字符") + .url("请输入有效的URL地址") + .refine( + (url) => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname; + // Block localhost + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") + return false; + // Block private IP ranges + // 10.0.0.0/8 + if (hostname.startsWith("10.")) return false; + // 192.168.0.0/16 + if (hostname.startsWith("192.168.")) return false; + // 172.16.0.0/12 + if (hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) return false; + // 169.254.0.0/16 (Link-local) + if (hostname.startsWith("169.254.")) return false; + return true; + } catch { + return false; + } + }, + { message: "不允许使用内部网络地址 (SSRF Protection)" } + ) + .nullable() + .optional(), // 金额限流配置 limit_5h_usd: z.coerce .number() @@ -339,6 +371,38 @@ export const UpdateProviderSchema = z allowed_models: z.array(z.string()).nullable().optional(), join_claude_pool: z.boolean().optional(), codex_instructions_strategy: z.enum(["auto", "force_official", "keep_original"]).optional(), + // MCP 透传配置 + mcp_passthrough_type: z.enum(["none", "minimax", "glm", "custom"]).optional(), + mcp_passthrough_url: z + .string() + .max(512, "MCP透传URL长度不能超过512个字符") + .url("请输入有效的URL地址") + .refine( + (url) => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname; + // Block localhost + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") + return false; + // Block private IP ranges + // 10.0.0.0/8 + if (hostname.startsWith("10.")) return false; + // 192.168.0.0/16 + if (hostname.startsWith("192.168.")) return false; + // 172.16.0.0/12 + if (hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) return false; + // 169.254.0.0/16 (Link-local) + if (hostname.startsWith("169.254.")) return false; + return true; + } catch { + return false; + } + }, + { message: "不允许使用内部网络地址 (SSRF Protection)" } + ) + .nullable() + .optional(), // 金额限流配置 limit_5h_usd: z.coerce .number() diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index fd2a2c6f8..f3d9a2cdd 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -49,6 +49,8 @@ export function toProvider(dbProvider: any): Provider { providerType: dbProvider?.providerType ?? "claude", modelRedirects: dbProvider?.modelRedirects ?? null, codexInstructionsStrategy: dbProvider?.codexInstructionsStrategy ?? "auto", + mcpPassthroughType: dbProvider?.mcpPassthroughType ?? "none", + mcpPassthroughUrl: dbProvider?.mcpPassthroughUrl ?? null, limit5hUsd: dbProvider?.limit5hUsd ? parseFloat(dbProvider.limit5hUsd) : null, limitDailyUsd: dbProvider?.limitDailyUsd ? parseFloat(dbProvider.limitDailyUsd) : null, dailyResetTime: dbProvider?.dailyResetTime ?? "00:00", diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 5a5ed275c..c0e6d8762 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -24,6 +24,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< allowedModels: providerData.allowed_models, joinClaudePool: providerData.join_claude_pool ?? false, codexInstructionsStrategy: providerData.codex_instructions_strategy ?? "auto", + mcpPassthroughType: providerData.mcp_passthrough_type ?? "none", + mcpPassthroughUrl: providerData.mcp_passthrough_url ?? null, limit5hUsd: providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null, limitDailyUsd: providerData.limit_daily_usd != null ? providerData.limit_daily_usd.toString() : null, @@ -66,6 +68,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, @@ -115,6 +119,8 @@ export async function findProviderList( allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, @@ -171,6 +177,8 @@ export async function findProviderById(id: number): Promise { allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, @@ -233,6 +241,10 @@ export async function updateProvider( dbData.joinClaudePool = providerData.join_claude_pool; if (providerData.codex_instructions_strategy !== undefined) dbData.codexInstructionsStrategy = providerData.codex_instructions_strategy; + if (providerData.mcp_passthrough_type !== undefined) + dbData.mcpPassthroughType = providerData.mcp_passthrough_type; + if (providerData.mcp_passthrough_url !== undefined) + dbData.mcpPassthroughUrl = providerData.mcp_passthrough_url; if (providerData.limit_5h_usd !== undefined) dbData.limit5hUsd = providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null; @@ -293,6 +305,8 @@ export async function updateProvider( allowedModels: providers.allowedModels, joinClaudePool: providers.joinClaudePool, codexInstructionsStrategy: providers.codexInstructionsStrategy, + mcpPassthroughType: providers.mcpPassthroughType, + mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, limitDailyUsd: providers.limitDailyUsd, dailyResetMode: providers.dailyResetMode, diff --git a/src/types/provider.ts b/src/types/provider.ts index 532419699..0730303ad 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -10,6 +10,9 @@ export type ProviderType = // Codex Instructions 策略枚举 export type CodexInstructionsStrategy = "auto" | "force_official" | "keep_original"; +// MCP 透传类型枚举 +export type McpPassthroughType = "none" | "minimax" | "glm" | "custom"; + export interface Provider { id: number; name: string; @@ -42,6 +45,18 @@ export interface Provider { // 仅对 providerType = 'codex' 的供应商有效 codexInstructionsStrategy: CodexInstructionsStrategy; + // MCP 透传类型:控制是否启用 MCP 透传功能 + // 'none': 不启用(默认) + // 'minimax': 透传到 minimax MCP 服务(图片识别、联网搜索) + // 'glm': 透传到智谱 MCP 服务(图片分析、视频分析) + // 'custom': 自定义 MCP 服务(预留) + mcpPassthroughType: McpPassthroughType; + + // MCP 透传 URL:MCP 服务的基础 URL + // 如果未配置,则自动从 provider.url 提取基础域名 + // 例如:https://api.minimaxi.com/anthropic -> https://api.minimaxi.com + mcpPassthroughUrl: string | null; + // 金额限流配置 limit5hUsd: number | null; limitDailyUsd: number | null; @@ -105,6 +120,10 @@ export interface ProviderDisplay { joinClaudePool: boolean; // Codex Instructions 策略 codexInstructionsStrategy: CodexInstructionsStrategy; + // MCP 透传类型 + mcpPassthroughType: McpPassthroughType; + // MCP 透传 URL + mcpPassthroughUrl: string | null; // 金额限流配置 limit5hUsd: number | null; limitDailyUsd: number | null; @@ -161,6 +180,8 @@ export interface CreateProviderData { allowed_models?: string[] | null; join_claude_pool?: boolean; codex_instructions_strategy?: CodexInstructionsStrategy; + mcp_passthrough_type?: McpPassthroughType; + mcp_passthrough_url?: string | null; // 金额限流配置 limit_5h_usd?: number | null; @@ -220,6 +241,8 @@ export interface UpdateProviderData { allowed_models?: string[] | null; join_claude_pool?: boolean; codex_instructions_strategy?: CodexInstructionsStrategy; + mcp_passthrough_type?: McpPassthroughType; + mcp_passthrough_url?: string | null; // 金额限流配置 limit_5h_usd?: number | null;