diff --git a/drizzle/0052_model_price_source.sql b/drizzle/0052_model_price_source.sql new file mode 100644 index 000000000..5075e48f4 --- /dev/null +++ b/drizzle/0052_model_price_source.sql @@ -0,0 +1,2 @@ +ALTER TABLE "model_prices" ADD COLUMN "source" varchar(20) DEFAULT 'litellm' NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_model_prices_source" ON "model_prices" USING btree ("source"); \ No newline at end of file diff --git a/drizzle/meta/0052_snapshot.json b/drizzle/meta/0052_snapshot.json new file mode 100644 index 000000000..62239cbb4 --- /dev/null +++ b/drizzle/meta/0052_snapshot.json @@ -0,0 +1,2374 @@ +{ + "id": "e7a58fbf-6e7a-4c5f-a0ac-255fcf6439d7", + "prevId": "c7b01fc8-2ed8-4359-a233-9fa3a2f7e8ec", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "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_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "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": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "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 + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_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_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_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()" + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + } + }, + "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 + }, + "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, + "default": "'Asia/Shanghai'" + }, + "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.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'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "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": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_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" + }, + "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 + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "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_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" + ] + }, + "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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2da6ac2a7..9473c9cce 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1767976327237, "tag": "0051_silent_maelstrom", "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1767924921400, + "tag": "0052_model_price_source", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 8cc6d5add..91ef7163a 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1377,6 +1377,10 @@ "threeMonths": "In 3 months", "oneYear": "In 1 year" }, + "providerGroupSelect": { + "providersSuffix": "providers", + "loadFailed": "Failed to load provider groups" + }, "providerGroup": { "label": "Provider group", "placeholder": "Select provider group", diff --git a/messages/en/settings.json b/messages/en/settings.json index cddd6971c..0572ae511 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -546,13 +546,46 @@ "sync": { "button": "Sync LiteLLM Prices", "syncing": "Syncing...", + "checking": "Checking conflicts...", "successWithChanges": "Price table updated: {added} added, {updated} updated, {unchanged} unchanged", "successNoChanges": "Price table is up to date, no updates needed", "failed": "Sync failed", "failedError": "Sync failed: {error}", "failedNoResult": "Price table updated but no result returned", "noModels": "No model prices found", - "partialFailure": "Partial update succeeded, but {failed} models failed" + "partialFailure": "Partial update succeeded, but {failed} models failed", + "skippedConflicts": "Skipped {count} manual models" + }, + "conflict": { + "title": "Select Items to Overwrite", + "description": "The following models have manual prices. Check the ones to overwrite with LiteLLM prices, unchecked ones will be kept unchanged", + "searchPlaceholder": "Search models...", + "table": { + "modelName": "Model", + "manualPrice": "Manual Price", + "litellmPrice": "LiteLLM Price", + "action": "Action" + }, + "viewDiff": "View Diff", + "diffTitle": "Price Difference", + "diff": { + "field": "Field", + "manual": "Manual", + "litellm": "LiteLLM", + "inputPrice": "Input Price", + "outputPrice": "Output Price", + "imagePrice": "Image Price", + "provider": "Provider", + "mode": "Type" + }, + "pagination": { + "showing": "Showing {from}-{to} of {total}" + }, + "selectedCount": "Selected {count}/{total} models", + "noMatch": "No matching models found", + "noConflicts": "No conflicts", + "applyOverwrite": "Apply Overwrite", + "applying": "Applying..." }, "table": { "modelName": "Model Name", @@ -561,6 +594,7 @@ "inputPrice": "Input Price ($/M)", "outputPrice": "Output Price ($/M)", "updatedAt": "Updated At", + "actions": "Actions", "typeChat": "Chat", "typeImage": "Image", "typeCompletion": "Completion", @@ -610,6 +644,34 @@ "details": "Details", "viewDetails": "View detailed logs" } + }, + "addModel": "Add Model", + "editModel": "Edit Model", + "deleteModel": "Delete Model", + "addModelDescription": "Manually add a new model price configuration", + "editModelDescription": "Edit the model price configuration", + "deleteConfirm": "Are you sure you want to delete model {name}? This action cannot be undone.", + "form": { + "modelName": "Model Name", + "modelNamePlaceholder": "e.g., gpt-5.2-codex", + "modelNameRequired": "Model name is required", + "type": "Type", + "provider": "Provider", + "providerPlaceholder": "e.g., openai", + "inputPrice": "Input Price ($/M tokens)", + "outputPrice": "Output Price ($/M tokens)", + "outputPriceImage": "Output Price ($/image)" + }, + "actions": { + "edit": "Edit", + "delete": "Delete" + }, + "toast": { + "createSuccess": "Model added", + "updateSuccess": "Model updated", + "deleteSuccess": "Model deleted", + "saveFailed": "Failed to save", + "deleteFailed": "Failed to delete" } }, "providers": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index a43abf9ca..b8ece37ac 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1339,6 +1339,10 @@ "threeMonths": "3か月後", "oneYear": "1年後" }, + "providerGroupSelect": { + "providersSuffix": "件のプロバイダー", + "loadFailed": "プロバイダーグループの読み込みに失敗しました" + }, "providerGroup": { "label": "プロバイダーグループ", "placeholder": "プロバイダーグループを選択", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index d4c391415..c02d1d290 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -537,13 +537,46 @@ "sync": { "button": "LiteLLM価格を同期", "syncing": "同期中...", + "checking": "競合を確認中...", "successWithChanges": "価格表を更新: {added}件追加、{updated}件更新、{unchanged}件変化なし", "successNoChanges": "価格表は最新です。更新の必要はありません", "failed": "同期に失敗しました", "failedError": "同期に失敗しました: {error}", "failedNoResult": "価格表は更新されましたが結果が返されていません", "noModels": "モデル価格が見つかりません", - "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました" + "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました", + "skippedConflicts": "{count}件の手動モデルをスキップしました" + }, + "conflict": { + "title": "上書きする項目を選択", + "description": "以下のモデルには手動で設定された価格があります。チェックした項目はLiteLLM価格で上書きされ、チェックしない項目は現在のままです", + "searchPlaceholder": "モデルを検索...", + "table": { + "modelName": "モデル", + "manualPrice": "手動価格", + "litellmPrice": "LiteLLM価格", + "action": "操作" + }, + "viewDiff": "差異を表示", + "diffTitle": "価格差異", + "diff": { + "field": "フィールド", + "manual": "手動", + "litellm": "LiteLLM", + "inputPrice": "入力価格", + "outputPrice": "出力価格", + "imagePrice": "画像価格", + "provider": "プロバイダー", + "mode": "タイプ" + }, + "pagination": { + "showing": "{from}〜{to}件を表示(全{total}件)" + }, + "selectedCount": "{count}/{total}件のモデルを選択", + "noMatch": "一致するモデルが見つかりません", + "noConflicts": "競合なし", + "applyOverwrite": "上書きを適用", + "applying": "適用中..." }, "table": { "modelName": "モデル名", @@ -552,6 +585,7 @@ "inputPrice": "入力価格 ($/M)", "outputPrice": "出力価格 ($/M)", "updatedAt": "更新日時", + "actions": "操作", "typeChat": "チャット", "typeImage": "画像生成", "typeCompletion": "補完", @@ -601,6 +635,34 @@ "details": "詳細", "viewDetails": "詳細ログを表示" } + }, + "addModel": "モデルを追加", + "editModel": "モデルを編集", + "deleteModel": "モデルを削除", + "addModelDescription": "新しいモデル価格設定を手動で追加します", + "editModelDescription": "モデルの価格設定を編集します", + "deleteConfirm": "モデル {name} を削除してもよろしいですか?この操作は元に戻せません。", + "form": { + "modelName": "モデル名", + "modelNamePlaceholder": "例: gpt-5.2-codex", + "modelNameRequired": "モデル名は必須です", + "type": "タイプ", + "provider": "プロバイダー", + "providerPlaceholder": "例: openai", + "inputPrice": "入力価格 ($/M tokens)", + "outputPrice": "出力価格 ($/M tokens)", + "outputPriceImage": "出力価格 ($/image)" + }, + "actions": { + "edit": "編集", + "delete": "削除" + }, + "toast": { + "createSuccess": "モデルを追加しました", + "updateSuccess": "モデルを更新しました", + "deleteSuccess": "モデルを削除しました", + "saveFailed": "保存に失敗しました", + "deleteFailed": "削除に失敗しました" } }, "providers": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index e0fd846da..1b332bc7e 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1350,6 +1350,10 @@ "threeMonths": "Через 3 месяца", "oneYear": "Через год" }, + "providerGroupSelect": { + "providersSuffix": "провайдеров", + "loadFailed": "Не удалось загрузить группы провайдеров" + }, "providerGroup": { "label": "Группа провайдеров", "placeholder": "Выберите группу провайдеров", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 8f7730d3b..4fe8730ae 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -537,13 +537,46 @@ "sync": { "button": "Синхронизировать цены LiteLLM", "syncing": "Синхронизация...", + "checking": "Проверка конфликтов...", "successWithChanges": "Обновление прайс-листа: добавлено {added}, обновлено {updated}, без изменений {unchanged}", "successNoChanges": "Прайс-лист актуален, обновление не требуется", "failed": "Ошибка синхронизации", "failedError": "Ошибка синхронизации: {error}", "failedNoResult": "Прайс-лист обновлен но результат не возвращен", "noModels": "Цены моделей не найдены", - "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить" + "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить", + "skippedConflicts": "Пропущено {count} ручных моделей" + }, + "conflict": { + "title": "Выберите элементы для перезаписи", + "description": "Следующие модели имеют ручные цены. Отмеченные будут перезаписаны ценами LiteLLM, неотмеченные останутся без изменений", + "searchPlaceholder": "Поиск моделей...", + "table": { + "modelName": "Модель", + "manualPrice": "Ручная цена", + "litellmPrice": "Цена LiteLLM", + "action": "Действие" + }, + "viewDiff": "Показать различия", + "diffTitle": "Различия цен", + "diff": { + "field": "Поле", + "manual": "Ручное", + "litellm": "LiteLLM", + "inputPrice": "Цена ввода", + "outputPrice": "Цена вывода", + "imagePrice": "Цена изображения", + "provider": "Поставщик", + "mode": "Тип" + }, + "pagination": { + "showing": "Показано {from}-{to} из {total}" + }, + "selectedCount": "Выбрано {count}/{total} моделей", + "noMatch": "Модели не найдены", + "noConflicts": "Конфликтов нет", + "applyOverwrite": "Применить перезапись", + "applying": "Применение..." }, "table": { "modelName": "Название модели", @@ -552,6 +585,7 @@ "inputPrice": "Цена ввода ($/M)", "outputPrice": "Цена вывода ($/M)", "updatedAt": "Обновлено", + "actions": "Действия", "typeChat": "Чат", "typeImage": "Генерация изображений", "typeCompletion": "Дополнение", @@ -601,6 +635,34 @@ "details": "Подробности", "viewDetails": "Просмотреть подробный журнал" } + }, + "addModel": "Добавить модель", + "editModel": "Редактировать модель", + "deleteModel": "Удалить модель", + "addModelDescription": "Вручную добавить новую цену модели", + "editModelDescription": "Редактировать цену модели", + "deleteConfirm": "Удалить модель {name}? Это действие необратимо.", + "form": { + "modelName": "Название модели", + "modelNamePlaceholder": "например: gpt-5.2-codex", + "modelNameRequired": "Название модели обязательно", + "type": "Тип", + "provider": "Поставщик", + "providerPlaceholder": "например: openai", + "inputPrice": "Цена ввода ($/M токенов)", + "outputPrice": "Цена вывода ($/M токенов)", + "outputPriceImage": "Цена вывода ($/изображение)" + }, + "actions": { + "edit": "Редактировать", + "delete": "Удалить" + }, + "toast": { + "createSuccess": "Модель добавлена", + "updateSuccess": "Модель обновлена", + "deleteSuccess": "Модель удалена", + "saveFailed": "Ошибка сохранения", + "deleteFailed": "Ошибка удаления" } }, "providers": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index d027152a4..40b1be77a 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1378,6 +1378,10 @@ "threeMonths": "三月后", "oneYear": "一年后" }, + "providerGroupSelect": { + "providersSuffix": "个供应商", + "loadFailed": "加载供应商分组失败" + }, "providerGroup": { "label": "供应商分组", "placeholder": "选择供应商分组", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 147b1b227..a63c08b93 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -1265,13 +1265,46 @@ "sync": { "button": "同步 LiteLLM 价格", "syncing": "同步中...", + "checking": "检查冲突...", "successWithChanges": "价格表更新: 新增 {added} 个,更新 {updated} 个,未变化 {unchanged} 个", "successNoChanges": "价格表已是最新,无需更新", "failed": "同步失败", "failedError": "同步失败: {error}", "failedNoResult": "价格表更新成功但未返回处理结果", "noModels": "未找到支持的模型价格", - "partialFailure": "部分更新成功,但有 {failed} 个模型失败" + "partialFailure": "部分更新成功,但有 {failed} 个模型失败", + "skippedConflicts": "跳过 {count} 个手动模型" + }, + "conflict": { + "title": "选择要覆盖的冲突项", + "description": "以下模型存在手动维护的价格,勾选后将用 LiteLLM 价格覆盖,未勾选的保持本地不变", + "searchPlaceholder": "搜索模型...", + "table": { + "modelName": "模型", + "manualPrice": "手动价格", + "litellmPrice": "LiteLLM 价格", + "action": "操作" + }, + "viewDiff": "查看差异", + "diffTitle": "价格差异对比", + "diff": { + "field": "字段", + "manual": "手动", + "litellm": "LiteLLM", + "inputPrice": "输入价格", + "outputPrice": "输出价格", + "imagePrice": "图片价格", + "provider": "供应商", + "mode": "类型" + }, + "pagination": { + "showing": "显示 {from}-{to} 条,共 {total} 条" + }, + "selectedCount": "已选择 {count}/{total} 个模型", + "noMatch": "未找到匹配的模型", + "noConflicts": "无冲突项", + "applyOverwrite": "应用覆盖", + "applying": "应用中..." }, "table": { "modelName": "模型名称", @@ -1280,6 +1313,7 @@ "inputPrice": "输入价格 ($/M)", "outputPrice": "输出价格 ($/M)", "updatedAt": "更新时间", + "actions": "操作", "typeChat": "对话", "typeImage": "图像生成", "typeCompletion": "补全", @@ -1329,6 +1363,34 @@ "details": "详细信息", "viewDetails": "查看详细日志" } + }, + "addModel": "添加模型", + "editModel": "编辑模型", + "deleteModel": "删除模型", + "addModelDescription": "手动添加新的模型价格配置", + "editModelDescription": "编辑模型的价格配置", + "deleteConfirm": "确定要删除模型 {name} 吗?此操作不可撤销。", + "form": { + "modelName": "模型名称", + "modelNamePlaceholder": "例如: gpt-5.2-codex", + "modelNameRequired": "模型名称不能为空", + "type": "类型", + "provider": "供应商", + "providerPlaceholder": "例如: openai", + "inputPrice": "输入价格 ($/M tokens)", + "outputPrice": "输出价格 ($/M tokens)", + "outputPriceImage": "输出价格 ($/image)" + }, + "actions": { + "edit": "编辑", + "delete": "删除" + }, + "toast": { + "createSuccess": "模型已添加", + "updateSuccess": "模型已更新", + "deleteSuccess": "模型已删除", + "saveFailed": "保存失败", + "deleteFailed": "删除失败" } }, "sensitiveWords": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index c7bc527a4..5751f6d32 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1348,6 +1348,10 @@ "threeMonths": "三個月後", "oneYear": "一年後" }, + "providerGroupSelect": { + "providersSuffix": "個供應商", + "loadFailed": "載入供應商分組失敗" + }, "providerGroup": { "label": "供應商分組", "placeholder": "選擇供應商分組", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index fb33cf1b5..2cf3fe91b 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -537,13 +537,46 @@ "sync": { "button": "同步 LiteLLM 價格", "syncing": "同步中...", + "checking": "檢查衝突...", "successWithChanges": "價格表更新: 新增 {added} 個,更新 {updated} 個,未變化 {unchanged} 個", "successNoChanges": "價格表已是最新,無需更新", "failed": "同步失敗", "failedError": "同步失敗: {error}", "failedNoResult": "價格表更新成功但未返回處理結果", "noModels": "未找到支援的模型價格", - "partialFailure": "部分更新成功,但有 {failed} 個模型失敗" + "partialFailure": "部分更新成功,但有 {failed} 個模型失敗", + "skippedConflicts": "跳過 {count} 個手動模型" + }, + "conflict": { + "title": "選擇要覆蓋的衝突項", + "description": "以下模型存在手動維護的價格,勾選後將用 LiteLLM 價格覆蓋,未勾選的保持本地不變", + "searchPlaceholder": "搜尋模型...", + "table": { + "modelName": "模型", + "manualPrice": "手動價格", + "litellmPrice": "LiteLLM 價格", + "action": "操作" + }, + "viewDiff": "查看差異", + "diffTitle": "價格差異對比", + "diff": { + "field": "欄位", + "manual": "手動", + "litellm": "LiteLLM", + "inputPrice": "輸入價格", + "outputPrice": "輸出價格", + "imagePrice": "圖片價格", + "provider": "供應商", + "mode": "類型" + }, + "pagination": { + "showing": "顯示 {from}-{to} 條,共 {total} 條" + }, + "selectedCount": "已選擇 {count}/{total} 個模型", + "noMatch": "未找到符合的模型", + "noConflicts": "無衝突項", + "applyOverwrite": "套用覆蓋", + "applying": "套用中..." }, "table": { "modelName": "模型名稱", @@ -552,6 +585,7 @@ "inputPrice": "輸入價格 ($/M)", "outputPrice": "輸出價格 ($/M)", "updatedAt": "更新時間", + "actions": "操作", "typeChat": "對話", "typeImage": "圖像生成", "typeCompletion": "補全", @@ -601,6 +635,34 @@ "details": "詳細資訊", "viewDetails": "檢視詳細記錄" } + }, + "addModel": "新增模型", + "editModel": "編輯模型", + "deleteModel": "刪除模型", + "addModelDescription": "手動新增模型價格設定", + "editModelDescription": "編輯模型的價格設定", + "deleteConfirm": "確定要刪除模型 {name} 嗎?此操作無法復原。", + "form": { + "modelName": "模型名稱", + "modelNamePlaceholder": "例如: gpt-5.2-codex", + "modelNameRequired": "模型名稱為必填", + "type": "類型", + "provider": "提供商", + "providerPlaceholder": "例如: openai", + "inputPrice": "輸入價格 ($/M tokens)", + "outputPrice": "輸出價格 ($/M tokens)", + "outputPriceImage": "輸出價格 ($/張圖)" + }, + "actions": { + "edit": "編輯", + "delete": "刪除" + }, + "toast": { + "createSuccess": "模型已新增", + "updateSuccess": "模型已更新", + "deleteSuccess": "模型已刪除", + "saveFailed": "儲存失敗", + "deleteFailed": "刪除失敗" } }, "providers": { diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index c082ebfb7..9d25caef7 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -6,18 +6,23 @@ import { logger } from "@/lib/logger"; import { getPriceTableJson } from "@/lib/price-sync"; import { createModelPrice, + deleteModelPriceByName, findAllLatestPrices, findAllLatestPricesPaginated, + findAllManualPrices, findLatestPriceByModel, hasAnyPriceRecords, type PaginatedResult, type PaginationParams, + upsertModelPrice, } from "@/repository/model-price"; import type { ModelPrice, ModelPriceData, PriceTableJson, PriceUpdateResult, + SyncConflict, + SyncConflictCheckResult, } from "@/types/model-price"; import type { ActionResult } from "./types"; @@ -32,9 +37,12 @@ function isPriceDataEqual(data1: ModelPriceData, data2: ModelPriceData): boolean /** * 价格表处理核心逻辑(内部函数,无权限检查) * 用于系统初始化和 Web UI 上传 + * @param jsonContent - 价格表 JSON 内容 + * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 */ export async function processPriceTableInternal( - jsonContent: string + jsonContent: string, + overwriteManual?: string[] ): Promise> { try { // 解析JSON内容 @@ -63,12 +71,19 @@ export async function processPriceTableInternal( return typeof modelName === "string" && modelName.trim().length > 0; }); + // 创建覆盖列表的 Set 用于快速查找 + const overwriteSet = new Set(overwriteManual ?? []); + + // 获取所有手动添加的模型(用于冲突检测) + const manualPrices = await findAllManualPrices(); + const result: PriceUpdateResult = { added: [], updated: [], unchanged: [], failed: [], total: entries.length, + skippedConflicts: [], }; // 处理每个模型的价格 @@ -88,23 +103,37 @@ export async function processPriceTableInternal( continue; } + // 检查是否存在手动添加的价格且不在覆盖列表中 + const isManualPrice = manualPrices.has(modelName); + if (isManualPrice && !overwriteSet.has(modelName)) { + // 跳过手动添加的模型,记录到 skippedConflicts + result.skippedConflicts?.push(modelName); + result.unchanged.push(modelName); + logger.debug(`跳过手动添加的模型: ${modelName}`); + continue; + } + // 查找该模型的最新价格 const existingPrice = await findLatestPriceByModel(modelName); if (!existingPrice) { // 模型不存在,新增记录 - await createModelPrice(modelName, priceData); + await createModelPrice(modelName, priceData, "litellm"); result.added.push(modelName); } else if (!isPriceDataEqual(existingPrice.priceData, priceData)) { - // 模型存在但价格发生变化,新增记录 - await createModelPrice(modelName, priceData); + // 模型存在但价格发生变化 + // 如果是手动模型且在覆盖列表中,先删除旧记录 + if (isManualPrice && overwriteSet.has(modelName)) { + await deleteModelPriceByName(modelName); + } + await createModelPrice(modelName, priceData, "litellm"); result.updated.push(modelName); } else { // 价格未发生变化,不需要更新 result.unchanged.push(modelName); } } catch (error) { - logger.error("处理模型 ${modelName} 失败:", error); + logger.error(`处理模型 ${modelName} 失败:`, error); result.failed.push(modelName); } } @@ -122,9 +151,11 @@ export async function processPriceTableInternal( /** * 上传并更新模型价格表(Web UI 入口,包含权限检查) + * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 */ export async function uploadPriceTable( - jsonContent: string + jsonContent: string, + overwriteManual?: string[] ): Promise> { // 权限检查:只有管理员可以上传价格表 const session = await getSession(); @@ -133,7 +164,7 @@ export async function uploadPriceTable( } // 调用核心逻辑 - return processPriceTableInternal(jsonContent); + return processPriceTableInternal(jsonContent, overwriteManual); } /** @@ -241,11 +272,76 @@ export async function getAvailableModelsByProviderType(): Promise { * 获取指定模型的最新价格 */ +/** + * 检查 LiteLLM 同步是否会产生冲突 + * @returns 冲突检查结果,包含是否有冲突以及冲突列表 + */ +export async function checkLiteLLMSyncConflicts(): Promise> { + try { + // 权限检查:只有管理员可以检查冲突 + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + // 获取价格表 JSON + const jsonContent = await getPriceTableJson(); + if (!jsonContent) { + return { + ok: false, + error: "无法从 CDN 或缓存获取价格表,请检查网络连接或稍后重试", + }; + } + + // 解析 JSON + let priceTable: PriceTableJson; + try { + priceTable = JSON.parse(jsonContent); + } catch { + return { ok: false, error: "JSON格式不正确" }; + } + + // 获取数据库中所有 manual 价格 + const manualPrices = await findAllManualPrices(); + logger.info(`[Conflict Check] Found ${manualPrices.size} manual prices in database`); + + // 构建冲突列表:检查哪些 manual 模型会被 LiteLLM 同步覆盖 + const conflicts: SyncConflict[] = []; + for (const [modelName, manualPrice] of manualPrices) { + const litellmPrice = priceTable[modelName]; + if (litellmPrice && typeof litellmPrice === "object" && "mode" in litellmPrice) { + conflicts.push({ + modelName, + manualPrice: manualPrice.priceData, + litellmPrice: litellmPrice as ModelPriceData, + }); + } + } + + logger.info(`[Conflict Check] Found ${conflicts.length} conflicts`); + + return { + ok: true, + data: { + hasConflicts: conflicts.length > 0, + conflicts, + }, + }; + } catch (error) { + logger.error("检查同步冲突失败:", error); + const message = error instanceof Error ? error.message : "检查失败,请稍后重试"; + return { ok: false, error: message }; + } +} + /** * 从 LiteLLM CDN 同步价格表到数据库 + * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 * @returns 同步结果 */ -export async function syncLiteLLMPrices(): Promise> { +export async function syncLiteLLMPrices( + overwriteManual?: string[] +): Promise> { try { // 权限检查:只有管理员可以同步价格表 const session = await getSession(); @@ -267,7 +363,7 @@ export async function syncLiteLLMPrices(): Promise> { + try { + // 权限检查:只有管理员可以操作 + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + // 验证输入 + if (!input.modelName?.trim()) { + return { ok: false, error: "模型名称不能为空" }; + } + + // 验证价格非负 + if ( + input.inputCostPerToken !== undefined && + (input.inputCostPerToken < 0 || !Number.isFinite(input.inputCostPerToken)) + ) { + return { ok: false, error: "输入价格必须为非负数" }; + } + if ( + input.outputCostPerToken !== undefined && + (input.outputCostPerToken < 0 || !Number.isFinite(input.outputCostPerToken)) + ) { + return { ok: false, error: "输出价格必须为非负数" }; + } + if ( + input.outputCostPerImage !== undefined && + (input.outputCostPerImage < 0 || !Number.isFinite(input.outputCostPerImage)) + ) { + return { ok: false, error: "图片价格必须为非负数" }; + } + + // 构建价格数据 + const priceData: ModelPriceData = { + mode: input.mode, + litellm_provider: input.litellmProvider || undefined, + input_cost_per_token: input.inputCostPerToken, + output_cost_per_token: input.outputCostPerToken, + output_cost_per_image: input.outputCostPerImage, + }; + + // 执行更新 + const result = await upsertModelPrice(input.modelName.trim(), priceData); + + // 刷新页面数据 + revalidatePath("/settings/prices"); + + return { ok: true, data: result }; + } catch (error) { + logger.error("更新模型价格失败:", error); + const message = error instanceof Error ? error.message : "操作失败,请稍后重试"; + return { ok: false, error: message }; + } +} + +/** + * 删除单个模型价格(硬删除) + */ +export async function deleteSingleModelPrice(modelName: string): Promise> { + try { + // 权限检查:只有管理员可以操作 + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + // 验证输入 + if (!modelName?.trim()) { + return { ok: false, error: "模型名称不能为空" }; + } + + // 执行删除 + await deleteModelPriceByName(modelName.trim()); + + // 刷新页面数据 + revalidatePath("/settings/prices"); + + return { ok: true, data: undefined }; + } catch (error) { + logger.error("删除模型价格失败:", error); + const message = error instanceof Error ? error.message : "删除失败,请稍后重试"; + return { ok: false, error: message }; + } +} diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 5d6b3a97f..e808276f9 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -72,10 +72,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const tCommon = useTranslations("common"); const [isPending, startTransition] = useTransition(); - // Use shared hooks - const modelSuggestions = useModelSuggestions(user.providerGroup); - const showUserProviderGroup = Boolean(user.providerGroup?.trim()); - const userEditTranslations = useUserTranslations({ showProviderGroup: showUserProviderGroup }); + // Always show providerGroup field in edit mode + const userEditTranslations = useUserTranslations({ showProviderGroup: true }); const defaultValues = useMemo(() => buildDefaultValues(user), [user]); @@ -125,6 +123,9 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const currentUserDraft = form.values || defaultValues; + // Model suggestions based on current providerGroup value + const modelSuggestions = useModelSuggestions(currentUserDraft.providerGroup); + const handleUserChange = (field: string | Record, value?: any) => { const prev = form.values || defaultValues; const next = { ...prev } as EditUserValues; @@ -236,7 +237,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr await handleEnableUser(); } }} - showProviderGroup={showUserProviderGroup} + showProviderGroup onChange={handleUserChange} translations={userEditTranslations} modelSuggestions={modelSuggestions} diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx index e8a691d60..1f8b30cdf 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx @@ -14,7 +14,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -23,6 +22,7 @@ import { cn } from "@/lib/utils"; import { AccessRestrictionsSection } from "./access-restrictions-section"; import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker"; import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display"; +import { ProviderGroupSelect } from "./provider-group-select"; import { QuickExpirePicker } from "./quick-expire-picker"; export interface UserEditSectionProps { @@ -69,6 +69,17 @@ export interface UserEditSectionProps { providerGroup?: { label: string; placeholder: string; + providersSuffix?: string; + tagInputErrors?: { + empty?: string; + duplicate?: string; + too_long?: string; + invalid_format?: string; + max_tags?: string; + }; + errors?: { + loadFailed?: string; + }; }; enableStatus?: { label: string; @@ -411,20 +422,12 @@ export function UserEditSection({ /> {showProviderGroup && translations.fields.providerGroup && ( -
- -
- {(user.providerGroup || PROVIDER_GROUP.DEFAULT) - .split(",") - .map((g) => g.trim()) - .filter(Boolean) - .map((group) => ( - - {group} - - ))} -
-
+ emitChange("providerGroup", val)} + disabled={false} + translations={translations.fields.providerGroup} + /> )} diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts index 1360c99f1..96cb4e8de 100644 --- a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts +++ b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts @@ -26,6 +26,17 @@ export interface UserEditTranslations { providerGroup?: { label: string; placeholder: string; + providersSuffix?: string; + tagInputErrors?: { + empty?: string; + duplicate?: string; + too_long?: string; + invalid_format?: string; + max_tags?: string; + }; + errors?: { + loadFailed?: string; + }; }; enableStatus: { label: string; @@ -98,6 +109,7 @@ export function useUserTranslations( ): UserEditTranslations { const { showProviderGroup = false } = options; const t = useTranslations("dashboard.userManagement"); + const tUi = useTranslations("ui.tagInput"); return useMemo(() => { return { @@ -124,6 +136,17 @@ export function useUserTranslations( ? { label: t("userEditSection.fields.providerGroup.label"), placeholder: t("userEditSection.fields.providerGroup.placeholder"), + providersSuffix: t("providerGroupSelect.providersSuffix"), + tagInputErrors: { + empty: tUi("emptyTag"), + duplicate: tUi("duplicateTag"), + too_long: tUi("tooLong", { max: 50 }), + invalid_format: tUi("invalidFormat"), + max_tags: tUi("maxTags"), + }, + errors: { + loadFailed: t("providerGroupSelect.loadFailed"), + }, } : undefined, enableStatus: { @@ -187,5 +210,5 @@ export function useUserTranslations( year: t("quickExpire.oneYear"), }, }; - }, [t, showProviderGroup]); + }, [t, tUi, showProviderGroup]); } diff --git a/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx b/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx new file mode 100644 index 000000000..e11781ccc --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Loader2, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { toast } from "sonner"; +import { deleteSingleModelPrice } from "@/actions/model-prices"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; + +interface DeleteModelDialogProps { + modelName: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +/** + * 删除模型价格确认对话框 + */ +export function DeleteModelDialog({ modelName, trigger, onSuccess }: DeleteModelDialogProps) { + const t = useTranslations("settings.prices"); + const tCommon = useTranslations("settings.common"); + + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const handleDelete = async () => { + setLoading(true); + + try { + const result = await deleteSingleModelPrice(modelName); + + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success(t("toast.deleteSuccess")); + setOpen(false); + onSuccess?.(); + window.dispatchEvent(new Event("price-data-updated")); + } catch (error) { + console.error("删除失败:", error); + toast.error(t("toast.deleteFailed")); + } finally { + setLoading(false); + } + }; + + const defaultTrigger = ( + + ); + + return ( + + {trigger || defaultTrigger} + + + {t("deleteModel")} + {t("deleteConfirm", { name: modelName })} + + + {tCommon("cancel")} + { + e.preventDefault(); + handleDelete(); + }} + disabled={loading} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {loading && } + {tCommon("delete")} + + + + + ); +} diff --git a/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx new file mode 100644 index 000000000..dba1c1c34 --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { Loader2, Pencil, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { upsertSingleModelPrice } from "@/actions/model-prices"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ModelPrice } from "@/types/model-price"; + +interface ModelPriceDialogProps { + mode: "create" | "edit"; + initialData?: ModelPrice; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +type ModelMode = "chat" | "image_generation" | "completion"; + +/** + * 模型价格添加/编辑对话框 + */ +export function ModelPriceDialog({ mode, initialData, trigger, onSuccess }: ModelPriceDialogProps) { + const t = useTranslations("settings.prices"); + const tCommon = useTranslations("settings.common"); + + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + // 表单状态 + const [modelName, setModelName] = useState(""); + const [modelMode, setModelMode] = useState("chat"); + const [provider, setProvider] = useState(""); + const [inputPrice, setInputPrice] = useState(""); + const [outputPrice, setOutputPrice] = useState(""); + + // 当对话框打开或初始数据变化时,重置表单 + useEffect(() => { + if (open) { + if (mode === "edit" && initialData) { + setModelName(initialData.modelName); + setModelMode((initialData.priceData.mode as ModelMode) || "chat"); + setProvider(initialData.priceData.litellm_provider || ""); + // 将每 token 价格转换为每百万 token 价格显示 + setInputPrice( + initialData.priceData.input_cost_per_token + ? (initialData.priceData.input_cost_per_token * 1000000).toString() + : "" + ); + if (initialData.priceData.mode === "image_generation") { + setOutputPrice( + initialData.priceData.output_cost_per_image + ? initialData.priceData.output_cost_per_image.toString() + : "" + ); + } else { + setOutputPrice( + initialData.priceData.output_cost_per_token + ? (initialData.priceData.output_cost_per_token * 1000000).toString() + : "" + ); + } + } else { + // 创建模式,清空表单 + setModelName(""); + setModelMode("chat"); + setProvider(""); + setInputPrice(""); + setOutputPrice(""); + } + } + }, [open, mode, initialData]); + + const handleSubmit = async () => { + // 验证 + if (!modelName.trim()) { + toast.error(t("form.modelNameRequired")); + return; + } + + setLoading(true); + + try { + // 将每百万 token 价格转换回每 token 价格 + const inputCostPerToken = inputPrice ? parseFloat(inputPrice) / 1000000 : undefined; + const outputCostPerToken = + modelMode !== "image_generation" && outputPrice + ? parseFloat(outputPrice) / 1000000 + : undefined; + const outputCostPerImage = + modelMode === "image_generation" && outputPrice ? parseFloat(outputPrice) : undefined; + + const result = await upsertSingleModelPrice({ + modelName: modelName.trim(), + mode: modelMode, + litellmProvider: provider.trim() || undefined, + inputCostPerToken, + outputCostPerToken, + outputCostPerImage, + }); + + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success(mode === "create" ? t("toast.createSuccess") : t("toast.updateSuccess")); + setOpen(false); + onSuccess?.(); + window.dispatchEvent(new Event("price-data-updated")); + } catch (error) { + console.error("保存失败:", error); + toast.error(t("toast.saveFailed")); + } finally { + setLoading(false); + } + }; + + const defaultTrigger = + mode === "create" ? ( + + ) : ( + + ); + + return ( + + {trigger || defaultTrigger} + + + {mode === "create" ? t("addModel") : t("editModel")} + + {mode === "create" ? t("addModelDescription") : t("editModelDescription")} + + + +
+ {/* 模型名称 */} +
+ + setModelName(e.target.value)} + placeholder={t("form.modelNamePlaceholder")} + disabled={mode === "edit" || loading} + /> +
+ + {/* 类型 */} +
+ + +
+ + {/* 供应商 */} +
+ + setProvider(e.target.value)} + placeholder={t("form.providerPlaceholder")} + disabled={loading} + /> +
+ + {/* 输入价格 */} + {modelMode !== "image_generation" && ( +
+ +
+ + $ + + setInputPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-12" + disabled={loading} + /> + + /M + +
+
+ )} + + {/* 输出价格 */} +
+ +
+ + $ + + setOutputPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-16" + disabled={loading} + /> + + {modelMode === "image_generation" ? "/img" : "/M"} + +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/app/[locale]/settings/prices/_components/price-list.tsx b/src/app/[locale]/settings/prices/_components/price-list.tsx index 17bb114d6..f471c957f 100644 --- a/src/app/[locale]/settings/prices/_components/price-list.tsx +++ b/src/app/[locale]/settings/prices/_components/price-list.tsx @@ -1,10 +1,25 @@ "use client"; -import { ChevronLeft, ChevronRight, DollarSign, Package, Search } from "lucide-react"; +import { + ChevronLeft, + ChevronRight, + DollarSign, + MoreHorizontal, + Package, + Pencil, + Search, + Trash2, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Select, @@ -23,6 +38,8 @@ import { } from "@/components/ui/table"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { ModelPrice } from "@/types/model-price"; +import { DeleteModelDialog } from "./delete-model-dialog"; +import { ModelPriceDialog } from "./model-price-dialog"; interface PriceListProps { initialPrices: ModelPrice[]; @@ -112,6 +129,16 @@ export function PriceList({ [] ); + // 监听价格数据变化事件(由其他组件触发) + useEffect(() => { + const handlePriceUpdate = () => { + fetchPrices(page, pageSize, debouncedSearchTerm); + }; + + window.addEventListener("price-data-updated", handlePriceUpdate); + return () => window.removeEventListener("price-data-updated", handlePriceUpdate); + }, [page, pageSize, debouncedSearchTerm, fetchPrices]); + // 当防抖后的搜索词变化时,触发搜索(重置到第一页) useEffect(() => { // 跳过初始渲染(当 debouncedSearchTerm 等于初始 searchTerm 时) @@ -229,12 +256,13 @@ export function PriceList({ {t("table.inputPrice")} {t("table.outputPrice")} {t("table.updatedAt")} + {t("table.actions")} {isLoading ? ( - +
{t("table.loading")} @@ -274,11 +302,46 @@ export function PriceList({ {new Date(price.createdAt).toLocaleDateString("zh-CN")} + + + + + + + fetchPrices(page, pageSize, debouncedSearchTerm)} + trigger={ + e.preventDefault()}> + + {t("actions.edit")} + + } + /> + fetchPrices(page, pageSize, debouncedSearchTerm)} + trigger={ + e.preventDefault()} + className="text-destructive focus:text-destructive" + > + + {t("actions.delete")} + + } + /> + + + )) ) : ( - +
{searchTerm ? ( <> diff --git a/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx new file mode 100644 index 000000000..2940ba240 --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { AlertTriangle, ChevronLeft, ChevronRight, Eye, Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { ModelPriceData, SyncConflict } from "@/types/model-price"; + +interface SyncConflictDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + conflicts: SyncConflict[]; + onConfirm: (selectedModels: string[]) => void; + isLoading?: boolean; +} + +const PAGE_SIZE = 10; + +/** + * 格式化价格显示为每百万token的价格 + */ +function formatPrice(value?: number): string { + if (value === undefined || value === null) return "-"; + const pricePerMillion = value * 1000000; + if (pricePerMillion < 0.01) { + return `$${pricePerMillion.toFixed(4)}/M`; + } else if (pricePerMillion < 1) { + return `$${pricePerMillion.toFixed(3)}/M`; + } else if (pricePerMillion < 100) { + return `$${pricePerMillion.toFixed(2)}/M`; + } + return `$${pricePerMillion.toFixed(0)}/M`; +} + +/** + * 价格差异对比 Popover + */ +function PriceDiffPopover({ + manualPrice, + litellmPrice, +}: { + manualPrice: ModelPriceData; + litellmPrice: ModelPriceData; +}) { + const t = useTranslations("settings.prices.conflict"); + + const diffs = useMemo(() => { + const items: Array<{ + field: string; + manual: string; + litellm: string; + changed: boolean; + }> = []; + + // 输入价格 + const manualInput = formatPrice(manualPrice.input_cost_per_token); + const litellmInput = formatPrice(litellmPrice.input_cost_per_token); + items.push({ + field: t("diff.inputPrice"), + manual: manualInput, + litellm: litellmInput, + changed: manualInput !== litellmInput, + }); + + // 输出价格 + const manualOutput = formatPrice(manualPrice.output_cost_per_token); + const litellmOutput = formatPrice(litellmPrice.output_cost_per_token); + items.push({ + field: t("diff.outputPrice"), + manual: manualOutput, + litellm: litellmOutput, + changed: manualOutput !== litellmOutput, + }); + + // 图片价格(如果有) + if (manualPrice.output_cost_per_image || litellmPrice.output_cost_per_image) { + const manualImg = manualPrice.output_cost_per_image + ? `$${manualPrice.output_cost_per_image}/img` + : "-"; + const litellmImg = litellmPrice.output_cost_per_image + ? `$${litellmPrice.output_cost_per_image}/img` + : "-"; + items.push({ + field: t("diff.imagePrice"), + manual: manualImg, + litellm: litellmImg, + changed: manualImg !== litellmImg, + }); + } + + // 供应商 + const manualProvider = manualPrice.litellm_provider || "-"; + const litellmProvider = litellmPrice.litellm_provider || "-"; + items.push({ + field: t("diff.provider"), + manual: manualProvider, + litellm: litellmProvider, + changed: manualProvider !== litellmProvider, + }); + + // 类型 + const manualMode = manualPrice.mode || "-"; + const litellmMode = litellmPrice.mode || "-"; + items.push({ + field: t("diff.mode"), + manual: manualMode, + litellm: litellmMode, + changed: manualMode !== litellmMode, + }); + + return items; + }, [manualPrice, litellmPrice, t]); + + return ( + + + + + +
+
{t("diffTitle")}
+ + + + {t("diff.field")} + {t("diff.manual")} + {t("diff.litellm")} + + + + {diffs.map((diff) => ( + + {diff.field} + + {diff.changed ? ( + {diff.manual} + ) : ( + diff.manual + )} + + + {diff.changed ? ( + {diff.litellm} + ) : ( + diff.litellm + )} + + + ))} + +
+
+
+
+ ); +} + +/** + * 同步冲突对比弹窗 + */ +export function SyncConflictDialog({ + open, + onOpenChange, + conflicts, + onConfirm, + isLoading = false, +}: SyncConflictDialogProps) { + const t = useTranslations("settings.prices.conflict"); + const tCommon = useTranslations("settings.common"); + + const [selectedModels, setSelectedModels] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useState(""); + const [page, setPage] = useState(1); + + // 过滤冲突列表 + const filteredConflicts = useMemo(() => { + if (!searchTerm.trim()) return conflicts; + const term = searchTerm.toLowerCase(); + return conflicts.filter((c) => c.modelName.toLowerCase().includes(term)); + }, [conflicts, searchTerm]); + + // 分页 + const totalPages = Math.ceil(filteredConflicts.length / PAGE_SIZE); + const paginatedConflicts = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredConflicts.slice(start, start + PAGE_SIZE); + }, [filteredConflicts, page]); + + // 全选/取消全选(仅当前页) + const allCurrentPageSelected = paginatedConflicts.every((c) => selectedModels.has(c.modelName)); + const someCurrentPageSelected = + paginatedConflicts.some((c) => selectedModels.has(c.modelName)) && !allCurrentPageSelected; + + const handleSelectAll = (checked: boolean) => { + const newSelected = new Set(selectedModels); + if (checked) { + paginatedConflicts.forEach((c) => newSelected.add(c.modelName)); + } else { + paginatedConflicts.forEach((c) => newSelected.delete(c.modelName)); + } + setSelectedModels(newSelected); + }; + + const handleSelectModel = (modelName: string, checked: boolean) => { + const newSelected = new Set(selectedModels); + if (checked) { + newSelected.add(modelName); + } else { + newSelected.delete(modelName); + } + setSelectedModels(newSelected); + }; + + const handleConfirm = () => { + onConfirm(Array.from(selectedModels)); + }; + + const handleCancel = () => { + // 取消时不覆盖任何手动模型 + onConfirm([]); + }; + + // 搜索时重置页码 + const handleSearchChange = (value: string) => { + setSearchTerm(value); + setPage(1); + }; + + return ( + + + + + + {t("title")} + + {t("description")} + + +
+ {/* 搜索框 */} +
+ + handleSearchChange(e.target.value)} + className="pl-9" + /> +
+ + {/* 冲突列表 */} +
+ + + + + { + if (el) { + (el as HTMLButtonElement & { indeterminate?: boolean }).indeterminate = + someCurrentPageSelected; + } + }} + onCheckedChange={handleSelectAll} + /> + + {t("table.modelName")} + {t("table.manualPrice")} + {t("table.litellmPrice")} + {t("table.action")} + + + + {paginatedConflicts.length > 0 ? ( + paginatedConflicts.map((conflict) => ( + + + + handleSelectModel(conflict.modelName, !!checked) + } + /> + + {conflict.modelName} + + + {formatPrice(conflict.manualPrice.input_cost_per_token)} + + + + + {formatPrice(conflict.litellmPrice.input_cost_per_token)} + + + + + + + )) + ) : ( + + + {searchTerm ? t("noMatch") : t("noConflicts")} + + + )} + +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + {t("pagination.showing", { + from: (page - 1) * PAGE_SIZE + 1, + to: Math.min(page * PAGE_SIZE, filteredConflicts.length), + total: filteredConflicts.length, + })} + +
+ + + {page} / {totalPages} + + +
+
+ )} + + {/* 选中统计 */} +
+ {t("selectedCount", { count: selectedModels.size, total: conflicts.length })} +
+
+ + + + + +
+
+ ); +} diff --git a/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx b/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx index 78edbeeee..ed0f77cb0 100644 --- a/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx +++ b/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx @@ -5,8 +5,10 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; -import { syncLiteLLMPrices } from "@/actions/model-prices"; +import { checkLiteLLMSyncConflicts, syncLiteLLMPrices } from "@/actions/model-prices"; import { Button } from "@/components/ui/button"; +import type { SyncConflict } from "@/types/model-price"; +import { SyncConflictDialog } from "./sync-conflict-dialog"; /** * LiteLLM 价格同步按钮组件 @@ -15,12 +17,20 @@ export function SyncLiteLLMButton() { const t = useTranslations("settings"); const router = useRouter(); const [syncing, setSyncing] = useState(false); + const [checking, setChecking] = useState(false); - const handleSync = async () => { + // 冲突弹窗状态 + const [conflictDialogOpen, setConflictDialogOpen] = useState(false); + const [conflicts, setConflicts] = useState([]); + + /** + * 执行同步(可选覆盖列表) + */ + const doSync = async (overwriteManual?: string[]) => { setSyncing(true); try { - const response = await syncLiteLLMPrices(); + const response = await syncLiteLLMPrices(overwriteManual); if (!response.ok) { toast.error(response.error || t("prices.sync.failed")); @@ -32,7 +42,7 @@ export function SyncLiteLLMButton() { return; } - const { added, updated, unchanged, failed } = response.data; + const { added, updated, unchanged, failed, skippedConflicts } = response.data; // 优先显示失败信息(更明显) if (failed.length > 0) { @@ -47,13 +57,16 @@ export function SyncLiteLLMButton() { // 显示成功信息 if (added.length > 0 || updated.length > 0) { - toast.success( - t("prices.sync.successWithChanges", { - added: added.length, - updated: updated.length, - unchanged: unchanged.length, - }) - ); + let message = t("prices.sync.successWithChanges", { + added: added.length, + updated: updated.length, + unchanged: unchanged.length, + }); + // 如果有跳过的冲突,追加提示 + if (skippedConflicts && skippedConflicts.length > 0) { + message += ` (${t("prices.sync.skippedConflicts", { count: skippedConflicts.length })})`; + } + toast.success(message); } else if (unchanged.length > 0) { toast.info(t("prices.sync.successNoChanges", { unchanged: unchanged.length })); } else if (failed.length === 0) { @@ -62,6 +75,7 @@ export function SyncLiteLLMButton() { // 刷新页面数据 router.refresh(); + window.dispatchEvent(new Event("price-data-updated")); } catch (error) { console.error("同步失败:", error); toast.error(t("prices.sync.failedError")); @@ -70,10 +84,66 @@ export function SyncLiteLLMButton() { } }; + /** + * 处理同步按钮点击 - 先检查冲突 + */ + const handleSync = async () => { + setChecking(true); + + try { + // 先检查是否有冲突 + const checkResult = await checkLiteLLMSyncConflicts(); + + if (!checkResult.ok) { + toast.error(checkResult.error || t("prices.sync.failed")); + return; + } + + if (checkResult.data?.hasConflicts && checkResult.data.conflicts.length > 0) { + // 有冲突,显示弹窗 + setConflicts(checkResult.data.conflicts); + setConflictDialogOpen(true); + } else { + // 无冲突,直接同步 + await doSync(); + } + } catch (error) { + console.error("检查冲突失败:", error); + toast.error(t("prices.sync.failedError")); + } finally { + setChecking(false); + } + }; + + /** + * 处理冲突弹窗确认 + */ + const handleConflictConfirm = async (selectedModels: string[]) => { + setConflictDialogOpen(false); + // 执行同步,传入要覆盖的模型列表 + await doSync(selectedModels); + }; + + const isLoading = syncing || checking; + return ( - + <> + + + + ); } diff --git a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx index cf92d9ba5..358ec76ba 100644 --- a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx @@ -118,6 +118,7 @@ export function UploadPriceDialog({ setResult(response.data); const totalUpdates = response.data.added.length + response.data.updated.length; toast.success(t("dialog.updateSuccess", { count: totalUpdates })); + window.dispatchEvent(new Event("price-data-updated")); } catch (error) { console.error("更新失败:", error); toast.error(t("dialog.updateFailed")); diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx index 3f4238bf8..f3b21cc51 100644 --- a/src/app/[locale]/settings/prices/page.tsx +++ b/src/app/[locale]/settings/prices/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { getModelPrices, getModelPricesPaginated } from "@/actions/model-prices"; import { Section } from "@/components/section"; import { SettingsPageHeader } from "../_components/settings-page-header"; +import { ModelPriceDialog } from "./_components/model-price-dialog"; import { PriceList } from "./_components/price-list"; import { PricesSkeleton } from "./_components/prices-skeleton"; import { SyncLiteLLMButton } from "./_components/sync-litellm-button"; @@ -73,6 +74,7 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) description={t("prices.section.description")} actions={
+
diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index f162fef90..17154d356 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -366,6 +366,8 @@ export const modelPrices = pgTable('model_prices', { id: serial('id').primaryKey(), modelName: varchar('model_name').notNull(), priceData: jsonb('price_data').notNull(), + // 价格来源: 'litellm' = 从 LiteLLM 同步, 'manual' = 手动添加 + source: varchar('source', { length: 20 }).notNull().default('litellm').$type<'litellm' | 'manual'>(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }, (table) => ({ @@ -374,6 +376,8 @@ export const modelPrices = pgTable('model_prices', { // 基础索引 modelPricesModelNameIdx: index('idx_model_prices_model_name').on(table.modelName), modelPricesCreatedAtIdx: index('idx_model_prices_created_at').on(table.createdAt.desc()), + // 按来源过滤的索引 + modelPricesSourceIdx: index('idx_model_prices_source').on(table.source), })); // Error Rules table diff --git a/src/lib/price-sync.ts b/src/lib/price-sync.ts index bb3006c27..002933ab7 100644 --- a/src/lib/price-sync.ts +++ b/src/lib/price-sync.ts @@ -13,7 +13,7 @@ import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; import { logger } from "@/lib/logger"; const LITELLM_PRICE_URL = - "https://jsd-proxy.ygxz.in/gh/BerriAI/litellm/model_prices_and_context_window.json"; + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; const CACHE_FILE_PATH = path.join(process.cwd(), "public", "cache", "litellm-prices.json"); const FETCH_TIMEOUT_MS = 10000; // 10 秒超时 diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 1b144a705..218048ceb 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -141,6 +141,7 @@ export function toMessageRequest(dbMessage: any): MessageRequest { export function toModelPrice(dbPrice: any): ModelPrice { return { ...dbPrice, + source: dbPrice?.source ?? "litellm", // 默认为 litellm(向后兼容) createdAt: dbPrice?.createdAt ? new Date(dbPrice.createdAt) : new Date(), updatedAt: dbPrice?.updatedAt ? new Date(dbPrice.updatedAt) : new Date(), }; diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index ad7f98407..0d5ab8e0e 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -3,7 +3,7 @@ import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { modelPrices } from "@/drizzle/schema"; -import type { ModelPrice, ModelPriceData } from "@/types/model-price"; +import type { ModelPrice, ModelPriceData, ModelPriceSource } from "@/types/model-price"; import { toModelPrice } from "./_shared/transformers"; /** @@ -13,6 +13,7 @@ export interface PaginationParams { page: number; pageSize: number; search?: string; // 可选的搜索关键词 + source?: ModelPriceSource; // 可选的来源过滤 } /** @@ -35,6 +36,7 @@ export async function findLatestPriceByModel(modelName: string): Promise { mp.id, mp.model_name, mp.price_data, + mp.source, mp.created_at, mp.updated_at, ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn @@ -77,6 +80,7 @@ export async function findAllLatestPrices(): Promise { id, model_name as "modelName", price_data as "priceData", + source, created_at as "createdAt", updated_at as "updatedAt" FROM latest_records @@ -95,9 +99,25 @@ export async function findAllLatestPrices(): Promise { export async function findAllLatestPricesPaginated( params: PaginationParams ): Promise> { - const { page, pageSize, search } = params; + const { page, pageSize, search, source } = params; const offset = (page - 1) * pageSize; + // 构建 WHERE 条件 + const buildWhereCondition = () => { + const conditions: ReturnType[] = []; + if (search?.trim()) { + conditions.push(sql`model_name ILIKE ${`%${search.trim()}%`}`); + } + if (source) { + conditions.push(sql`source = ${source}`); + } + if (conditions.length === 0) return sql``; + if (conditions.length === 1) return sql`WHERE ${conditions[0]}`; + return sql`WHERE ${sql.join(conditions, sql` AND `)}`; + }; + + const whereCondition = buildWhereCondition(); + // 先获取总数 const countQuery = sql` WITH latest_prices AS ( @@ -105,7 +125,7 @@ export async function findAllLatestPricesPaginated( model_name, MAX(created_at) as max_created_at FROM model_prices - ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``} + ${whereCondition} GROUP BY model_name ), latest_records AS ( @@ -132,7 +152,7 @@ export async function findAllLatestPricesPaginated( model_name, MAX(created_at) as max_created_at FROM model_prices - ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``} + ${whereCondition} GROUP BY model_name ), latest_records AS ( @@ -140,6 +160,7 @@ export async function findAllLatestPricesPaginated( mp.id, mp.model_name, mp.price_data, + mp.source, mp.created_at, mp.updated_at, ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn @@ -152,6 +173,7 @@ export async function findAllLatestPricesPaginated( id, model_name as "modelName", price_data as "priceData", + source, created_at as "createdAt", updated_at as "updatedAt" FROM latest_records @@ -183,21 +205,25 @@ export async function hasAnyPriceRecords(): Promise { /** * 创建新的价格记录 + * @param source - 价格来源,默认为 'litellm'(同步时使用),手动添加时传入 'manual' */ export async function createModelPrice( modelName: string, - priceData: ModelPriceData + priceData: ModelPriceData, + source: ModelPriceSource = "litellm" ): Promise { const [price] = await db .insert(modelPrices) .values({ modelName: modelName, priceData: priceData, + source: source, }) .returning({ id: modelPrices.id, modelName: modelPrices.modelName, priceData: modelPrices.priceData, + source: modelPrices.source, createdAt: modelPrices.createdAt, updatedAt: modelPrices.updatedAt, }); @@ -205,6 +231,88 @@ export async function createModelPrice( return toModelPrice(price); } +/** + * 更新或插入模型价格(先删除旧记录,再插入新记录) + * 用于手动维护单个模型价格,source 固定为 'manual' + */ +export async function upsertModelPrice( + modelName: string, + priceData: ModelPriceData +): Promise { + // 使用事务确保删除和插入的原子性 + return await db.transaction(async (tx) => { + // 先删除该模型的所有旧记录 + await tx.delete(modelPrices).where(eq(modelPrices.modelName, modelName)); + + // 插入新记录,source 固定为 'manual' + const [price] = await tx + .insert(modelPrices) + .values({ + modelName: modelName, + priceData: priceData, + source: "manual", + }) + .returning(); + return toModelPrice(price); + }); +} + +/** + * 删除指定模型的所有价格记录(硬删除) + */ +export async function deleteModelPriceByName(modelName: string): Promise { + await db.delete(modelPrices).where(eq(modelPrices.modelName, modelName)); +} + +/** + * 获取数据库中所有 source='manual' 的最新价格记录 + * 返回 Map + */ +export async function findAllManualPrices(): Promise> { + const query = sql` + WITH latest_prices AS ( + SELECT + model_name, + MAX(created_at) as max_created_at + FROM model_prices + WHERE source = 'manual' + GROUP BY model_name + ), + latest_records AS ( + SELECT + mp.id, + mp.model_name, + mp.price_data, + mp.source, + mp.created_at, + mp.updated_at, + ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn + FROM model_prices mp + INNER JOIN latest_prices lp + ON mp.model_name = lp.model_name + AND mp.created_at = lp.max_created_at + ) + SELECT + id, + model_name as "modelName", + price_data as "priceData", + source, + created_at as "createdAt", + updated_at as "updatedAt" + FROM latest_records + WHERE rn = 1 + `; + + const result = await db.execute(query); + const prices = Array.from(result).map(toModelPrice); + + const priceMap = new Map(); + for (const price of prices) { + priceMap.set(price.modelName, price); + } + return priceMap; +} + /** * 批量创建价格记录 */ diff --git a/src/types/model-price.ts b/src/types/model-price.ts index 91c998664..b370719ad 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -50,6 +50,11 @@ export interface ModelPriceData { [key: string]: unknown; // 允许额外字段 } +/** + * 价格来源类型 + */ +export type ModelPriceSource = "litellm" | "manual"; + /** * 模型价格记录 */ @@ -57,6 +62,7 @@ export interface ModelPrice { id: number; modelName: string; priceData: ModelPriceData; + source: ModelPriceSource; createdAt: Date; updatedAt: Date; } @@ -77,4 +83,22 @@ export interface PriceUpdateResult { unchanged: string[]; // 未变化的模型 failed: string[]; // 处理失败的模型 total: number; // 总数 + skippedConflicts?: string[]; // 因冲突而跳过的手动添加模型 +} + +/** + * 同步冲突信息 + */ +export interface SyncConflict { + modelName: string; + manualPrice: ModelPriceData; // 当前手动添加的价格 + litellmPrice: ModelPriceData; // LiteLLM 中的价格 +} + +/** + * 同步冲突检查结果 + */ +export interface SyncConflictCheckResult { + hasConflicts: boolean; + conflicts: SyncConflict[]; } diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts new file mode 100644 index 000000000..e6e7a94ad --- /dev/null +++ b/tests/unit/actions/model-prices.test.ts @@ -0,0 +1,448 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelPrice, ModelPriceData } from "@/types/model-price"; + +// Mock dependencies +const getSessionMock = vi.fn(); +const revalidatePathMock = vi.fn(); + +// Repository mocks +const findLatestPriceByModelMock = vi.fn(); +const createModelPriceMock = vi.fn(); +const upsertModelPriceMock = vi.fn(); +const deleteModelPriceByNameMock = vi.fn(); +const findAllManualPricesMock = vi.fn(); + +// Price sync mock +const getPriceTableJsonMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: () => getSessionMock(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: () => revalidatePathMock(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: () => findLatestPriceByModelMock(), + createModelPrice: (...args: unknown[]) => createModelPriceMock(...args), + upsertModelPrice: (...args: unknown[]) => upsertModelPriceMock(...args), + deleteModelPriceByName: (...args: unknown[]) => deleteModelPriceByNameMock(...args), + findAllManualPrices: () => findAllManualPricesMock(), + findAllLatestPrices: vi.fn(async () => []), + findAllLatestPricesPaginated: vi.fn(async () => ({ + data: [], + total: 0, + page: 1, + pageSize: 50, + totalPages: 0, + })), + hasAnyPriceRecords: vi.fn(async () => false), +})); + +vi.mock("@/lib/price-sync", () => ({ + getPriceTableJson: () => getPriceTableJsonMock(), +})); + +// Helper to create mock ModelPrice +function makeMockPrice( + modelName: string, + priceData: Partial, + source: "litellm" | "manual" = "manual" +): ModelPrice { + const now = new Date(); + return { + id: Math.floor(Math.random() * 1000), + modelName, + priceData: { + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + ...priceData, + }, + source, + createdAt: now, + updatedAt: now, + }; +} + +describe("Model Price Actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: admin session + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + describe("upsertSingleModelPrice", () => { + it("should create a new model price for admin", async () => { + const mockResult = makeMockPrice("gpt-5.2-codex", { + mode: "chat", + input_cost_per_token: 0.000015, + output_cost_per_token: 0.00006, + }); + upsertModelPriceMock.mockResolvedValue(mockResult); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "gpt-5.2-codex", + mode: "chat", + litellmProvider: "openai", + inputCostPerToken: 0.000015, + outputCostPerToken: 0.00006, + }); + + expect(result.ok).toBe(true); + expect(result.data?.modelName).toBe("gpt-5.2-codex"); + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "gpt-5.2-codex", + expect.objectContaining({ + mode: "chat", + litellm_provider: "openai", + input_cost_per_token: 0.000015, + output_cost_per_token: 0.00006, + }) + ); + }); + + it("should reject empty model name", async () => { + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: " ", + mode: "chat", + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("模型名称"); + expect(upsertModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "test-model", + mode: "chat", + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(upsertModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should handle image generation mode", async () => { + const mockResult = makeMockPrice("dall-e-3", { + mode: "image_generation", + output_cost_per_image: 0.04, + }); + upsertModelPriceMock.mockResolvedValue(mockResult); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "dall-e-3", + mode: "image_generation", + litellmProvider: "openai", + outputCostPerImage: 0.04, + }); + + expect(result.ok).toBe(true); + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "dall-e-3", + expect.objectContaining({ + mode: "image_generation", + output_cost_per_image: 0.04, + }) + ); + }); + + it("should handle repository errors gracefully", async () => { + upsertModelPriceMock.mockRejectedValue(new Error("Database error")); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "test-model", + mode: "chat", + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("deleteSingleModelPrice", () => { + it("should delete a model price for admin", async () => { + deleteModelPriceByNameMock.mockResolvedValue(undefined); + + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice("gpt-5.2-codex"); + + expect(result.ok).toBe(true); + expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("gpt-5.2-codex"); + }); + + it("should reject empty model name", async () => { + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice(""); + + expect(result.ok).toBe(false); + expect(result.error).toContain("模型名称"); + expect(deleteModelPriceByNameMock).not.toHaveBeenCalled(); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice("test-model"); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(deleteModelPriceByNameMock).not.toHaveBeenCalled(); + }); + + it("should handle repository errors gracefully", async () => { + deleteModelPriceByNameMock.mockRejectedValue(new Error("Database error")); + + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice("test-model"); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("checkLiteLLMSyncConflicts", () => { + it("should return no conflicts when no manual prices exist", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + getPriceTableJsonMock.mockResolvedValue( + JSON.stringify({ + "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, + }) + ); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(true); + expect(result.data?.hasConflicts).toBe(false); + expect(result.data?.conflicts).toHaveLength(0); + }); + + it("should detect conflicts when manual prices exist in LiteLLM", async () => { + const manualPrice = makeMockPrice("claude-3-opus", { + mode: "chat", + input_cost_per_token: 0.00001, + output_cost_per_token: 0.00002, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["claude-3-opus", manualPrice]])); + + getPriceTableJsonMock.mockResolvedValue( + JSON.stringify({ + "claude-3-opus": { + mode: "chat", + input_cost_per_token: 0.000015, + output_cost_per_token: 0.00006, + }, + }) + ); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(true); + expect(result.data?.hasConflicts).toBe(true); + expect(result.data?.conflicts).toHaveLength(1); + expect(result.data?.conflicts[0]?.modelName).toBe("claude-3-opus"); + }); + + it("should not report conflicts for manual prices not in LiteLLM", async () => { + const manualPrice = makeMockPrice("custom-model", { + mode: "chat", + input_cost_per_token: 0.00001, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); + + getPriceTableJsonMock.mockResolvedValue( + JSON.stringify({ + "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, + }) + ); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(true); + expect(result.data?.hasConflicts).toBe(false); + expect(result.data?.conflicts).toHaveLength(0); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + }); + + it("should handle network errors gracefully", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + getPriceTableJsonMock.mockResolvedValue(null); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(false); + expect(result.error).toContain("CDN"); + }); + + it("should handle invalid JSON gracefully", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + getPriceTableJsonMock.mockResolvedValue("invalid json {"); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(false); + expect(result.error).toContain("JSON"); + }); + }); + + describe("processPriceTableInternal - source handling", () => { + it("should skip manual prices during sync by default", async () => { + const manualPrice = makeMockPrice("custom-model", { + mode: "chat", + input_cost_per_token: 0.00001, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); + findLatestPriceByModelMock.mockResolvedValue(manualPrice); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "custom-model": { + mode: "chat", + input_cost_per_token: 0.000015, + }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.skippedConflicts).toContain("custom-model"); + expect(result.data?.unchanged).toContain("custom-model"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should overwrite manual prices when specified", async () => { + const manualPrice = makeMockPrice("custom-model", { + mode: "chat", + input_cost_per_token: 0.00001, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); + findLatestPriceByModelMock.mockResolvedValue(manualPrice); + deleteModelPriceByNameMock.mockResolvedValue(undefined); + createModelPriceMock.mockResolvedValue( + makeMockPrice( + "custom-model", + { + mode: "chat", + input_cost_per_token: 0.000015, + }, + "litellm" + ) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "custom-model": { + mode: "chat", + input_cost_per_token: 0.000015, + }, + }), + ["custom-model"] // Overwrite list + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("custom-model"); + expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("custom-model"); + expect(createModelPriceMock).toHaveBeenCalled(); + }); + + it("should add new models with litellm source", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findLatestPriceByModelMock.mockResolvedValue(null); + createModelPriceMock.mockResolvedValue( + makeMockPrice( + "new-model", + { + mode: "chat", + }, + "litellm" + ) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "new-model": { + mode: "chat", + input_cost_per_token: 0.000001, + }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.added).toContain("new-model"); + expect(createModelPriceMock).toHaveBeenCalledWith("new-model", expect.any(Object), "litellm"); + }); + + it("should skip metadata fields like sample_spec", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findLatestPriceByModelMock.mockResolvedValue(null); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + sample_spec: { description: "This is metadata" }, + "real-model": { mode: "chat", input_cost_per_token: 0.000001 }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.total).toBe(1); // Only real-model + expect(result.data?.failed).not.toContain("sample_spec"); + }); + + it("should skip entries without mode field", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findLatestPriceByModelMock.mockResolvedValue(null); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "invalid-model": { input_cost_per_token: 0.000001 }, // No mode + "valid-model": { mode: "chat", input_cost_per_token: 0.000001 }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.failed).toContain("invalid-model"); + }); + }); +});