From 6414f369b8a0d4f9b59a021d0a731c79a0e63bd5 Mon Sep 17 00:00:00 2001 From: ylxmf2005 Date: Sun, 1 Feb 2026 22:17:03 +0800 Subject: [PATCH 01/12] feat(providers): add configurable weekly reset time for rate limiting Add the ability to configure which day of the week and time (HH:mm) the weekly cost window resets for providers. Changes: - Add weeklyResetDay (0-6) and weeklyResetTime (HH:mm) columns to providers table - Extend time-utils.ts to support custom weekly reset day/time parameters - Update rate-limit service to use configurable weekly reset with Redis key suffix - Add Weekly Reset Settings UI in provider limits section - Add i18n translations for all 5 languages (en, zh-CN, zh-TW, ja, ru) - Update related tests for new function signatures Default behavior remains Monday 00:00 for backward compatibility. Closes #694 --- drizzle/0060_minor_bloodstorm.sql | 3 + drizzle/meta/0060_snapshot.json | 2948 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../en/settings/providers/form/sections.json | 17 + .../ja/settings/providers/form/sections.json | 17 + .../ru/settings/providers/form/sections.json | 17 + .../settings/providers/form/sections.json | 17 + .../settings/providers/form/sections.json | 17 + src/actions/providers.ts | 34 +- .../provider-form/provider-form-context.tsx | 6 + .../provider-form/provider-form-types.ts | 4 + .../provider-form/sections/limits-section.tsx | 59 + src/app/v1/_lib/proxy/provider-selector.ts | 4 + src/app/v1/_lib/proxy/response-handler.ts | 2 + src/drizzle/schema.ts | 2 + src/lib/rate-limit/service.ts | 74 +- src/lib/rate-limit/time-utils.ts | 101 +- src/lib/validation/schemas.ts | 12 + src/types/provider.ts | 8 + tests/unit/actions/providers-usage.test.ts | 13 +- tests/unit/lib/rate-limit/cost-limits.test.ts | 4 +- 21 files changed, 3324 insertions(+), 42 deletions(-) create mode 100644 drizzle/0060_minor_bloodstorm.sql create mode 100644 drizzle/meta/0060_snapshot.json diff --git a/drizzle/0060_minor_bloodstorm.sql b/drizzle/0060_minor_bloodstorm.sql new file mode 100644 index 000000000..fb811fb56 --- /dev/null +++ b/drizzle/0060_minor_bloodstorm.sql @@ -0,0 +1,3 @@ +ALTER TABLE "notification_target_bindings" ALTER COLUMN "schedule_timezone" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN "weekly_reset_day" integer;--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN "weekly_reset_time" varchar(5); \ No newline at end of file diff --git a/drizzle/meta/0060_snapshot.json b/drizzle/meta/0060_snapshot.json new file mode 100644 index 000000000..9c8f0d5cb --- /dev/null +++ b/drizzle/meta/0060_snapshot.json @@ -0,0 +1,2948 @@ +{ + "id": "c37b93b9-a7e1-4c5f-869f-ade9382f61ec", + "prevId": "5fd37dcd-8e23-4450-9177-cea694050745", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "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_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_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 + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "weekly_reset_day": { + "name": "weekly_reset_day", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "weekly_reset_time": { + "name": "weekly_reset_time", + "type": "varchar(5)", + "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": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 82949325e..c7455aa54 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1769539222210, "tag": "0059_safe_xorn", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1769954541614, + "tag": "0060_minor_bloodstorm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index 75a90f64b..36f1f574b 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -144,6 +144,22 @@ "dailyResetTime": { "label": "Daily Reset Time (HH:mm)" }, + "weeklyResetDay": { + "label": "Weekly Reset Day", + "desc": "Day of week when weekly quota resets", + "days": { + "0": "Sunday", + "1": "Monday", + "2": "Tuesday", + "3": "Wednesday", + "4": "Thursday", + "5": "Friday", + "6": "Saturday" + } + }, + "weeklyResetTime": { + "label": "Weekly Reset Time (HH:mm)" + }, "desc": "Configure spending limits to control costs across different time windows", "limit5h": { "label": "5h Spend Limit (USD)", @@ -183,6 +199,7 @@ "limits": { "timeBased": "Time-based Limits", "dailyReset": "Daily Reset Settings", + "weeklyReset": "Weekly Reset Settings", "otherLimits": "Other Limits" }, "routing": { diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index e5a64d1d6..de668f165 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -170,6 +170,22 @@ "label": "週の上限 (USD)", "placeholder": "空欄で無制限" }, + "weeklyResetDay": { + "label": "週次リセット曜日", + "desc": "週次クォータをリセットする曜日", + "days": { + "0": "日曜日", + "1": "月曜日", + "2": "火曜日", + "3": "水曜日", + "4": "木曜日", + "5": "金曜日", + "6": "土曜日" + } + }, + "weeklyResetTime": { + "label": "週次リセット時刻 (HH:mm)" + }, "summary": { "concurrent": "同時: {count}", "daily": "日: {amount}({resetTime}にリセット)", @@ -184,6 +200,7 @@ "limits": { "timeBased": "時間ベースの制限", "dailyReset": "日次リセット設定", + "weeklyReset": "週次リセット設定", "otherLimits": "その他の制限" }, "routing": { diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 46ed44ebd..3e3e984da 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -170,6 +170,22 @@ "label": "Недельный лимит (USD)", "placeholder": "Пусто — без ограничений" }, + "weeklyResetDay": { + "label": "День еженедельного сброса", + "desc": "День недели, когда сбрасывается недельная квота", + "days": { + "0": "Воскресенье", + "1": "Понедельник", + "2": "Вторник", + "3": "Среда", + "4": "Четверг", + "5": "Пятница", + "6": "Суббота" + } + }, + "weeklyResetTime": { + "label": "Время еженедельного сброса (ЧЧ:мм)" + }, "summary": { "concurrent": "Параллельно: {count}", "daily": "день: {amount} (сброс {resetTime})", @@ -184,6 +200,7 @@ "limits": { "timeBased": "Временные ограничения", "dailyReset": "Настройки ежедневного сброса", + "weeklyReset": "Настройки еженедельного сброса", "otherLimits": "Другие ограничения" }, "routing": { diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index bb250af48..90de37028 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -173,6 +173,22 @@ "label": "周消费上限 (USD)", "placeholder": "留空表示无限制" }, + "weeklyResetDay": { + "label": "每周重置日", + "desc": "每周哪一天重置周配额", + "days": { + "0": "周日", + "1": "周一", + "2": "周二", + "3": "周三", + "4": "周四", + "5": "周五", + "6": "周六" + } + }, + "weeklyResetTime": { + "label": "每周重置时间 (HH:mm)" + }, "limitMonthly": { "label": "月消费上限 (USD)", "placeholder": "留空表示无限制" @@ -189,6 +205,7 @@ "limits": { "timeBased": "时间维度限制", "dailyReset": "每日重置设置", + "weeklyReset": "每周重置设置", "otherLimits": "其他限制" }, "circuitBreaker": { diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index d5e0e72b9..a12a61405 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -170,6 +170,22 @@ "label": "週消費上限(USD)", "placeholder": "留空表示無限制" }, + "weeklyResetDay": { + "label": "每週重置日", + "desc": "每週哪一天重置週配額", + "days": { + "0": "週日", + "1": "週一", + "2": "週二", + "3": "週三", + "4": "週四", + "5": "週五", + "6": "週六" + } + }, + "weeklyResetTime": { + "label": "每週重置時間 (HH:mm)" + }, "summary": { "concurrent": "並發:{count}", "daily": "日: {amount}(重置 {resetTime})", @@ -184,6 +200,7 @@ "limits": { "timeBased": "時間維度限制", "dailyReset": "每日重置設定", + "weeklyReset": "每週重置設定", "otherLimits": "其他限制" }, "routing": { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 3a3693b05..81c082ad2 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -260,6 +260,8 @@ export async function getProviders(): Promise { dailyResetMode: provider.dailyResetMode, dailyResetTime: provider.dailyResetTime, limitWeeklyUsd: provider.limitWeeklyUsd, + weeklyResetDay: provider.weeklyResetDay, + weeklyResetTime: provider.weeklyResetTime, limitMonthlyUsd: provider.limitMonthlyUsd, limitTotalUsd: provider.limitTotalUsd, limitConcurrentSessions: provider.limitConcurrentSessions, @@ -532,6 +534,8 @@ export async function addProvider(data: { daily_reset_mode: validated.daily_reset_mode ?? "fixed", daily_reset_time: validated.daily_reset_time ?? "00:00", limit_weekly_usd: validated.limit_weekly_usd ?? null, + weekly_reset_day: validated.weekly_reset_day ?? null, + weekly_reset_time: validated.weekly_reset_time ?? null, limit_monthly_usd: validated.limit_monthly_usd ?? null, limit_total_usd: validated.limit_total_usd ?? null, limit_concurrent_sessions: validated.limit_concurrent_sessions ?? 0, @@ -625,6 +629,8 @@ export async function editProvider( limit_daily_usd?: number | null; daily_reset_time?: string; limit_weekly_usd?: number | null; + weekly_reset_day?: number | null; + weekly_reset_time?: string | null; limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; @@ -1204,7 +1210,12 @@ export async function getProviderLimitUsage(providerId: number): Promise< provider.dailyResetTime ?? undefined, (provider.dailyResetMode ?? "fixed") as "fixed" | "rolling" ), - getTimeRangeForPeriod("weekly"), + getTimeRangeForPeriod( + "weekly", + "00:00", + provider.weeklyResetDay ?? undefined, + provider.weeklyResetTime ?? undefined + ), getTimeRangeForPeriod("monthly"), ]); @@ -1224,7 +1235,12 @@ export async function getProviderLimitUsage(providerId: number): Promise< provider.dailyResetTime, provider.dailyResetMode ?? "fixed" ); - const resetWeekly = await getResetInfo("weekly"); + const resetWeekly = await getResetInfo( + "weekly", + "00:00", + provider.weeklyResetDay ?? undefined, + provider.weeklyResetTime ?? undefined + ); const resetMonthly = await getResetInfo("monthly"); return { @@ -1286,6 +1302,8 @@ export async function getProviderLimitUsageBatch( id: number; dailyResetTime?: string | null; dailyResetMode?: string | null; + weeklyResetDay?: number | null; + weeklyResetTime?: string | null; limit5hUsd?: number | null; limitDailyUsd?: number | null; limitWeeklyUsd?: number | null; @@ -1321,10 +1339,9 @@ export async function getProviderLimitUsageBatch( // 获取并发 session 计数(仍使用 Redis,这是实时数据) const sessionCountMap = await SessionTracker.getProviderSessionCountBatch(providerIds); - // 获取各周期的时间范围(这些范围对所有供应商是相同的,除了 daily 需要根据每个供应商的配置) - const [range5h, rangeWeekly, rangeMonthly] = await Promise.all([ + // 获取各周期的时间范围(5h、monthly 对所有供应商相同,daily、weekly 需要根据每个供应商配置) + const [range5h, rangeMonthly] = await Promise.all([ getTimeRangeForPeriod("5h"), - getTimeRangeForPeriod("weekly"), getTimeRangeForPeriod("monthly"), ]); @@ -1337,6 +1354,13 @@ export async function getProviderLimitUsageBatch( provider.dailyResetTime ?? undefined, dailyResetMode ); + // 获取该供应商的 weekly 时间范围(根据其 weeklyResetDay/Time 配置) + const rangeWeekly = await getTimeRangeForPeriod( + "weekly", + "00:00", + provider.weeklyResetDay ?? undefined, + provider.weeklyResetTime ?? undefined + ); // 并行查询该供应商的各周期消费(直接查询数据库) const [cost5h, costDaily, costWeekly, costMonthly] = await Promise.all([ diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index c8a187f1d..41a5eefa2 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -66,6 +66,8 @@ export function createInitialState( dailyResetMode: sourceProvider?.dailyResetMode ?? "fixed", dailyResetTime: sourceProvider?.dailyResetTime ?? "00:00", limitWeeklyUsd: sourceProvider?.limitWeeklyUsd ?? null, + weeklyResetDay: sourceProvider?.weeklyResetDay ?? 1, + weeklyResetTime: sourceProvider?.weeklyResetTime ?? "00:00", limitMonthlyUsd: sourceProvider?.limitMonthlyUsd ?? null, limitTotalUsd: sourceProvider?.limitTotalUsd ?? null, limitConcurrentSessions: sourceProvider?.limitConcurrentSessions ?? null, @@ -180,6 +182,10 @@ export function providerFormReducer( return { ...state, rateLimit: { ...state.rateLimit, dailyResetTime: action.payload } }; case "SET_LIMIT_WEEKLY_USD": return { ...state, rateLimit: { ...state.rateLimit, limitWeeklyUsd: action.payload } }; + case "SET_WEEKLY_RESET_DAY": + return { ...state, rateLimit: { ...state.rateLimit, weeklyResetDay: action.payload } }; + case "SET_WEEKLY_RESET_TIME": + return { ...state, rateLimit: { ...state.rateLimit, weeklyResetTime: action.payload } }; case "SET_LIMIT_MONTHLY_USD": return { ...state, rateLimit: { ...state.rateLimit, limitMonthlyUsd: action.payload } }; case "SET_LIMIT_TOTAL_USD": diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 5beb3c2c0..6157026f0 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -55,6 +55,8 @@ export interface RateLimitState { dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; limitWeeklyUsd: number | null; + weeklyResetDay: number; + weeklyResetTime: string; limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number | null; @@ -126,6 +128,8 @@ export type ProviderFormAction = | { type: "SET_DAILY_RESET_MODE"; payload: "fixed" | "rolling" } | { type: "SET_DAILY_RESET_TIME"; payload: string } | { type: "SET_LIMIT_WEEKLY_USD"; payload: number | null } + | { type: "SET_WEEKLY_RESET_DAY"; payload: number } + | { type: "SET_WEEKLY_RESET_TIME"; payload: string } | { type: "SET_LIMIT_MONTHLY_USD"; payload: number | null } | { type: "SET_LIMIT_TOTAL_USD"; payload: number | null } | { type: "SET_LIMIT_CONCURRENT_SESSIONS"; payload: number | null } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx index 3b9f8a470..53123a4c7 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -244,6 +244,65 @@ export function LimitsSection() { + {/* Weekly Reset Settings */} + +
+ + + + + + + dispatch({ type: "SET_WEEKLY_RESET_TIME", payload: e.target.value || "00:00" }) + } + placeholder="00:00" + disabled={state.ui.isPending} + step="60" + /> + +
+
+ {/* Total and Concurrent Limits */}
diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 22d606ae4..c32d28dbd 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -617,6 +617,8 @@ export class ProxyProviderResolver { daily_reset_mode: provider.dailyResetMode, daily_reset_time: provider.dailyResetTime, limit_weekly_usd: provider.limitWeeklyUsd, + weekly_reset_day: provider.weeklyResetDay, + weekly_reset_time: provider.weeklyResetTime, limit_monthly_usd: provider.limitMonthlyUsd, }); @@ -995,6 +997,8 @@ export class ProxyProviderResolver { daily_reset_mode: p.dailyResetMode, daily_reset_time: p.dailyResetTime, limit_weekly_usd: p.limitWeeklyUsd, + weekly_reset_day: p.weeklyResetDay, + weekly_reset_time: p.weeklyResetTime, limit_monthly_usd: p.limitMonthlyUsd, }); diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index ccdd044e5..c412cb5f4 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -2002,6 +2002,8 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul keyResetMode: key.dailyResetMode, providerResetTime: provider.dailyResetTime, providerResetMode: provider.dailyResetMode, + providerWeeklyResetDay: provider.weeklyResetDay, + providerWeeklyResetTime: provider.weeklyResetTime, requestId: messageContext.id, createdAtMs: messageContext.createdAt.getTime(), } diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index de3fe4b95..8dfa184ab 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -222,6 +222,8 @@ export const providers = pgTable('providers', { .default('00:00') .notNull(), // HH:mm 格式,如 "18:00"(仅 fixed 模式使用) limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }), + weeklyResetDay: integer('weekly_reset_day'), // 0=Sunday, 1=Monday, ..., 6=Saturday + weeklyResetTime: varchar('weekly_reset_time', { length: 5 }), // HH:mm limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }), limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), totalCostResetAt: timestamp('total_cost_reset_at', { withTimezone: true }), diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 8dbbdab93..76ccdcbd4 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -97,6 +97,8 @@ interface CostLimit { name: string; resetTime?: string; // 自定义重置时间(仅 daily + fixed 模式使用,格式 "HH:mm") resetMode?: DailyResetMode; // 日限额重置模式(仅 daily 使用) + weeklyResetDay?: number; // 0-6 (Sunday=0) + weeklyResetTime?: string; // HH:mm } export class RateLimitService { @@ -110,6 +112,15 @@ export class RateLimitService { return { normalized, suffix: normalized.replace(":", "") }; } + private static resolveWeeklyReset( + day?: number | null, + time?: string | null + ): { day: number; time: string; suffix: string } { + const d = day != null && day >= 0 && day <= 6 ? day : 1; + const t = normalizeResetTime(time ?? undefined); + return { day: d, time: t, suffix: `${d}_${t.replace(":", "")}` }; + } + private static async warmRollingCostZset( key: string, entries: Array<{ id: number; createdAt: Date; costUsd: number }>, @@ -145,11 +156,17 @@ export class RateLimitService { daily_reset_time?: string; daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; + weekly_reset_day?: number | null; + weekly_reset_time?: string | null; limit_monthly_usd: number | null; } ): Promise<{ allowed: boolean; reason?: string }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); const dailyResetMode = limits.daily_reset_mode ?? "fixed"; + const weeklyReset = RateLimitService.resolveWeeklyReset( + limits.weekly_reset_day, + limits.weekly_reset_time + ); const costLimits: CostLimit[] = [ { amount: limits.limit_5h_usd, period: "5h", name: "5小时" }, { @@ -159,7 +176,13 @@ export class RateLimitService { resetTime: normalizedDailyReset, resetMode: dailyResetMode, }, - { amount: limits.limit_weekly_usd, period: "weekly", name: "周" }, + { + amount: limits.limit_weekly_usd, + period: "weekly", + name: "周", + weeklyResetDay: weeklyReset.day, + weeklyResetTime: weeklyReset.time, + }, { amount: limits.limit_monthly_usd, period: "monthly", name: "月" }, ]; @@ -238,9 +261,20 @@ export class RateLimitService { return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); } } else { - // daily fixed/周/月使用普通 GET + // daily fixed/weekly/monthly: use GET const { suffix } = RateLimitService.resolveDailyReset(limit.resetTime); - const periodKey = limit.period === "daily" ? `${limit.period}_${suffix}` : limit.period; + let periodKey: string; + if (limit.period === "daily") { + periodKey = `daily_${suffix}`; + } else if (limit.period === "weekly") { + const wSuffix = RateLimitService.resolveWeeklyReset( + limit.weeklyResetDay, + limit.weeklyResetTime + ).suffix; + periodKey = `weekly_${wSuffix}`; + } else { + periodKey = limit.period; + } const value = await RateLimitService.redis.get(`${type}:${id}:cost_${periodKey}`); // Cache Miss 检测 @@ -400,7 +434,9 @@ export class RateLimitService { const { startTime, endTime } = await getTimeRangeForPeriodWithMode( limit.period, limit.resetTime, - limit.resetMode + limit.resetMode, + limit.weeklyResetDay, + limit.weeklyResetTime ); // 查询数据库 @@ -612,6 +648,8 @@ export class RateLimitService { keyResetMode?: DailyResetMode; providerResetTime?: string; providerResetMode?: DailyResetMode; + providerWeeklyResetDay?: number | null; + providerWeeklyResetTime?: string | null; requestId?: number; createdAtMs?: number; } @@ -628,6 +666,11 @@ export class RateLimitService { const window5h = 5 * 60 * 60 * 1000; // 5 hours in ms const window24h = 24 * 60 * 60 * 1000; // 24 hours in ms + const providerWeeklyReset = RateLimitService.resolveWeeklyReset( + options?.providerWeeklyResetDay, + options?.providerWeeklyResetTime + ); + // 计算动态 TTL(daily/周/月) const ttlDailyKey = await getTTLForPeriodWithMode( "daily", @@ -643,7 +686,13 @@ export class RateLimitService { providerDailyReset.normalized, providerDailyMode ); - const ttlWeekly = await getTTLForPeriod("weekly"); + const ttlWeeklyKey = await getTTLForPeriod("weekly"); + const ttlWeeklyProvider = await getTTLForPeriod( + "weekly", + "00:00", + providerWeeklyReset.day, + providerWeeklyReset.time + ); const ttlMonthly = await getTTLForPeriod("monthly"); // 1. 5h 滚动窗口:使用 Lua 脚本(ZSET) @@ -705,7 +754,7 @@ export class RateLimitService { } pipeline.incrbyfloat(`key:${keyId}:cost_weekly`, cost); - pipeline.expire(`key:${keyId}:cost_weekly`, ttlWeekly); + pipeline.expire(`key:${keyId}:cost_weekly`, ttlWeeklyKey); pipeline.incrbyfloat(`key:${keyId}:cost_monthly`, cost); pipeline.expire(`key:${keyId}:cost_monthly`, ttlMonthly); @@ -717,8 +766,9 @@ export class RateLimitService { pipeline.expire(providerDailyKey, ttlDailyProvider); } - pipeline.incrbyfloat(`provider:${providerId}:cost_weekly`, cost); - pipeline.expire(`provider:${providerId}:cost_weekly`, ttlWeekly); + const providerWeeklyKey = `provider:${providerId}:cost_weekly_${providerWeeklyReset.suffix}`; + pipeline.incrbyfloat(providerWeeklyKey, cost); + pipeline.expire(providerWeeklyKey, ttlWeeklyProvider); pipeline.incrbyfloat(`provider:${providerId}:cost_monthly`, cost); pipeline.expire(`provider:${providerId}:cost_monthly`, ttlMonthly); @@ -1317,11 +1367,17 @@ export class RateLimitService { daily_reset_time?: string; daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; + weekly_reset_day?: number | null; + weekly_reset_time?: string | null; limit_monthly_usd: number | null; } ): Promise<{ allowed: boolean; reason?: string; failOpen?: boolean }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); const dailyResetMode = limits.daily_reset_mode ?? "fixed"; + const weeklyReset = RateLimitService.resolveWeeklyReset( + limits.weekly_reset_day, + limits.weekly_reset_time + ); // Define windows to check with their limits const windowChecks: Array<{ @@ -1349,7 +1405,7 @@ export class RateLimitService { window: "weekly", limit: limits.limit_weekly_usd, name: "weekly", - resetTime: "00:00", + resetTime: weeklyReset.time, resetMode: "fixed" as DailyResetMode, }, { diff --git a/src/lib/rate-limit/time-utils.ts b/src/lib/rate-limit/time-utils.ts index 1edd48b94..50c223565 100644 --- a/src/lib/rate-limit/time-utils.ts +++ b/src/lib/rate-limit/time-utils.ts @@ -42,7 +42,9 @@ export interface ResetInfo { */ export async function getTimeRangeForPeriod( period: TimePeriod, - resetTime = "00:00" + resetTime = "00:00", + weeklyResetDay?: number, + weeklyResetTime?: string ): Promise { const timezone = await resolveSystemTimezone(); const normalizedResetTime = normalizeResetTime(resetTime); @@ -63,10 +65,25 @@ export async function getTimeRangeForPeriod( } case "weekly": { - // 自然周:本周一 00:00 (系统时区) + // Custom weekly reset: configurable day (0-6) and time (HH:mm) + const day = weeklyResetDay ?? 1; // default Monday + const wTime = normalizeResetTime(weeklyResetTime ?? "00:00"); + const { hours: wHours, minutes: wMinutes } = parseResetTime(wTime); const zonedNow = toZonedTime(now, timezone); - const zonedStartOfWeek = startOfWeek(zonedNow, { weekStartsOn: 1 }); // 周一 - startTime = fromZonedTime(zonedStartOfWeek, timezone); + const zonedStartOfWeek = startOfWeek(zonedNow, { + weekStartsOn: day as 0 | 1 | 2 | 3 | 4 | 5 | 6, + }); + const zonedResetPoint = buildZonedDate(zonedStartOfWeek, wHours, wMinutes); + const resetPoint = fromZonedTime(zonedResetPoint, timezone); + + if (now >= resetPoint) { + startTime = resetPoint; + } else { + // We're before this week's reset, use last week's reset + const zonedPrevWeek = addWeeks(zonedStartOfWeek, -1); + const zonedPrevReset = buildZonedDate(zonedPrevWeek, wHours, wMinutes); + startTime = fromZonedTime(zonedPrevReset, timezone); + } break; } @@ -91,7 +108,9 @@ export async function getTimeRangeForPeriod( export async function getTimeRangeForPeriodWithMode( period: TimePeriod, resetTime = "00:00", - mode: DailyResetMode = "fixed" + mode: DailyResetMode = "fixed", + weeklyResetDay?: number, + weeklyResetTime?: string ): Promise { if (period === "daily" && mode === "rolling") { // 滚动窗口:过去 24 小时 @@ -103,7 +122,7 @@ export async function getTimeRangeForPeriodWithMode( } // 其他情况使用原有逻辑 - return getTimeRangeForPeriod(period, resetTime); + return getTimeRangeForPeriod(period, resetTime, weeklyResetDay, weeklyResetTime); } /** @@ -113,7 +132,12 @@ export async function getTimeRangeForPeriodWithMode( * - weekly: 到下周一 00:00 的秒数 * - monthly: 到下月 1 号 00:00 的秒数 */ -export async function getTTLForPeriod(period: TimePeriod, resetTime = "00:00"): Promise { +export async function getTTLForPeriod( + period: TimePeriod, + resetTime = "00:00", + weeklyResetDay?: number, + weeklyResetTime?: string +): Promise { const timezone = await resolveSystemTimezone(); const now = new Date(); const normalizedResetTime = normalizeResetTime(resetTime); @@ -128,13 +152,27 @@ export async function getTTLForPeriod(period: TimePeriod, resetTime = "00:00"): } case "weekly": { - // 计算到下周一 00:00 的秒数 + // Calculate seconds until next weekly reset + const day = weeklyResetDay ?? 1; + const wTime = normalizeResetTime(weeklyResetTime ?? "00:00"); + const { hours: wHours, minutes: wMinutes } = parseResetTime(wTime); const zonedNow = toZonedTime(now, timezone); - const zonedStartOfWeek = startOfWeek(zonedNow, { weekStartsOn: 1 }); - const zonedNextWeek = addWeeks(zonedStartOfWeek, 1); - const nextWeek = fromZonedTime(zonedNextWeek, timezone); + const zonedStartOfWeek = startOfWeek(zonedNow, { + weekStartsOn: day as 0 | 1 | 2 | 3 | 4 | 5 | 6, + }); + const zonedResetPoint = buildZonedDate(zonedStartOfWeek, wHours, wMinutes); + const resetPoint = fromZonedTime(zonedResetPoint, timezone); + + let nextReset: Date; + if (now < resetPoint) { + nextReset = resetPoint; + } else { + const zonedNextWeek = addWeeks(zonedStartOfWeek, 1); + const zonedNextReset = buildZonedDate(zonedNextWeek, wHours, wMinutes); + nextReset = fromZonedTime(zonedNextReset, timezone); + } - return Math.ceil((nextWeek.getTime() - now.getTime()) / 1000); + return Math.max(1, Math.ceil((nextReset.getTime() - now.getTime()) / 1000)); } case "monthly": { @@ -158,19 +196,26 @@ export async function getTTLForPeriod(period: TimePeriod, resetTime = "00:00"): export async function getTTLForPeriodWithMode( period: TimePeriod, resetTime = "00:00", - mode: DailyResetMode = "fixed" + mode: DailyResetMode = "fixed", + weeklyResetDay?: number, + weeklyResetTime?: string ): Promise { if (period === "daily" && mode === "rolling") { return 24 * 3600; // 24 小时 } - return getTTLForPeriod(period, resetTime); + return getTTLForPeriod(period, resetTime, weeklyResetDay, weeklyResetTime); } /** * 获取重置信息(用于前端展示) */ -export async function getResetInfo(period: TimePeriod, resetTime = "00:00"): Promise { +export async function getResetInfo( + period: TimePeriod, + resetTime = "00:00", + weeklyResetDay?: number, + weeklyResetTime?: string +): Promise { const timezone = await resolveSystemTimezone(); const now = new Date(); const normalizedResetTime = normalizeResetTime(resetTime); @@ -191,10 +236,24 @@ export async function getResetInfo(period: TimePeriod, resetTime = "00:00"): Pro } case "weekly": { + const day = weeklyResetDay ?? 1; + const wTime = normalizeResetTime(weeklyResetTime ?? "00:00"); + const { hours: wHours, minutes: wMinutes } = parseResetTime(wTime); const zonedNow = toZonedTime(now, timezone); - const zonedStartOfWeek = startOfWeek(zonedNow, { weekStartsOn: 1 }); - const zonedNextWeek = addWeeks(zonedStartOfWeek, 1); - const resetAt = fromZonedTime(zonedNextWeek, timezone); + const zonedStartOfWeek = startOfWeek(zonedNow, { + weekStartsOn: day as 0 | 1 | 2 | 3 | 4 | 5 | 6, + }); + const zonedResetPoint = buildZonedDate(zonedStartOfWeek, wHours, wMinutes); + const resetPoint = fromZonedTime(zonedResetPoint, timezone); + + let resetAt: Date; + if (now < resetPoint) { + resetAt = resetPoint; + } else { + const zonedNextWeek = addWeeks(zonedStartOfWeek, 1); + const zonedNextReset = buildZonedDate(zonedNextWeek, wHours, wMinutes); + resetAt = fromZonedTime(zonedNextReset, timezone); + } return { type: "natural", @@ -222,7 +281,9 @@ export async function getResetInfo(period: TimePeriod, resetTime = "00:00"): Pro export async function getResetInfoWithMode( period: TimePeriod, resetTime = "00:00", - mode: DailyResetMode = "fixed" + mode: DailyResetMode = "fixed", + weeklyResetDay?: number, + weeklyResetTime?: string ): Promise { if (period === "daily" && mode === "rolling") { return { @@ -231,7 +292,7 @@ export async function getResetInfoWithMode( }; } - return getResetInfo(period, resetTime); + return getResetInfo(period, resetTime, weeklyResetDay, weeklyResetTime); } function getCustomDailyResetTime(now: Date, resetTime: string, timezone: string): Date { diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 4c5b4d35b..34646a3f3 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -416,6 +416,18 @@ export const CreateProviderSchema = z.object({ .max(50000, "周消费上限不能超过50000美元") .nullable() .optional(), + weekly_reset_day: z.coerce + .number() + .int("每周重置日必须是整数") + .min(0, "每周重置日范围为 0-6") + .max(6, "每周重置日范围为 0-6") + .nullable() + .optional(), + weekly_reset_time: z + .string() + .regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "重置时间格式必须为 HH:mm") + .nullable() + .optional(), limit_monthly_usd: z.coerce .number() .min(0, "月消费上限不能为负数") diff --git a/src/types/provider.ts b/src/types/provider.ts index dd961e79c..c527519dd 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -90,6 +90,8 @@ export interface Provider { dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; limitWeeklyUsd: number | null; + weeklyResetDay: number | null; + weeklyResetTime: string | null; limitMonthlyUsd: number | null; // 总消费上限(手动重置后从 0 重新累计) limitTotalUsd: number | null; @@ -178,6 +180,8 @@ export interface ProviderDisplay { dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; limitWeeklyUsd: number | null; + weeklyResetDay: number | null; + weeklyResetTime: string | null; limitMonthlyUsd: number | null; limitTotalUsd: number | null; limitConcurrentSessions: number; @@ -262,6 +266,8 @@ export interface CreateProviderData { daily_reset_mode?: "fixed" | "rolling"; daily_reset_time?: string; limit_weekly_usd?: number | null; + weekly_reset_day?: number | null; + weekly_reset_time?: string | null; limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; @@ -332,6 +338,8 @@ export interface UpdateProviderData { daily_reset_mode?: "fixed" | "rolling"; daily_reset_time?: string; limit_weekly_usd?: number | null; + weekly_reset_day?: number | null; + weekly_reset_time?: string | null; limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number; diff --git a/tests/unit/actions/providers-usage.test.ts b/tests/unit/actions/providers-usage.test.ts index 81aae83d6..3d015092f 100644 --- a/tests/unit/actions/providers-usage.test.ts +++ b/tests/unit/actions/providers-usage.test.ts @@ -48,11 +48,12 @@ vi.mock("@/lib/session-tracker", () => ({ })); vi.mock("@/lib/rate-limit/time-utils", () => ({ - getTimeRangeForPeriod: (period: string, resetTime?: string) => - getTimeRangeForPeriodMock(period, resetTime), + getTimeRangeForPeriod: (period: string, resetTime?: string, weeklyResetDay?: number, weeklyResetTime?: string) => + getTimeRangeForPeriodMock(period, resetTime, weeklyResetDay, weeklyResetTime), getTimeRangeForPeriodWithMode: (period: string, resetTime?: string, mode?: string) => getTimeRangeForPeriodWithModeMock(period, resetTime, mode), - getResetInfo: (period: string, resetTime?: string) => getResetInfoMock(period, resetTime), + getResetInfo: (period: string, resetTime?: string, weeklyResetDay?: number, weeklyResetTime?: string) => + getResetInfoMock(period, resetTime, weeklyResetDay, weeklyResetTime), getResetInfoWithMode: (period: string, resetTime?: string, mode?: string) => getResetInfoWithModeMock(period, resetTime, mode), })); @@ -188,9 +189,9 @@ describe("getProviderLimitUsage", () => { await getProviderLimitUsage(1); // 5h should use getTimeRangeForPeriod (note: second arg is optional resetTime, defaults to undefined) - expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("5h", undefined); - expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("weekly", undefined); - expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("monthly", undefined); + expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("5h", undefined, undefined, undefined); + expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("weekly", "00:00", undefined, undefined); + expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("monthly", undefined, undefined, undefined); }); it("should call getTimeRangeForPeriodWithMode for daily with provider config", async () => { diff --git a/tests/unit/lib/rate-limit/cost-limits.test.ts b/tests/unit/lib/rate-limit/cost-limits.test.ts index aa3634baf..dd05414e0 100644 --- a/tests/unit/lib/rate-limit/cost-limits.test.ts +++ b/tests/unit/lib/rate-limit/cost-limits.test.ts @@ -121,11 +121,11 @@ describe("RateLimitService - cost limits and quota checks", () => { expect(result.reason).toContain("供应商 每日消费上限已达到(11.0000/10)"); }); - it("checkCostLimits:User fast-path 的类型标识应为 User(避免错误标为“供应商”)", async () => { + it('checkCostLimits: User fast-path should be labeled as User (not Provider)', async () => { const { RateLimitService } = await import("@/lib/rate-limit"); redisClient.get.mockImplementation(async (key: string) => { - if (key === "user:1:cost_weekly") return "20"; + if (key === "user:1:cost_weekly_1_0000") return "20"; return "0"; }); From caee3aa6ac8d26e6961317378565e07905afcd12 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Feb 2026 14:20:26 +0000 Subject: [PATCH 02/12] chore: format code (issue-694-weekly-reset-config-6414f36) --- tests/unit/actions/providers-usage.test.ts | 23 +++++++++++++++---- tests/unit/lib/rate-limit/cost-limits.test.ts | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/unit/actions/providers-usage.test.ts b/tests/unit/actions/providers-usage.test.ts index 3d015092f..90e79da2d 100644 --- a/tests/unit/actions/providers-usage.test.ts +++ b/tests/unit/actions/providers-usage.test.ts @@ -48,12 +48,20 @@ vi.mock("@/lib/session-tracker", () => ({ })); vi.mock("@/lib/rate-limit/time-utils", () => ({ - getTimeRangeForPeriod: (period: string, resetTime?: string, weeklyResetDay?: number, weeklyResetTime?: string) => - getTimeRangeForPeriodMock(period, resetTime, weeklyResetDay, weeklyResetTime), + getTimeRangeForPeriod: ( + period: string, + resetTime?: string, + weeklyResetDay?: number, + weeklyResetTime?: string + ) => getTimeRangeForPeriodMock(period, resetTime, weeklyResetDay, weeklyResetTime), getTimeRangeForPeriodWithMode: (period: string, resetTime?: string, mode?: string) => getTimeRangeForPeriodWithModeMock(period, resetTime, mode), - getResetInfo: (period: string, resetTime?: string, weeklyResetDay?: number, weeklyResetTime?: string) => - getResetInfoMock(period, resetTime, weeklyResetDay, weeklyResetTime), + getResetInfo: ( + period: string, + resetTime?: string, + weeklyResetDay?: number, + weeklyResetTime?: string + ) => getResetInfoMock(period, resetTime, weeklyResetDay, weeklyResetTime), getResetInfoWithMode: (period: string, resetTime?: string, mode?: string) => getResetInfoWithModeMock(period, resetTime, mode), })); @@ -191,7 +199,12 @@ describe("getProviderLimitUsage", () => { // 5h should use getTimeRangeForPeriod (note: second arg is optional resetTime, defaults to undefined) expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("5h", undefined, undefined, undefined); expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("weekly", "00:00", undefined, undefined); - expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith("monthly", undefined, undefined, undefined); + expect(getTimeRangeForPeriodMock).toHaveBeenCalledWith( + "monthly", + undefined, + undefined, + undefined + ); }); it("should call getTimeRangeForPeriodWithMode for daily with provider config", async () => { diff --git a/tests/unit/lib/rate-limit/cost-limits.test.ts b/tests/unit/lib/rate-limit/cost-limits.test.ts index dd05414e0..be18fe9af 100644 --- a/tests/unit/lib/rate-limit/cost-limits.test.ts +++ b/tests/unit/lib/rate-limit/cost-limits.test.ts @@ -121,7 +121,7 @@ describe("RateLimitService - cost limits and quota checks", () => { expect(result.reason).toContain("供应商 每日消费上限已达到(11.0000/10)"); }); - it('checkCostLimits: User fast-path should be labeled as User (not Provider)', async () => { + it("checkCostLimits: User fast-path should be labeled as User (not Provider)", async () => { const { RateLimitService } = await import("@/lib/rate-limit"); redisClient.get.mockImplementation(async (key: string) => { From 642d0130bd2dedcaca7d0994f6cacc84de8519ec Mon Sep 17 00:00:00 2001 From: ylxmf2005 Date: Sun, 1 Feb 2026 23:18:58 +0800 Subject: [PATCH 03/12] fix(rate-limit): resolve PR review issues for weekly reset config Critical fixes: - Fix Redis key mismatch for keys/users weekly limits (Gemini review) Only providers have configurable weekly reset, so keys/users now use 'cost_weekly' without suffix instead of 'cost_weekly_{day}_{HHmm}' - Fix cache warming for weekly to differentiate entity types Providers use weekly_${suffix}, keys/users use plain 'weekly' - Fix getCurrentCost and getCurrentCostBatch for weekly suffix Added weeklyResetDay/weeklyResetTime params to getCurrentCost Extended resetConfigs parameter type in getCurrentCostBatch High priority fixes: - Add weekly_reset_day/time fields to UpdateProviderSchema (CodeRabbit) - Add weekly_reset_day/time fields to addProvider input type (CodeRabbit) - Fix getProviderLimitUsageBatch resetWeekly to use provider config Test updates: - Update cost-limits.test.ts to use correct Redis key for users --- src/actions/providers.ts | 9 +- src/lib/rate-limit/service.ts | 82 +++++++++++++++---- src/lib/validation/schemas.ts | 12 +++ tests/unit/lib/rate-limit/cost-limits.test.ts | 3 +- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 81c082ad2..1ff2c6a9c 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -460,6 +460,8 @@ export async function addProvider(data: { daily_reset_mode?: "fixed" | "rolling"; daily_reset_time?: string; limit_weekly_usd?: number | null; + weekly_reset_day?: number | null; + weekly_reset_time?: string | null; limit_monthly_usd?: number | null; limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; @@ -1379,7 +1381,12 @@ export async function getProviderLimitUsageBatch( provider.dailyResetTime ?? undefined, dailyResetMode ); - const resetWeekly = await getResetInfo("weekly"); + const resetWeekly = await getResetInfo( + "weekly", + "00:00", + provider.weeklyResetDay ?? undefined, + provider.weeklyResetTime ?? undefined + ); const resetMonthly = await getResetInfo("monthly"); result.set(provider.id, { diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 76ccdcbd4..b69b81107 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -267,11 +267,16 @@ export class RateLimitService { if (limit.period === "daily") { periodKey = `daily_${suffix}`; } else if (limit.period === "weekly") { - const wSuffix = RateLimitService.resolveWeeklyReset( - limit.weeklyResetDay, - limit.weeklyResetTime - ).suffix; - periodKey = `weekly_${wSuffix}`; + // Only providers have configurable weekly reset; keys/users use default Monday 00:00 + if (type === "provider") { + const wSuffix = RateLimitService.resolveWeeklyReset( + limit.weeklyResetDay, + limit.weeklyResetTime + ).suffix; + periodKey = `weekly_${wSuffix}`; + } else { + periodKey = "weekly"; + } } else { periodKey = limit.period; } @@ -507,7 +512,19 @@ export class RateLimitService { // daily fixed/周/月固定窗口:使用 STRING + 动态 TTL const { normalized, suffix } = RateLimitService.resolveDailyReset(limit.resetTime); const ttl = await getTTLForPeriodWithMode(limit.period, normalized, limit.resetMode); - const periodKey = limit.period === "daily" ? `${limit.period}_${suffix}` : limit.period; + let periodKey: string; + if (limit.period === "daily") { + periodKey = `${limit.period}_${suffix}`; + } else if (limit.period === "weekly" && type === "provider") { + // Only providers have configurable weekly reset + const wSuffix = RateLimitService.resolveWeeklyReset( + limit.weeklyResetDay, + limit.weeklyResetTime + ).suffix; + periodKey = `weekly_${wSuffix}`; + } else { + periodKey = limit.period; + } await RateLimitService.redis.set( `${type}:${id}:cost_${periodKey}`, current.toString(), @@ -791,7 +808,9 @@ export class RateLimitService { type: "key" | "provider", period: "5h" | "daily" | "weekly" | "monthly", resetTime = "00:00", - resetMode: DailyResetMode = "fixed" + resetMode: DailyResetMode = "fixed", + weeklyResetDay?: number | null, + weeklyResetTime?: string | null ): Promise { try { const dailyResetInfo = RateLimitService.resolveDailyReset(resetTime); @@ -860,7 +879,19 @@ export class RateLimitService { } } else { // daily fixed/周/月使用普通 GET - const redisKey = period === "daily" ? `${period}_${dailyResetInfo.suffix}` : period; + let redisKey: string; + if (period === "daily") { + redisKey = `${period}_${dailyResetInfo.suffix}`; + } else if (period === "weekly" && type === "provider") { + // Only providers have configurable weekly reset + const wSuffix = RateLimitService.resolveWeeklyReset( + weeklyResetDay, + weeklyResetTime + ).suffix; + redisKey = `weekly_${wSuffix}`; + } else { + redisKey = period; + } const value = await RateLimitService.redis.get(`${type}:${id}:cost_${redisKey}`); // Cache Hit @@ -948,7 +979,18 @@ export class RateLimitService { } } else { // daily fixed/周/月固定窗口:使用 STRING + 动态 TTL - const redisKey = period === "daily" ? `${period}_${dailyResetInfo.suffix}` : period; + let redisKey: string; + if (period === "daily") { + redisKey = `${period}_${dailyResetInfo.suffix}`; + } else if (period === "weekly" && type === "provider") { + const wSuffix = RateLimitService.resolveWeeklyReset( + weeklyResetDay, + weeklyResetTime + ).suffix; + redisKey = `weekly_${wSuffix}`; + } else { + redisKey = period; + } const ttl = await getTTLForPeriodWithMode(period, dailyResetInfo.normalized, resetMode); await RateLimitService.redis.set( `${type}:${id}:cost_${redisKey}`, @@ -1215,12 +1257,20 @@ export class RateLimitService { * 用于避免 N+1 查询问题 * * @param providerIds - 供应商 ID 列表 - * @param dailyResetConfigs - 每个供应商的日限额重置配置 + * @param resetConfigs - 每个供应商的限额重置配置 * @returns Map */ static async getCurrentCostBatch( providerIds: number[], - dailyResetConfigs: Map + resetConfigs: Map< + number, + { + resetTime?: string | null; + resetMode?: string | null; + weeklyResetDay?: number | null; + weeklyResetTime?: string | null; + } + > ): Promise< Map > { @@ -1259,9 +1309,13 @@ export class RateLimitService { }> = []; for (const providerId of providerIds) { - const config = dailyResetConfigs.get(providerId); + const config = resetConfigs.get(providerId); const dailyResetMode = (config?.resetMode ?? "fixed") as DailyResetMode; const { suffix } = RateLimitService.resolveDailyReset(config?.resetTime ?? undefined); + const weeklySuffix = RateLimitService.resolveWeeklyReset( + config?.weeklyResetDay, + config?.weeklyResetTime + ).suffix; // 5h 滚动窗口 pipeline.eval( @@ -1288,8 +1342,8 @@ export class RateLimitService { queryMeta.push({ providerId, period: "daily", isRolling: false }); } - // Weekly - pipeline.get(`provider:${providerId}:cost_weekly`); + // Weekly - use suffix for configurable weekly reset + pipeline.get(`provider:${providerId}:cost_weekly_${weeklySuffix}`); queryMeta.push({ providerId, period: "weekly", isRolling: false }); // Monthly diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 34646a3f3..035bf8d1d 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -606,6 +606,18 @@ export const UpdateProviderSchema = z .max(50000, "周消费上限不能超过50000美元") .nullable() .optional(), + weekly_reset_day: z.coerce + .number() + .int("每周重置日必须是整数") + .min(0, "每周重置日范围为 0-6") + .max(6, "每周重置日范围为 0-6") + .nullable() + .optional(), + weekly_reset_time: z + .string() + .regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "重置时间格式必须为 HH:mm") + .nullable() + .optional(), limit_monthly_usd: z.coerce .number() .min(0, "月消费上限不能为负数") diff --git a/tests/unit/lib/rate-limit/cost-limits.test.ts b/tests/unit/lib/rate-limit/cost-limits.test.ts index be18fe9af..68a08f01c 100644 --- a/tests/unit/lib/rate-limit/cost-limits.test.ts +++ b/tests/unit/lib/rate-limit/cost-limits.test.ts @@ -125,7 +125,8 @@ describe("RateLimitService - cost limits and quota checks", () => { const { RateLimitService } = await import("@/lib/rate-limit"); redisClient.get.mockImplementation(async (key: string) => { - if (key === "user:1:cost_weekly_1_0000") return "20"; + // Users don't have configurable weekly reset, so they use cost_weekly without suffix + if (key === "user:1:cost_weekly") return "20"; return "0"; }); From 8adc8e639f517ecbbbd6d306540124e3e2524f2e Mon Sep 17 00:00:00 2001 From: ylxmf2005 Date: Mon, 2 Feb 2026 00:36:53 +0800 Subject: [PATCH 04/12] chore: fix pre-existing lint errors (unused imports) Fixed unused imports across 11 files to pass CI lint check. These were pre-existing issues in the codebase, not related to the weekly reset config feature. --- .../_components/user/forms/limit-rule-picker.tsx | 2 +- .../availability/_components/endpoint/endpoint-tab.tsx | 2 +- .../availability/_components/endpoint/latency-curve.tsx | 9 ++------- .../availability/_components/endpoint/probe-terminal.tsx | 6 +++--- .../availability/_components/provider/latency-chart.tsx | 9 ++------- .../notifications/_components/global-settings-card.tsx | 2 +- .../_components/webhook-targets-section.tsx | 1 - .../[locale]/settings/prices/_components/price-list.tsx | 2 +- src/app/v1/_lib/proxy/forwarder.ts | 8 ++------ src/components/ui/relative-time.tsx | 1 - tests/unit/actions/user-all-limit-window.test.ts | 2 +- 11 files changed, 14 insertions(+), 30 deletions(-) diff --git a/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx b/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx index 26951f50f..437630032 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx @@ -109,7 +109,7 @@ export function LimitRulePicker({ setDailyMode("fixed"); setDailyTime("00:00"); setError(null); - }, [open, availableTypes]); + }, [open]); const numericValue = useMemo(() => { const trimmed = rawValue.trim(); diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx index e4806fdb5..4e32eff34 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx @@ -1,6 +1,6 @@ "use client"; -import { Radio, RefreshCw } from "lucide-react"; +import { Radio } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx index ac4794a08..f07c3942c 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx @@ -3,13 +3,8 @@ import { formatInTimeZone } from "date-fns-tz"; import { useTimeZone, useTranslations } from "next-intl"; import { useMemo } from "react"; -import { CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { cn } from "@/lib/utils"; import type { ProviderEndpointProbeLog } from "@/types/provider"; diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx index 1c89546fc..fb3e29761 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx @@ -1,7 +1,7 @@ "use client"; import { formatInTimeZone } from "date-fns-tz"; -import { AlertCircle, CheckCircle2, Download, Trash2, XCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, XCircle } from "lucide-react"; import { useTimeZone, useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -82,7 +82,7 @@ export function ProbeTerminal({ if (autoScroll && !userScrolled && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } - }, [logs, autoScroll, userScrolled]); + }, [autoScroll, userScrolled]); // Detect user scroll const handleScroll = () => { @@ -188,7 +188,7 @@ export function ProbeTerminal({ filteredLogs.map((log) => { const level = getLogLevel(log); const config = levelConfig[level]; - const Icon = config.icon; + const _Icon = config.icon; return (