Jelajahi Sumber

feat: 实现供应商智能路由和熔断系统

实现完整的多层供应商选择策略,支持优先级管理、成本优化、用户分组隔离和自动故障切换。

## 数据库变更
- providers 表:新增 priority(优先级)、costPerMtok(成本)、groupTag(分组)
- users 表:新增 providerGroup(用户专属分组)
- keys/providers 表:新增金额限流配置(5h/weekly/monthly/concurrent)

## 核心功能

### 1. 四层智能选择算法 (provider-selector.ts)
- Step 0: 用户分组过滤(VIP 专属供应商)
- Step 1: 健康度过滤(熔断器 + 限流检查)
- Step 2: 优先级分层(只选择最高优先级)
- Step 3: 成本优化 + 加权随机(便宜优先)

### 2. 熔断器系统 (circuit-breaker.ts)
- 状态机:Closed → Open (5次失败) → Half-Open (60秒) → Closed (2次成功)
- 自动故障检测和服务恢复
- 内存实现,轻量高效

### 3. 智能重试机制 (forwarder.ts)
- 最多重试 3 次,自动切换备用供应商
- 失败记录到熔断器,实现自适应路由
- HTTP 5xx 触发重试,保证服务可用性

### 4. 限流系统 (rate-limit/)
- Redis + 内存双模式,支持优雅降级
- 支持金额限流(5h/weekly/monthly)和并发限制
- Fail Open 策略,确保服务可用性

## 前端优化
- Provider 管理:新增优先级、权重、成本、分组配置界面
- User 管理:新增供应商分组选择
- 表单验证:完整的 Zod schema 验证

## 技术亮点
- 零破坏设计:所有新字段可选,向后兼容
- Good Taste 原则:数据结构消除特殊情况
- Fail Open 策略:任何组件失败都降级而非阻断

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 4 bulan lalu
induk
melakukan
a43e802c68
42 mengubah file dengan 3416 tambahan dan 391 penghapusan
  1. 6 0
      .env.example
  2. 16 0
      docker-compose.yml
  3. 8 0
      drizzle/0007_lively_luminals.sql
  4. 7 0
      drizzle/0008_bumpy_chameleon.sql
  5. 820 0
      drizzle/meta/0007_snapshot.json
  6. 867 0
      drizzle/meta/0008_snapshot.json
  7. 14 0
      drizzle/meta/_journal.json
  8. 2 0
      package.json
  9. 80 34
      pnpm-lock.yaml
  10. 10 2
      src/actions/keys.ts
  11. 25 0
      src/actions/providers.ts
  12. 7 1
      src/actions/users.ts
  13. 42 2
      src/app/dashboard/_components/user/forms/add-key-form.tsx
  14. 49 5
      src/app/dashboard/_components/user/forms/edit-key-form.tsx
  15. 12 0
      src/app/dashboard/_components/user/forms/user-form.tsx
  16. 165 59
      src/app/settings/providers/_components/forms/provider-form.tsx
  17. 108 105
      src/app/settings/providers/_components/hooks/use-provider-edit.ts
  18. 134 104
      src/app/settings/providers/_components/provider-list-item.tsx
  19. 1 1
      src/app/settings/providers/page.tsx
  20. 9 0
      src/app/v1/_lib/proxy-handler.ts
  21. 100 5
      src/app/v1/_lib/proxy/forwarder.ts
  22. 131 11
      src/app/v1/_lib/proxy/provider-selector.ts
  23. 65 0
      src/app/v1/_lib/proxy/rate-limit-guard.ts
  24. 47 0
      src/app/v1/_lib/proxy/response-handler.ts
  25. 1 16
      src/components/form/form-field.tsx
  26. 26 2
      src/drizzle/schema.ts
  27. 5 0
      src/lib/auth.ts
  28. 130 0
      src/lib/circuit-breaker.ts
  29. 4 8
      src/lib/constants/provider.constants.ts
  30. 1 0
      src/lib/rate-limit/index.ts
  31. 174 0
      src/lib/rate-limit/service.ts
  32. 57 0
      src/lib/redis/client.ts
  33. 1 0
      src/lib/redis/index.ts
  34. 9 3
      src/lib/utils/validation/provider.ts
  35. 110 32
      src/lib/validation/schemas.ts
  36. 13 1
      src/repository/_shared/transformers.ts
  37. 40 0
      src/repository/key.ts
  38. 42 0
      src/repository/provider.ts
  39. 7 0
      src/repository/user.ts
  40. 17 0
      src/types/key.ts
  41. 50 0
      src/types/provider.ts
  42. 4 0
      src/types/user.ts

+ 6 - 0
.env.example

@@ -5,3 +5,9 @@ AUTO_MIGRATE=true
 
 DSN="postgres://user:password@host:port/db_name"
 
+# Redis 配置(限流功能)
+ENABLE_RATE_LIMIT=true
+REDIS_URL=redis://localhost:6379
+
+# Session 配置
+SESSION_TTL=300

+ 16 - 0
docker-compose.yml

@@ -18,5 +18,21 @@ services:
       timeout: 5s
       retries: 5
 
+  redis:
+    image: redis:7-alpine
+    container_name: claude-hub-redis
+    ports:
+      - "6379:6379"
+    volumes:
+      - redis_data:/data
+    command: redis-server --appendonly yes
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      timeout: 3s
+      retries: 5
+    restart: unless-stopped
+
 volumes:
   postgres_data:
+  redis_data:

+ 8 - 0
drizzle/0007_lively_luminals.sql

@@ -0,0 +1,8 @@
+ALTER TABLE "keys" ADD COLUMN "limit_5h_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "keys" ADD COLUMN "limit_weekly_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "keys" ADD COLUMN "limit_monthly_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "keys" ADD COLUMN "limit_concurrent_sessions" integer DEFAULT 0;--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "limit_5h_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "limit_weekly_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "limit_monthly_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "limit_concurrent_sessions" integer DEFAULT 0;

+ 7 - 0
drizzle/0008_bumpy_chameleon.sql

@@ -0,0 +1,7 @@
+DROP INDEX "idx_providers_enabled_weight";--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "priority" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "cost_per_mtok" numeric(10, 4);--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "group_tag" varchar(50);--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "provider_group" varchar(50);--> statement-breakpoint
+CREATE INDEX "idx_providers_enabled_priority" ON "providers" USING btree ("is_enabled","priority","weight") WHERE "providers"."deleted_at" IS NULL;--> statement-breakpoint
+CREATE INDEX "idx_providers_group" ON "providers" USING btree ("group_tag") WHERE "providers"."deleted_at" IS NULL;

+ 820 - 0
drizzle/meta/0007_snapshot.json

@@ -0,0 +1,820 @@
+{
+  "id": "9ae97b20-7648-43cb-b719-62e7133586d4",
+  "prevId": "6d326a08-5a90-4b2e-a627-4d0d88f661b8",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "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_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "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_weight": {
+          "name": "idx_providers_enabled_weight",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "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_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.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": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 867 - 0
drizzle/meta/0008_snapshot.json

@@ -0,0 +1,867 @@
+{
+  "id": "2d99973b-a8c8-47bf-a59d-37514929bf5e",
+  "prevId": "9ae97b20-7648-43cb-b719-62e7133586d4",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "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_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_per_mtok": {
+          "name": "cost_per_mtok",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.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": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 14 - 0
drizzle/meta/_journal.json

@@ -50,6 +50,20 @@
       "when": 1759814494585,
       "tag": "0006_expand_cost_precision",
       "breakpoints": true
+    },
+    {
+      "idx": 7,
+      "version": "7",
+      "when": 1761016542065,
+      "tag": "0007_lively_luminals",
+      "breakpoints": true
+    },
+    {
+      "idx": 8,
+      "version": "7",
+      "when": 1761021877184,
+      "tag": "0008_bumpy_chameleon",
+      "breakpoints": true
     }
   ]
 }

+ 2 - 0
package.json

@@ -32,6 +32,7 @@
     "dotenv": "^17.2.2",
     "drizzle-orm": "^0.44.5",
     "hono": "^4.9.6",
+    "ioredis": "^5.8.1",
     "lucide-react": "^0.544.0",
     "next": "15.4.6",
     "next-themes": "^0.4.6",
@@ -48,6 +49,7 @@
   "devDependencies": {
     "@eslint/eslintrc": "^3.3.1",
     "@tailwindcss/postcss": "^4.1.13",
+    "@types/ioredis": "^5.0.0",
     "@types/node": "^20.19.13",
     "@types/pg": "^8.15.5",
     "@types/react": "^19.1.12",

+ 80 - 34
pnpm-lock.yaml

@@ -59,6 +59,9 @@ importers:
       hono:
         specifier: ^4.9.6
         version: 4.9.8
+      ioredis:
+        specifier: ^5.8.1
+        version: 5.8.1
       lucide-react:
         specifier: ^0.544.0
         version: 0.544.0([email protected])
@@ -102,6 +105,9 @@ importers:
       '@tailwindcss/postcss':
         specifier: ^4.1.13
         version: 4.1.13
+      '@types/ioredis':
+        specifier: ^5.0.0
+        version: 5.0.0
       '@types/node':
         specifier: ^20.19.13
         version: 20.19.17
@@ -553,92 +559,78 @@ packages:
     resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
     cpu: [arm]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
     engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@img/[email protected]':
     resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==}
@@ -663,6 +655,9 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@ioredis/[email protected]':
+    resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
+
   '@isaacs/[email protected]':
     resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
     engines: {node: '>=18.0.0'}
@@ -712,28 +707,24 @@ packages:
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@next/[email protected]':
     resolution: {integrity: sha512-XBbuQddtY1p5FGPc2naMO0kqs4YYtLYK/8aPausI5lyOjr4J77KTG9mtlU4P3NwkLI1+OjsPzKVvSJdMs3cFaw==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@next/[email protected]':
     resolution: {integrity: sha512-+WTeK7Qdw82ez3U9JgD+igBAP75gqZ1vbK6R8PlEEuY0OIe5FuYXA4aTjL811kWPf7hNeslD4hHK2WoM9W0IgA==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@next/[email protected]':
     resolution: {integrity: sha512-XP824mCbgQsK20jlXKrUpZoh/iO3vUWhMpxCz8oYeagoiZ4V0TQiKy0ASji1KK6IAe3DYGfj5RfKP6+L2020OQ==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@next/[email protected]':
     resolution: {integrity: sha512-FxrsenhUz0LbgRkNWx6FRRJIPe/MI1JRA4W4EPd5leXO00AZ6YU8v5vfx4MDXTvN77lM/EqsE3+6d2CIeF5NYg==}
@@ -1214,28 +1205,24 @@ packages:
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@tailwindcss/[email protected]':
     resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@tailwindcss/[email protected]':
     resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@tailwindcss/[email protected]':
     resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@tailwindcss/[email protected]':
     resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==}
@@ -1309,6 +1296,10 @@ packages:
   '@types/[email protected]':
     resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 
+  '@types/[email protected]':
+    resolution: {integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==}
+    deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed.
+
   '@types/[email protected]':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
@@ -1427,49 +1418,41 @@ packages:
     resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
     cpu: [riscv64]
     os: [linux]
-    libc: [musl]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@unrs/[email protected]':
     resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -1628,6 +1611,10 @@ packages:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
 
+  [email protected]:
+    resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
+    engines: {node: '>=0.10.0'}
+
   [email protected]:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -1742,6 +1729,10 @@ packages:
     resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
     engines: {node: '>= 0.4'}
 
+  [email protected]:
+    resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
+    engines: {node: '>=0.10'}
+
   [email protected]:
     resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
     engines: {node: '>=8'}
@@ -2202,6 +2193,10 @@ packages:
     resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
     engines: {node: '>=12'}
 
+  [email protected]:
+    resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==}
+    engines: {node: '>=12.22.0'}
+
   [email protected]:
     resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
     engines: {node: '>= 0.4'}
@@ -2389,28 +2384,24 @@ packages:
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   [email protected]:
     resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   [email protected]:
     resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   [email protected]:
     resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   [email protected]:
     resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -2432,6 +2423,12 @@ packages:
     resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
     engines: {node: '>=10'}
 
+  [email protected]:
+    resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
+
+  [email protected]:
+    resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
+
   [email protected]:
     resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
 
@@ -2726,6 +2723,14 @@ packages:
       react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
       react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
 
+  [email protected]:
+    resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
+    engines: {node: '>=4'}
+
+  [email protected]:
+    resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
+    engines: {node: '>=4'}
+
   [email protected]:
     resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
     engines: {node: '>= 0.4'}
@@ -2844,6 +2849,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
 
+  [email protected]:
+    resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
+
   [email protected]:
     resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
     engines: {node: '>= 0.4'}
@@ -3392,6 +3400,8 @@ snapshots:
   '@img/[email protected]':
     optional: true
 
+  '@ioredis/[email protected]': {}
+
   '@isaacs/[email protected]':
     dependencies:
       minipass: 7.1.2
@@ -3985,6 +3995,12 @@ snapshots:
 
   '@types/[email protected]': {}
 
+  '@types/[email protected]':
+    dependencies:
+      ioredis: 5.8.1
+    transitivePeerDependencies:
+      - supports-color
+
   '@types/[email protected]': {}
 
   '@types/[email protected]': {}
@@ -4322,6 +4338,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       color-name: 1.1.4
@@ -4432,6 +4450,8 @@ snapshots:
       has-property-descriptors: 1.0.2
       object-keys: 1.1.1
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -5001,6 +5021,20 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      '@ioredis/commands': 1.4.0
+      cluster-key-slot: 1.1.2
+      debug: 4.4.1
+      denque: 2.1.0
+      lodash.defaults: 4.2.0
+      lodash.isarguments: 3.1.0
+      redis-errors: 1.2.0
+      redis-parser: 3.0.0
+      standard-as-callback: 2.1.0
+    transitivePeerDependencies:
+      - supports-color
+
   [email protected]:
     dependencies:
       call-bind: 1.0.8
@@ -5217,6 +5251,10 @@ snapshots:
     dependencies:
       p-locate: 5.0.0
 
+  [email protected]: {}
+
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -5499,6 +5537,12 @@ snapshots:
       tiny-invariant: 1.3.3
       victory-vendor: 36.9.2
 
+  [email protected]: {}
+
+  [email protected]:
+    dependencies:
+      redis-errors: 1.2.0
+
   [email protected]:
     dependencies:
       call-bind: 1.0.8
@@ -5673,6 +5717,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       es-errors: 1.3.0

+ 10 - 2
src/actions/keys.ts

@@ -40,7 +40,11 @@ export async function addKey(
       name: validatedData.name,
       key: generatedKey,
       is_enabled: true,
-      expires_at: validatedData.expiresAt ? new Date(validatedData.expiresAt) : undefined
+      expires_at: validatedData.expiresAt ? new Date(validatedData.expiresAt) : undefined,
+      limit_5h_usd: validatedData.limit5hUsd,
+      limit_weekly_usd: validatedData.limitWeeklyUsd,
+      limit_monthly_usd: validatedData.limitMonthlyUsd,
+      limit_concurrent_sessions: validatedData.limitConcurrentSessions,
     });
 
     revalidatePath('/dashboard');
@@ -79,7 +83,11 @@ export async function editKey(
     
     await updateKey(keyId, {
       name: validatedData.name,
-      expires_at: validatedData.expiresAt ? new Date(validatedData.expiresAt) : undefined
+      expires_at: validatedData.expiresAt ? new Date(validatedData.expiresAt) : undefined,
+      limit_5h_usd: validatedData.limit5hUsd,
+      limit_weekly_usd: validatedData.limitWeeklyUsd,
+      limit_monthly_usd: validatedData.limitMonthlyUsd,
+      limit_concurrent_sessions: validatedData.limitConcurrentSessions,
     });
     
     revalidatePath('/dashboard');

+ 25 - 0
src/actions/providers.ts

@@ -25,6 +25,13 @@ export async function getProviders(): Promise<ProviderDisplay[]> {
       maskedKey: maskKey(provider.key),
       isEnabled: provider.isEnabled,
       weight: provider.weight,
+      priority: provider.priority,
+      costPerMtok: provider.costPerMtok,
+      groupTag: provider.groupTag,
+      limit5hUsd: provider.limit5hUsd,
+      limitWeeklyUsd: provider.limitWeeklyUsd,
+      limitMonthlyUsd: provider.limitMonthlyUsd,
+      limitConcurrentSessions: provider.limitConcurrentSessions,
       tpm: provider.tpm,
       rpm: provider.rpm,
       rpd: provider.rpd,
@@ -45,6 +52,13 @@ export async function addProvider(data: {
   key: string;
   is_enabled?: boolean;
   weight?: number;
+  priority?: number;
+  cost_per_mtok?: number | null;
+  group_tag?: string | null;
+  limit_5h_usd?: number | null;
+  limit_weekly_usd?: number | null;
+  limit_monthly_usd?: number | null;
+  limit_concurrent_sessions?: number | null;
   tpm: number | null;
   rpm: number | null;
   rpd: number | null;
@@ -59,6 +73,10 @@ export async function addProvider(data: {
     const validated = CreateProviderSchema.parse(data);
     const payload = {
       ...validated,
+      limit_5h_usd: validated.limit_5h_usd ?? null,
+      limit_weekly_usd: validated.limit_weekly_usd ?? null,
+      limit_monthly_usd: validated.limit_monthly_usd ?? null,
+      limit_concurrent_sessions: validated.limit_concurrent_sessions ?? 0,
       tpm: validated.tpm ?? null,
       rpm: validated.rpm ?? null,
       rpd: validated.rpd ?? null,
@@ -83,6 +101,13 @@ export async function editProvider(
     key?: string;
     is_enabled?: boolean;
     weight?: number;
+    priority?: number;
+    cost_per_mtok?: number | null;
+    group_tag?: string | null;
+    limit_5h_usd?: number | null;
+    limit_weekly_usd?: number | null;
+    limit_monthly_usd?: number | null;
+    limit_concurrent_sessions?: number | null;
     tpm?: number | null;
     rpm?: number | null;
     rpd?: number | null;

+ 7 - 1
src/actions/users.ts

@@ -59,6 +59,7 @@ export async function getUsers(): Promise<UserDisplay[]> {
             role: user.role,
             rpm: user.rpm,
             dailyQuota: user.dailyQuota,
+            providerGroup: user.providerGroup || undefined,
             keys: keys.map((key) => ({
               id: key.id,
               name: key.name,
@@ -90,6 +91,7 @@ export async function getUsers(): Promise<UserDisplay[]> {
             role: user.role,
             rpm: user.rpm,
             dailyQuota: user.dailyQuota,
+            providerGroup: user.providerGroup || undefined,
             keys: [],
           };
         }
@@ -107,6 +109,7 @@ export async function getUsers(): Promise<UserDisplay[]> {
 export async function addUser(data: {
   name: string;
   note?: string;
+  providerGroup?: string | null;
   rpm?: number;
   dailyQuota?: number;
 }): Promise<ActionResult> {
@@ -120,6 +123,7 @@ export async function addUser(data: {
     const validatedData = CreateUserSchema.parse({
       name: data.name,
       note: data.note || "",
+      providerGroup: data.providerGroup || "",
       rpm: data.rpm || USER_DEFAULTS.RPM,
       dailyQuota: data.dailyQuota || USER_DEFAULTS.DAILY_QUOTA,
     });
@@ -127,6 +131,7 @@ export async function addUser(data: {
     const newUser = await createUser({
       name: validatedData.name,
       description: validatedData.note || "",
+      providerGroup: validatedData.providerGroup || null,
       rpm: validatedData.rpm,
       dailyQuota: validatedData.dailyQuota,
     });
@@ -154,7 +159,7 @@ export async function addUser(data: {
 // 更新用户
 export async function editUser(
   userId: number,
-  data: { name?: string; note?: string; rpm?: number; dailyQuota?: number },
+  data: { name?: string; note?: string; providerGroup?: string | null; rpm?: number; dailyQuota?: number },
 ): Promise<ActionResult> {
   try {
     const session = await getSession();
@@ -167,6 +172,7 @@ export async function editUser(
     await updateUser(userId, {
       name: validatedData.name,
       description: validatedData.note,
+      providerGroup: validatedData.providerGroup,
       rpm: validatedData.rpm,
       dailyQuota: validatedData.dailyQuota,
     });

+ 42 - 2
src/app/dashboard/_components/user/forms/add-key-form.tsx

@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
 import { toast } from "sonner";
 import { addKey } from "@/actions/keys";
 import { DialogFormLayout } from "@/components/form/form-layout";
-import { TextField, DateField } from "@/components/form/form-field";
+import { TextField, DateField, NumberField } from "@/components/form/form-field";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 
@@ -21,7 +21,11 @@ export function AddKeyForm({ userId, onSuccess }: AddKeyFormProps) {
     schema: KeyFormSchema,
     defaultValues: {
       name: '',
-      expiresAt: ''
+      expiresAt: '',
+      limit5hUsd: null,
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitConcurrentSessions: 0,
     },
     onSubmit: async (data) => {
       if (!userId) {
@@ -87,6 +91,42 @@ export function AddKeyForm({ userId, onSuccess }: AddKeyFormProps) {
         description="留空表示永不过期"
         {...form.getFieldProps('expiresAt')}
       />
+
+      <NumberField
+        label="5小时消费上限 (USD)"
+        placeholder="留空表示无限制"
+        description="5小时内最大消费金额"
+        min={0}
+        step={0.01}
+        {...form.getFieldProps('limit5hUsd')}
+      />
+
+      <NumberField
+        label="周消费上限 (USD)"
+        placeholder="留空表示无限制"
+        description="每周最大消费金额"
+        min={0}
+        step={0.01}
+        {...form.getFieldProps('limitWeeklyUsd')}
+      />
+
+      <NumberField
+        label="月消费上限 (USD)"
+        placeholder="留空表示无限制"
+        description="每月最大消费金额"
+        min={0}
+        step={0.01}
+        {...form.getFieldProps('limitMonthlyUsd')}
+      />
+
+      <NumberField
+        label="并发 Session 上限"
+        placeholder="0 表示无限制"
+        description="同时运行的对话数量"
+        min={0}
+        step={1}
+        {...form.getFieldProps('limitConcurrentSessions')}
+      />
     </DialogFormLayout>
   );
 }

+ 49 - 5
src/app/dashboard/_components/user/forms/edit-key-form.tsx

@@ -3,7 +3,7 @@ import { useTransition } from "react";
 import { useRouter } from "next/navigation";
 import { editKey } from "@/actions/keys";
 import { DialogFormLayout } from "@/components/form/form-layout";
-import { TextField, DateField } from "@/components/form/form-field";
+import { TextField, DateField, NumberField } from "@/components/form/form-field";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import { toast } from "sonner";
@@ -13,6 +13,10 @@ interface EditKeyFormProps {
     id: number;
     name: string;
     expiresAt: string;
+    limit5hUsd?: number | null;
+    limitWeeklyUsd?: number | null;
+    limitMonthlyUsd?: number | null;
+    limitConcurrentSessions?: number;
   };
   onSuccess?: () => void;
 }
@@ -34,13 +38,17 @@ export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
     schema: KeyFormSchema,
     defaultValues: {
       name: keyData?.name || '',
-      expiresAt: formatExpiresAt(keyData?.expiresAt || "")
+      expiresAt: formatExpiresAt(keyData?.expiresAt || ""),
+      limit5hUsd: keyData?.limit5hUsd ?? null,
+      limitWeeklyUsd: keyData?.limitWeeklyUsd ?? null,
+      limitMonthlyUsd: keyData?.limitMonthlyUsd ?? null,
+      limitConcurrentSessions: keyData?.limitConcurrentSessions ?? 0,
     },
     onSubmit: async (data) => {
       if (!keyData) {
         throw new Error("密钥信息不存在");
       }
-      
+
       startTransition(async () => {
         try {
           const res = await editKey(keyData.id, {
@@ -65,7 +73,7 @@ export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
     <DialogFormLayout
       config={{
         title: "编辑 Key",
-        description: "修改密钥的名称和过期时间。",
+        description: "修改密钥的名称、过期时间和限流配置。",
         submitText: "保存修改",
         loadingText: "保存中..."
       }}
@@ -82,13 +90,49 @@ export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
         placeholder="请输入Key名称"
         {...form.getFieldProps('name')}
       />
-      
+
       <DateField
         label="过期时间"
         placeholder="选择过期时间"
         description="留空表示永不过期"
         {...form.getFieldProps('expiresAt')}
       />
+
+      <NumberField
+        label="5小时消费上限 (USD)"
+        placeholder="留空表示无限制"
+        description="5小时内最大消费金额"
+        min={0}
+        step={0.01}
+        {...form.getFieldProps('limit5hUsd')}
+      />
+
+      <NumberField
+        label="周消费上限 (USD)"
+        placeholder="留空表示无限制"
+        description="每周最大消费金额"
+        min={0}
+        step={0.01}
+        {...form.getFieldProps('limitWeeklyUsd')}
+      />
+
+      <NumberField
+        label="月消费上限 (USD)"
+        placeholder="留空表示无限制"
+        description="每月最大消费金额"
+        min={0}
+        step={0.01}
+        {...form.getFieldProps('limitMonthlyUsd')}
+      />
+
+      <NumberField
+        label="并发 Session 上限"
+        placeholder="0 表示无限制"
+        description="同时运行的对话数量"
+        min={0}
+        step={1}
+        {...form.getFieldProps('limitConcurrentSessions')}
+      />
     </DialogFormLayout>
   );
 }

+ 12 - 0
src/app/dashboard/_components/user/forms/user-form.tsx

@@ -16,6 +16,7 @@ interface UserFormProps {
     note?: string;
     rpm: number;
     dailyQuota: number;
+    providerGroup?: string | null;
   };
   onSuccess?: () => void;
 }
@@ -32,6 +33,7 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
       note: user?.note || '',
       rpm: user?.rpm || USER_DEFAULTS.RPM,
       dailyQuota: user?.dailyQuota || USER_DEFAULTS.DAILY_QUOTA,
+      providerGroup: user?.providerGroup || '',
     },
     onSubmit: async (data) => {
       startTransition(async () => {
@@ -43,6 +45,7 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
               note: data.note,
               rpm: data.rpm,
               dailyQuota: data.dailyQuota,
+              providerGroup: data.providerGroup || null,
             });
           } else {
             res = await addUser({
@@ -50,6 +53,7 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
               note: data.note,
               rpm: data.rpm,
               dailyQuota: data.dailyQuota,
+              providerGroup: data.providerGroup || null,
             });
           }
 
@@ -99,6 +103,14 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
         {...form.getFieldProps("note")}
       />
 
+      <TextField
+        label="供应商分组"
+        maxLength={50}
+        placeholder="例如: premium, economy(可选)"
+        description="指定用户专属的供应商分组,留空则使用全局供应商池"
+        {...form.getFieldProps("providerGroup")}
+      />
+
       <TextField
         label="RPM限制"
         type="number"

+ 165 - 59
src/app/settings/providers/_components/forms/provider-form.tsx

@@ -35,10 +35,14 @@ export function ProviderForm({ mode, onSuccess, provider }: ProviderFormProps) {
   const [name, setName] = useState(isEdit ? provider?.name ?? "" : "");
   const [url, setUrl] = useState(isEdit ? provider?.url ?? "" : "");
   const [key, setKey] = useState(""); // 编辑时留空代表不更新
-  const [tpm, setTpm] = useState<number | null>(isEdit ? provider?.tpm ?? null : null);
-  const [rpm, setRpm] = useState<number | null>(isEdit ? provider?.rpm ?? null : null);
-  const [rpd, setRpd] = useState<number | null>(isEdit ? provider?.rpd ?? null : null);
-  const [cc, setCc] = useState<number | null>(isEdit ? provider?.cc ?? null : null);
+  const [priority, setPriority] = useState<number>(isEdit ? provider?.priority ?? 0 : 0);
+  const [weight, setWeight] = useState<number>(isEdit ? provider?.weight ?? 1 : 1);
+  const [costPerMtok, setCostPerMtok] = useState<number | null>(isEdit ? provider?.costPerMtok ?? null : null);
+  const [groupTag, setGroupTag] = useState<string>(isEdit ? provider?.groupTag ?? "" : "");
+  const [limit5hUsd, setLimit5hUsd] = useState<number | null>(isEdit ? provider?.limit5hUsd ?? null : null);
+  const [limitWeeklyUsd, setLimitWeeklyUsd] = useState<number | null>(isEdit ? provider?.limitWeeklyUsd ?? null : null);
+  const [limitMonthlyUsd, setLimitMonthlyUsd] = useState<number | null>(isEdit ? provider?.limitMonthlyUsd ?? null : null);
+  const [limitConcurrentSessions, setLimitConcurrentSessions] = useState<number | null>(isEdit ? provider?.limitConcurrentSessions ?? null : null);
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
@@ -59,6 +63,14 @@ export function ProviderForm({ mode, onSuccess, provider }: ProviderFormProps) {
             name?: string;
             url?: string;
             key?: string;
+            priority?: number;
+            weight?: number;
+            cost_per_mtok?: number | null;
+            group_tag?: string | null;
+            limit_5h_usd?: number | null;
+            limit_weekly_usd?: number | null;
+            limit_monthly_usd?: number | null;
+            limit_concurrent_sessions?: number | null;
             tpm?: number | null;
             rpm?: number | null;
             rpd?: number | null;
@@ -66,10 +78,18 @@ export function ProviderForm({ mode, onSuccess, provider }: ProviderFormProps) {
           } = {
             name: name.trim(),
             url: url.trim(),
-            tpm,
-            rpm,
-            rpd,
-            cc,
+            priority: priority,
+            weight: weight,
+            cost_per_mtok: costPerMtok,
+            group_tag: groupTag.trim() || null,
+            limit_5h_usd: limit5hUsd,
+            limit_weekly_usd: limitWeeklyUsd,
+            limit_monthly_usd: limitMonthlyUsd,
+            limit_concurrent_sessions: limitConcurrentSessions,
+            tpm: null,
+            rpm: null,
+            rpd: null,
+            cc: null,
           };
           if (key.trim()) {
             updateData.key = key.trim();
@@ -86,11 +106,18 @@ export function ProviderForm({ mode, onSuccess, provider }: ProviderFormProps) {
             key: key.trim(),
             // 使用配置的默认值:默认不启用、权重=1
             is_enabled: PROVIDER_DEFAULTS.IS_ENABLED,
-            weight: PROVIDER_DEFAULTS.WEIGHT,
-            tpm,
-            rpm,
-            rpd,
-            cc,
+            weight: weight,
+            priority: priority,
+            cost_per_mtok: costPerMtok,
+            group_tag: groupTag.trim() || null,
+            limit_5h_usd: limit5hUsd,
+            limit_weekly_usd: limitWeeklyUsd,
+            limit_monthly_usd: limitMonthlyUsd,
+            limit_concurrent_sessions: limitConcurrentSessions ?? 0,
+            tpm: null,
+            rpm: null,
+            rpd: null,
+            cc: null,
           });
           if (!res.ok) {
             toast.error(res.error || '添加服务商失败');
@@ -100,10 +127,14 @@ export function ProviderForm({ mode, onSuccess, provider }: ProviderFormProps) {
           setName("");
           setUrl("");
           setKey("");
-          setTpm(null);
-          setRpm(null);
-          setRpd(null);
-          setCc(null);
+          setPriority(0);
+          setWeight(1);
+          setCostPerMtok(null);
+          setGroupTag("");
+          setLimit5hUsd(null);
+          setLimitWeeklyUsd(null);
+          setLimitMonthlyUsd(null);
+          setLimitConcurrentSessions(null);
         }
         onSuccess?.();
       } catch (error) {
@@ -162,57 +193,132 @@ export function ProviderForm({ mode, onSuccess, provider }: ProviderFormProps) {
           ) : null}
         </div>
 
-        <div className="grid grid-cols-2 gap-4">
-          <div className="space-y-2">
-            <Label htmlFor={isEdit ? "edit-tpm" : "tpm"}>TPM (每分钟Token数)</Label>
-            <Input
-              id={isEdit ? "edit-tpm" : "tpm"}
-              type="number"
-              value={tpm?.toString() ?? ""}
-              onChange={(e) => setTpm(validateNumericField(e.target.value))}
-              placeholder="留空表示无限制"
-              disabled={isPending}
-              min="1"
-            />
+        {/* 路由配置 */}
+        <div className="space-y-4 pt-2 border-t">
+          <div className="text-sm font-medium">路由配置</div>
+          <div className="grid grid-cols-3 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor={isEdit ? "edit-priority" : "priority"}>
+                优先级
+                <span className="text-xs text-muted-foreground ml-1">(0最高)</span>
+              </Label>
+              <Input
+                id={isEdit ? "edit-priority" : "priority"}
+                type="number"
+                value={priority}
+                onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
+                placeholder="0"
+                disabled={isPending}
+                min="0"
+                step="1"
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor={isEdit ? "edit-weight" : "weight"}>
+                权重
+                <span className="text-xs text-muted-foreground ml-1">(负载均衡)</span>
+              </Label>
+              <Input
+                id={isEdit ? "edit-weight" : "weight"}
+                type="number"
+                value={weight}
+                onChange={(e) => setWeight(parseInt(e.target.value) || 1)}
+                placeholder="1"
+                disabled={isPending}
+                min="1"
+                step="1"
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor={isEdit ? "edit-cost" : "cost"}>
+                成本 (USD/M tokens)
+              </Label>
+              <Input
+                id={isEdit ? "edit-cost" : "cost"}
+                type="number"
+                value={costPerMtok?.toString() ?? ""}
+                onChange={(e) => setCostPerMtok(validateNumericField(e.target.value))}
+                placeholder="留空表示未知"
+                disabled={isPending}
+                min="0"
+                step="0.0001"
+              />
+            </div>
           </div>
           <div className="space-y-2">
-            <Label htmlFor={isEdit ? "edit-rpm" : "rpm"}>RPM (每分钟请求数)</Label>
+            <Label htmlFor={isEdit ? "edit-group" : "group"}>
+              供应商分组
+              <span className="text-xs text-muted-foreground ml-1">(用于用户绑定)</span>
+            </Label>
             <Input
-              id={isEdit ? "edit-rpm" : "rpm"}
-              type="number"
-              value={rpm?.toString() ?? ""}
-              onChange={(e) => setRpm(validateNumericField(e.target.value))}
-              placeholder="留空表示无限制"
+              id={isEdit ? "edit-group" : "group"}
+              value={groupTag}
+              onChange={(e) => setGroupTag(e.target.value)}
+              placeholder="例如: premium, economy"
               disabled={isPending}
-              min="1"
             />
           </div>
         </div>
 
-        <div className="grid grid-cols-2 gap-4">
-          <div className="space-y-2">
-            <Label htmlFor={isEdit ? "edit-rpd" : "rpd"}>RPD (每日请求数)</Label>
-            <Input
-              id={isEdit ? "edit-rpd" : "rpd"}
-              type="number"
-              value={rpd?.toString() ?? ""}
-              onChange={(e) => setRpd(validateNumericField(e.target.value))}
-              placeholder="留空表示无限制"
-              disabled={isPending}
-              min="1"
-            />
+        {/* 限流配置 */}
+        <div className="space-y-4 pt-2 border-t">
+          <div className="text-sm font-medium">限流配置</div>
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor={isEdit ? "edit-limit-5h" : "limit-5h"}>5小时消费上限 (USD)</Label>
+              <Input
+                id={isEdit ? "edit-limit-5h" : "limit-5h"}
+                type="number"
+                value={limit5hUsd?.toString() ?? ""}
+                onChange={(e) => setLimit5hUsd(validateNumericField(e.target.value))}
+                placeholder="留空表示无限制"
+                disabled={isPending}
+                min="0"
+                step="0.01"
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor={isEdit ? "edit-limit-weekly" : "limit-weekly"}>周消费上限 (USD)</Label>
+              <Input
+                id={isEdit ? "edit-limit-weekly" : "limit-weekly"}
+                type="number"
+                value={limitWeeklyUsd?.toString() ?? ""}
+                onChange={(e) => setLimitWeeklyUsd(validateNumericField(e.target.value))}
+                placeholder="留空表示无限制"
+                disabled={isPending}
+                min="0"
+                step="0.01"
+              />
+            </div>
           </div>
-          <div className="space-y-2">
-            <Label htmlFor={isEdit ? "edit-cc" : "cc"}>并发连接数</Label>
-            <Input
-              id={isEdit ? "edit-cc" : "cc"}
-              type="number"
-              value={cc?.toString() ?? ""}
-              onChange={(e) => setCc(validateNumericField(e.target.value))}
-              placeholder="例如: 10,留空表示无限制"
-              disabled={isPending}
-              min="1"
-            />
+
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor={isEdit ? "edit-limit-monthly" : "limit-monthly"}>月消费上限 (USD)</Label>
+              <Input
+                id={isEdit ? "edit-limit-monthly" : "limit-monthly"}
+                type="number"
+                value={limitMonthlyUsd?.toString() ?? ""}
+                onChange={(e) => setLimitMonthlyUsd(validateNumericField(e.target.value))}
+                placeholder="留空表示无限制"
+                disabled={isPending}
+                min="0"
+                step="0.01"
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor={isEdit ? "edit-limit-concurrent" : "limit-concurrent"}>并发 Session 上限</Label>
+              <Input
+                id={isEdit ? "edit-limit-concurrent" : "limit-concurrent"}
+                type="number"
+                value={limitConcurrentSessions?.toString() ?? ""}
+                onChange={(e) => setLimitConcurrentSessions(validateNumericField(e.target.value))}
+                placeholder="0 表示无限制"
+                disabled={isPending}
+                min="0"
+                step="1"
+              />
+            </div>
           </div>
         </div>
 

+ 108 - 105
src/app/settings/providers/_components/hooks/use-provider-edit.ts

@@ -2,11 +2,7 @@ import { useRef, useState } from "react";
 import { toast } from "sonner";
 import { editProvider } from "@/actions/providers";
 import type { ProviderDisplay } from "@/types/provider";
-import {
-  clampWeight,
-  clampIntInRange,
-  clampTpm,
-} from "@/lib/utils/validation";
+import { clampWeight } from "@/lib/utils/validation";
 import { PROVIDER_LIMITS } from "@/lib/constants/provider.constants";
 
 export function useProviderEdit(item: ProviderDisplay, canEdit: boolean) {
@@ -19,38 +15,37 @@ export function useProviderEdit(item: ProviderDisplay, canEdit: boolean) {
   const [weight, setWeight] = useState<number>(clampWeight(item.weight));
   const initialWeightRef = useRef<number>(item.weight);
 
-  // TPM 编辑
-  const [showTpm, setShowTpm] = useState(false);
-  const [tpmInfinite, setTpmInfinite] = useState<boolean>(item.tpm === null);
-  const [tpmValue, setTpmValue] = useState<number>(() => {
-    const base = item.tpm ?? PROVIDER_LIMITS.TPM.MIN;
-    return clampTpm(base);
+  // 5小时消费上限
+  const [show5hLimit, setShow5hLimit] = useState(false);
+  const [limit5hInfinite, setLimit5hInfinite] = useState<boolean>(item.limit5hUsd === null);
+  const [limit5hValue, setLimit5hValue] = useState<number>(() => {
+    return item.limit5hUsd ?? PROVIDER_LIMITS.LIMIT_5H_USD.MIN;
   });
-  const initialTpmRef = useRef<number | null>(item.tpm);
-
-  // RPM 编辑
-  const [showRpm, setShowRpm] = useState(false);
-  const [rpmInfinite, setRpmInfinite] = useState<boolean>(item.rpm === null);
-  const [rpmValue, setRpmValue] = useState<number>(() =>
-    clampIntInRange(item.rpm ?? PROVIDER_LIMITS.RPM.MIN, PROVIDER_LIMITS.RPM.MIN, PROVIDER_LIMITS.RPM.MAX)
-  );
-  const initialRpmRef = useRef<number | null>(item.rpm);
-
-  // RPD 编辑
-  const [showRpd, setShowRpd] = useState(false);
-  const [rpdInfinite, setRpdInfinite] = useState<boolean>(item.rpd === null);
-  const [rpdValue, setRpdValue] = useState<number>(() =>
-    clampIntInRange(item.rpd ?? PROVIDER_LIMITS.RPD.MIN, PROVIDER_LIMITS.RPD.MIN, PROVIDER_LIMITS.RPD.MAX)
-  );
-  const initialRpdRef = useRef<number | null>(item.rpd);
-
-  // CC 编辑
-  const [showCc, setShowCc] = useState(false);
-  const [ccInfinite, setCcInfinite] = useState<boolean>(item.cc === null);
-  const [ccValue, setCcValue] = useState<number>(() =>
-    clampIntInRange(item.cc ?? PROVIDER_LIMITS.CC.MIN, PROVIDER_LIMITS.CC.MIN, PROVIDER_LIMITS.CC.MAX)
-  );
-  const initialCcRef = useRef<number | null>(item.cc);
+  const initial5hRef = useRef<number | null>(item.limit5hUsd);
+
+  // 周消费上限
+  const [showWeeklyLimit, setShowWeeklyLimit] = useState(false);
+  const [limitWeeklyInfinite, setLimitWeeklyInfinite] = useState<boolean>(item.limitWeeklyUsd === null);
+  const [limitWeeklyValue, setLimitWeeklyValue] = useState<number>(() => {
+    return item.limitWeeklyUsd ?? PROVIDER_LIMITS.LIMIT_WEEKLY_USD.MIN;
+  });
+  const initialWeeklyRef = useRef<number | null>(item.limitWeeklyUsd);
+
+  // 月消费上限
+  const [showMonthlyLimit, setShowMonthlyLimit] = useState(false);
+  const [limitMonthlyInfinite, setLimitMonthlyInfinite] = useState<boolean>(item.limitMonthlyUsd === null);
+  const [limitMonthlyValue, setLimitMonthlyValue] = useState<number>(() => {
+    return item.limitMonthlyUsd ?? PROVIDER_LIMITS.LIMIT_MONTHLY_USD.MIN;
+  });
+  const initialMonthlyRef = useRef<number | null>(item.limitMonthlyUsd);
+
+  // 并发Session上限
+  const [showConcurrent, setShowConcurrent] = useState(false);
+  const [concurrentInfinite, setConcurrentInfinite] = useState<boolean>(item.limitConcurrentSessions === 0);
+  const [concurrentValue, setConcurrentValue] = useState<number>(() => {
+    return item.limitConcurrentSessions === 0 ? PROVIDER_LIMITS.CONCURRENT_SESSIONS.MIN : item.limitConcurrentSessions;
+  });
+  const initialConcurrentRef = useRef<number>(item.limitConcurrentSessions);
 
   // 切换启用状态
   const handleToggle = async (next: boolean) => {
@@ -96,94 +91,94 @@ export function useProviderEdit(item: ProviderDisplay, canEdit: boolean) {
     }
   };
 
-  // TPM 编辑处理
-  const handleTpmPopover = (open: boolean) => {
+  // 5小时消费上限编辑处理
+  const handle5hLimitPopover = (open: boolean) => {
     if (!canEdit) return;
-    setShowTpm(open);
+    setShow5hLimit(open);
     if (open) {
-      initialTpmRef.current = item.tpm;
+      initial5hRef.current = item.limit5hUsd;
       return;
     }
 
-    const nextValue = tpmInfinite ? null : clampTpm(tpmValue);
-    if (nextValue !== initialTpmRef.current) {
-      editProvider(item.id, { tpm: nextValue }).then(res => {
+    const nextValue = limit5hInfinite ? null : Math.max(PROVIDER_LIMITS.LIMIT_5H_USD.MIN, limit5hValue);
+    if (nextValue !== initial5hRef.current) {
+      editProvider(item.id, { limit_5h_usd: nextValue }).then(res => {
         if (!res.ok) throw new Error(res.error);
       }).catch((e) => {
-        console.error("更新TPM失败", e);
-        const msg = e instanceof Error ? e.message : '更新TPM失败';
+        console.error("更新5小时消费上限失败", e);
+        const msg = e instanceof Error ? e.message : '更新5小时消费上限失败';
         toast.error(msg);
-        setTpmInfinite(initialTpmRef.current === null);
-        setTpmValue(clampTpm(initialTpmRef.current ?? PROVIDER_LIMITS.TPM.MIN));
+        setLimit5hInfinite(initial5hRef.current === null);
+        setLimit5hValue(initial5hRef.current ?? PROVIDER_LIMITS.LIMIT_5H_USD.MIN);
       });
     }
   };
 
-  // RPM 编辑处理
-  const handleRpmPopover = (open: boolean) => {
+  // 周消费上限编辑处理
+  const handleWeeklyLimitPopover = (open: boolean) => {
     if (!canEdit) return;
-    setShowRpm(open);
+    setShowWeeklyLimit(open);
     if (open) {
-      initialRpmRef.current = item.rpm;
+      initialWeeklyRef.current = item.limitWeeklyUsd;
       return;
     }
 
-    const nextValue = rpmInfinite ? null : clampIntInRange(rpmValue, PROVIDER_LIMITS.RPM.MIN, PROVIDER_LIMITS.RPM.MAX);
-    if (nextValue !== initialRpmRef.current) {
-      editProvider(item.id, { rpm: nextValue }).then(res => {
+    const nextValue = limitWeeklyInfinite ? null : Math.max(PROVIDER_LIMITS.LIMIT_WEEKLY_USD.MIN, limitWeeklyValue);
+    if (nextValue !== initialWeeklyRef.current) {
+      editProvider(item.id, { limit_weekly_usd: nextValue }).then(res => {
         if (!res.ok) throw new Error(res.error);
       }).catch((e) => {
-        console.error("更新RPM失败", e);
-        const msg = e instanceof Error ? e.message : '更新RPM失败';
+        console.error("更新周消费上限失败", e);
+        const msg = e instanceof Error ? e.message : '更新周消费上限失败';
         toast.error(msg);
-        setRpmInfinite(initialRpmRef.current === null);
-        setRpmValue(clampIntInRange(initialRpmRef.current ?? PROVIDER_LIMITS.RPM.MIN, PROVIDER_LIMITS.RPM.MIN, PROVIDER_LIMITS.RPM.MAX));
+        setLimitWeeklyInfinite(initialWeeklyRef.current === null);
+        setLimitWeeklyValue(initialWeeklyRef.current ?? PROVIDER_LIMITS.LIMIT_WEEKLY_USD.MIN);
       });
     }
   };
 
-  // RPD 编辑处理
-  const handleRpdPopover = (open: boolean) => {
+  // 月消费上限编辑处理
+  const handleMonthlyLimitPopover = (open: boolean) => {
     if (!canEdit) return;
-    setShowRpd(open);
+    setShowMonthlyLimit(open);
     if (open) {
-      initialRpdRef.current = item.rpd;
+      initialMonthlyRef.current = item.limitMonthlyUsd;
       return;
     }
 
-    const nextValue = rpdInfinite ? null : clampIntInRange(rpdValue, PROVIDER_LIMITS.RPD.MIN, PROVIDER_LIMITS.RPD.MAX);
-    if (nextValue !== initialRpdRef.current) {
-      editProvider(item.id, { rpd: nextValue }).then(res => {
+    const nextValue = limitMonthlyInfinite ? null : Math.max(PROVIDER_LIMITS.LIMIT_MONTHLY_USD.MIN, limitMonthlyValue);
+    if (nextValue !== initialMonthlyRef.current) {
+      editProvider(item.id, { limit_monthly_usd: nextValue }).then(res => {
         if (!res.ok) throw new Error(res.error);
       }).catch((e) => {
-        console.error("更新RPD失败", e);
-        const msg = e instanceof Error ? e.message : '更新RPD失败';
+        console.error("更新月消费上限失败", e);
+        const msg = e instanceof Error ? e.message : '更新月消费上限失败';
         toast.error(msg);
-        setRpdInfinite(initialRpdRef.current === null);
-        setRpdValue(clampIntInRange(initialRpdRef.current ?? PROVIDER_LIMITS.RPD.MIN, PROVIDER_LIMITS.RPD.MIN, PROVIDER_LIMITS.RPD.MAX));
+        setLimitMonthlyInfinite(initialMonthlyRef.current === null);
+        setLimitMonthlyValue(initialMonthlyRef.current ?? PROVIDER_LIMITS.LIMIT_MONTHLY_USD.MIN);
       });
     }
   };
 
-  // CC 编辑处理
-  const handleCcPopover = (open: boolean) => {
+  // 并发Session上限编辑处理
+  const handleConcurrentPopover = (open: boolean) => {
     if (!canEdit) return;
-    setShowCc(open);
+    setShowConcurrent(open);
     if (open) {
-      initialCcRef.current = item.cc;
+      initialConcurrentRef.current = item.limitConcurrentSessions;
       return;
     }
 
-    const nextValue = ccInfinite ? null : clampIntInRange(ccValue, PROVIDER_LIMITS.CC.MIN, PROVIDER_LIMITS.CC.MAX);
-    if (nextValue !== initialCcRef.current) {
-      editProvider(item.id, { cc: nextValue }).then(res => {
+    const nextValue = concurrentInfinite ? 0 : Math.max(PROVIDER_LIMITS.CONCURRENT_SESSIONS.MIN, concurrentValue);
+    if (nextValue !== initialConcurrentRef.current) {
+      editProvider(item.id, { limit_concurrent_sessions: nextValue }).then(res => {
         if (!res.ok) throw new Error(res.error);
       }).catch((e) => {
-        console.error("更新CC失败", e);
-        const msg = e instanceof Error ? e.message : '更新CC失败';
+        console.error("更新并发Session上限失败", e);
+        const msg = e instanceof Error ? e.message : '更新并发Session上限失败';
         toast.error(msg);
-        setCcInfinite(initialCcRef.current === null);
-        setCcValue(clampIntInRange(initialCcRef.current ?? PROVIDER_LIMITS.CC.MIN, PROVIDER_LIMITS.CC.MIN, PROVIDER_LIMITS.CC.MAX));
+        setConcurrentInfinite(initialConcurrentRef.current === 0);
+        setConcurrentValue(initialConcurrentRef.current === 0 ? PROVIDER_LIMITS.CONCURRENT_SESSIONS.MIN : initialConcurrentRef.current);
       });
     }
   };
@@ -195,33 +190,41 @@ export function useProviderEdit(item: ProviderDisplay, canEdit: boolean) {
     weight,
     setWeight,
     showWeight,
-    tpmInfinite,
-    setTpmInfinite,
-    tpmValue,
-    setTpmValue,
-    showTpm,
-    rpmInfinite,
-    setRpmInfinite,
-    rpmValue,
-    setRpmValue,
-    showRpm,
-    rpdInfinite,
-    setRpdInfinite,
-    rpdValue,
-    setRpdValue,
-    showRpd,
-    ccInfinite,
-    setCcInfinite,
-    ccValue,
-    setCcValue,
-    showCc,
+
+    // 5小时消费上限
+    limit5hInfinite,
+    setLimit5hInfinite,
+    limit5hValue,
+    setLimit5hValue,
+    show5hLimit,
+
+    // 周消费上限
+    limitWeeklyInfinite,
+    setLimitWeeklyInfinite,
+    limitWeeklyValue,
+    setLimitWeeklyValue,
+    showWeeklyLimit,
+
+    // 月消费上限
+    limitMonthlyInfinite,
+    setLimitMonthlyInfinite,
+    limitMonthlyValue,
+    setLimitMonthlyValue,
+    showMonthlyLimit,
+
+    // 并发Session上限
+    concurrentInfinite,
+    setConcurrentInfinite,
+    concurrentValue,
+    setConcurrentValue,
+    showConcurrent,
 
     // 处理函数
     handleToggle,
     handleWeightPopover,
-    handleTpmPopover,
-    handleRpmPopover,
-    handleRpdPopover,
-    handleCcPopover,
+    handle5hLimitPopover,
+    handleWeeklyLimitPopover,
+    handleMonthlyLimitPopover,
+    handleConcurrentPopover,
   };
-}
+}

+ 134 - 104
src/app/settings/providers/_components/provider-list-item.tsx

@@ -9,7 +9,6 @@ import { ProviderForm } from "./forms/provider-form";
 import { Switch } from "@/components/ui/switch";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
 import { Slider } from "@/components/ui/slider";
-import { formatTpmDisplay } from "@/lib/utils/validation";
 import { PROVIDER_LIMITS } from "@/lib/constants/provider.constants";
 import { FormErrorBoundary } from "@/components/form-error-boundary";
 import { useProviderEdit } from "./hooks/use-provider-edit";
@@ -29,32 +28,32 @@ export function ProviderListItem({ item, currentUser }: ProviderListItemProps) {
     weight,
     setWeight,
     showWeight,
-    tpmInfinite,
-    setTpmInfinite,
-    tpmValue,
-    setTpmValue,
-    showTpm,
-    rpmInfinite,
-    setRpmInfinite,
-    rpmValue,
-    setRpmValue,
-    showRpm,
-    rpdInfinite,
-    setRpdInfinite,
-    rpdValue,
-    setRpdValue,
-    showRpd,
-    ccInfinite,
-    setCcInfinite,
-    ccValue,
-    setCcValue,
-    showCc,
+    limit5hInfinite,
+    setLimit5hInfinite,
+    limit5hValue,
+    setLimit5hValue,
+    show5hLimit,
+    limitWeeklyInfinite,
+    setLimitWeeklyInfinite,
+    limitWeeklyValue,
+    setLimitWeeklyValue,
+    showWeeklyLimit,
+    limitMonthlyInfinite,
+    setLimitMonthlyInfinite,
+    limitMonthlyValue,
+    setLimitMonthlyValue,
+    showMonthlyLimit,
+    concurrentInfinite,
+    setConcurrentInfinite,
+    concurrentValue,
+    setConcurrentValue,
+    showConcurrent,
     handleToggle,
     handleWeightPopover,
-    handleTpmPopover,
-    handleRpmPopover,
-    handleRpdPopover,
-    handleCcPopover,
+    handle5hLimitPopover,
+    handleWeeklyLimitPopover,
+    handleMonthlyLimitPopover,
+    handleConcurrentPopover,
   } = useProviderEdit(item, canEdit);
 
   return (
@@ -116,181 +115,212 @@ export function ProviderListItem({ item, currentUser }: ProviderListItemProps) {
           </div>
         </div>
 
-        {/* 下:5 个配置项(每个 20% 宽度)改为文本样式 */}
-        <div className="grid grid-cols-5 gap-2 text-[11px]">
+        {/* 路由配置 */}
+        <div className="grid grid-cols-4 gap-2 text-[11px] pb-2 border-b border-border/40">
+          {/* 优先级 */}
           <div className="min-w-0 text-center">
-            <div className="text-muted-foreground">TPM</div>
+            <div className="text-muted-foreground">优先级</div>
+            <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
+              <span>{item.priority}</span>
+            </div>
+          </div>
+
+          {/* 权重 */}
+          <div className="min-w-0 text-center">
+            <div className="text-muted-foreground">权重</div>
             {canEdit ? (
-              <Popover open={showTpm} onOpenChange={handleTpmPopover}>
+              <Popover open={showWeight} onOpenChange={handleWeightPopover}>
                 <PopoverTrigger asChild>
-                  <button type="button" className="w-full text-center font-medium tabular-nums truncate text-foreground hover:text-primary/80 transition-colors cursor-pointer">
-                    <span>{formatTpmDisplay(tpmValue, tpmInfinite)}</span>
+                  <button
+                    type="button"
+                    aria-label="编辑权重"
+                    className="w-full text-center font-medium tabular-nums truncate text-foreground cursor-pointer hover:text-primary/80 transition-colors"
+                  >
+                    <span>{weight}</span>
                   </button>
                 </PopoverTrigger>
-                <PopoverContent align="center" side="bottom" sideOffset={6} className="w-72 p-3">
-                  <div className="mb-2 flex items-center justify-between text-[11px]">
-                    <span className="text-muted-foreground">TPM(令牌/分)</span>
-                    <div className="flex items-center gap-2 text-muted-foreground">
-                      <span>无限</span>
-                      <Switch checked={tpmInfinite} onCheckedChange={setTpmInfinite} aria-label="TPM无限" />
-                    </div>
-                  </div>
-                  <div className="flex items-center gap-3">
-                    <Slider
-                      min={PROVIDER_LIMITS.TPM.MIN / 1000}
-                      max={PROVIDER_LIMITS.TPM.MAX / 1000}
-                      step={1}
-                      value={[Math.round(tpmValue / 1000)]}
-                      onValueChange={(v) => !tpmInfinite && setTpmValue(Math.round((v?.[0] ?? PROVIDER_LIMITS.TPM.MIN / 1000) * 1000))}
-                      disabled={tpmInfinite}
-                    />
-                    <span className="w-12 text-right text-xs font-medium">{formatTpmDisplay(tpmValue, tpmInfinite)}</span>
+                <PopoverContent align="center" side="bottom" sideOffset={6} className="w-64 p-3">
+                  <div className="mb-2 flex items-center justify-between text-[11px] text-muted-foreground">
+                    <span>调整权重</span>
+                    <span className="font-medium text-foreground">{weight}</span>
                   </div>
+                  <Slider min={PROVIDER_LIMITS.WEIGHT.MIN} max={PROVIDER_LIMITS.WEIGHT.MAX} step={1} value={[weight]} onValueChange={(v) => setWeight(v?.[0] ?? PROVIDER_LIMITS.WEIGHT.MIN)} />
                 </PopoverContent>
               </Popover>
             ) : (
               <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-                <span>{formatTpmDisplay(tpmValue, tpmInfinite)}</span>
+                <span>{weight}</span>
               </div>
             )}
           </div>
 
+          {/* 成本 */}
           <div className="min-w-0 text-center">
-            <div className="text-muted-foreground">RPM</div>
+            <div className="text-muted-foreground">成本/M</div>
+            <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
+              <span>{item.costPerMtok ? `$${item.costPerMtok.toFixed(4)}` : '-'}</span>
+            </div>
+          </div>
+
+          {/* 分组 */}
+          <div className="min-w-0 text-center">
+            <div className="text-muted-foreground">分组</div>
+            <div className="w-full text-center font-medium truncate text-foreground">
+              <span>{item.groupTag || '-'}</span>
+            </div>
+          </div>
+        </div>
+
+        {/* 限流配置 */}
+        <div className="grid grid-cols-4 gap-2 text-[11px]">
+          {/* 5小时消费上限 */}
+          <div className="min-w-0 text-center">
+            <div className="text-muted-foreground">5h USD</div>
             {canEdit ? (
-              <Popover open={showRpm} onOpenChange={handleRpmPopover}>
+              <Popover open={show5hLimit} onOpenChange={handle5hLimitPopover}>
                 <PopoverTrigger asChild>
                   <button type="button" className="w-full text-center font-medium tabular-nums truncate text-foreground hover:text-primary/80 transition-colors cursor-pointer">
-                    <span>{rpmInfinite ? "∞" : rpmValue.toLocaleString()}</span>
+                    <span>{limit5hInfinite ? "∞" : `$${limit5hValue.toFixed(2)}`}</span>
                   </button>
                 </PopoverTrigger>
                 <PopoverContent align="center" side="bottom" sideOffset={6} className="w-72 p-3">
                   <div className="mb-2 flex items-center justify-between text-[11px]">
-                    <span className="text-muted-foreground">RPM(请求/分)</span>
+                    <span className="text-muted-foreground">5小时消费上限 (USD)</span>
                     <div className="flex items-center gap-2 text-muted-foreground">
                       <span>无限</span>
-                      <Switch checked={rpmInfinite} onCheckedChange={setRpmInfinite} aria-label="RPM无限" />
+                      <Switch checked={limit5hInfinite} onCheckedChange={setLimit5hInfinite} aria-label="无限" />
                     </div>
                   </div>
                   <div className="flex items-center gap-3">
                     <Slider
-                      min={PROVIDER_LIMITS.RPM.MIN}
-                      max={PROVIDER_LIMITS.RPM.MAX}
-                      step={1}
-                      value={[rpmInfinite ? PROVIDER_LIMITS.RPM.MIN : rpmValue]}
-                      onValueChange={(v) => !rpmInfinite && setRpmValue(v?.[0] ?? PROVIDER_LIMITS.RPM.MIN)}
-                      disabled={rpmInfinite}
+                      min={PROVIDER_LIMITS.LIMIT_5H_USD.MIN}
+                      max={PROVIDER_LIMITS.LIMIT_5H_USD.MAX}
+                      step={PROVIDER_LIMITS.LIMIT_5H_USD.STEP}
+                      value={[limit5hValue]}
+                      onValueChange={(v) => !limit5hInfinite && setLimit5hValue(v?.[0] ?? PROVIDER_LIMITS.LIMIT_5H_USD.MIN)}
+                      disabled={limit5hInfinite}
                     />
-                    <span className="w-12 text-right text-xs font-medium">{rpmInfinite ? "∞" : rpmValue.toLocaleString()}</span>
+                    <span className="w-16 text-right text-xs font-medium">{limit5hInfinite ? "∞" : `$${limit5hValue.toFixed(2)}`}</span>
                   </div>
                 </PopoverContent>
               </Popover>
             ) : (
               <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-                <span>{rpmInfinite ? "∞" : rpmValue.toLocaleString()}</span>
+                <span>{limit5hInfinite ? "∞" : `$${limit5hValue.toFixed(2)}`}</span>
               </div>
             )}
           </div>
 
+          {/* 周消费上限 */}
           <div className="min-w-0 text-center">
-            <div className="text-muted-foreground">RPD</div>
+            <div className="text-muted-foreground">Week USD</div>
             {canEdit ? (
-              <Popover open={showRpd} onOpenChange={handleRpdPopover}>
+              <Popover open={showWeeklyLimit} onOpenChange={handleWeeklyLimitPopover}>
                 <PopoverTrigger asChild>
                   <button type="button" className="w-full text-center font-medium tabular-nums truncate text-foreground hover:text-primary/80 transition-colors cursor-pointer">
-                    <span>{rpdInfinite ? "∞" : rpdValue.toLocaleString()}</span>
+                    <span>{limitWeeklyInfinite ? "∞" : `$${limitWeeklyValue.toFixed(2)}`}</span>
                   </button>
                 </PopoverTrigger>
                 <PopoverContent align="center" side="bottom" sideOffset={6} className="w-72 p-3">
                   <div className="mb-2 flex items-center justify-between text-[11px]">
-                    <span className="text-muted-foreground">RPD(请求/日)</span>
+                    <span className="text-muted-foreground">周消费上限 (USD)</span>
                     <div className="flex items-center gap-2 text-muted-foreground">
                       <span>无限</span>
-                      <Switch checked={rpdInfinite} onCheckedChange={setRpdInfinite} aria-label="RPD无限" />
+                      <Switch checked={limitWeeklyInfinite} onCheckedChange={setLimitWeeklyInfinite} aria-label="无限" />
                     </div>
                   </div>
                   <div className="flex items-center gap-3">
                     <Slider
-                      min={PROVIDER_LIMITS.RPD.MIN}
-                      max={PROVIDER_LIMITS.RPD.MAX}
-                      step={1}
-                      value={[rpdInfinite ? PROVIDER_LIMITS.RPD.MIN : rpdValue]}
-                      onValueChange={(v) => !rpdInfinite && setRpdValue(v?.[0] ?? PROVIDER_LIMITS.RPD.MIN)}
-                      disabled={rpdInfinite}
+                      min={PROVIDER_LIMITS.LIMIT_WEEKLY_USD.MIN}
+                      max={PROVIDER_LIMITS.LIMIT_WEEKLY_USD.MAX}
+                      step={PROVIDER_LIMITS.LIMIT_WEEKLY_USD.STEP}
+                      value={[limitWeeklyValue]}
+                      onValueChange={(v) => !limitWeeklyInfinite && setLimitWeeklyValue(v?.[0] ?? PROVIDER_LIMITS.LIMIT_WEEKLY_USD.MIN)}
+                      disabled={limitWeeklyInfinite}
                     />
-                    <span className="w-12 text-right text-xs font-medium">{rpdInfinite ? "∞" : rpdValue.toLocaleString()}</span>
+                    <span className="w-16 text-right text-xs font-medium">{limitWeeklyInfinite ? "∞" : `$${limitWeeklyValue.toFixed(2)}`}</span>
                   </div>
                 </PopoverContent>
               </Popover>
             ) : (
               <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-                <span>{rpdInfinite ? "∞" : rpdValue.toLocaleString()}</span>
+                <span>{limitWeeklyInfinite ? "∞" : `$${limitWeeklyValue.toFixed(2)}`}</span>
               </div>
             )}
           </div>
 
+          {/* 月消费上限 */}
           <div className="min-w-0 text-center">
-            <div className="text-muted-foreground">CC</div>
+            <div className="text-muted-foreground">Mon USD</div>
             {canEdit ? (
-              <Popover open={showCc} onOpenChange={handleCcPopover}>
+              <Popover open={showMonthlyLimit} onOpenChange={handleMonthlyLimitPopover}>
                 <PopoverTrigger asChild>
                   <button type="button" className="w-full text-center font-medium tabular-nums truncate text-foreground hover:text-primary/80 transition-colors cursor-pointer">
-                    <span>{ccInfinite ? "∞" : ccValue.toLocaleString()}</span>
+                    <span>{limitMonthlyInfinite ? "∞" : `$${limitMonthlyValue.toFixed(2)}`}</span>
                   </button>
                 </PopoverTrigger>
                 <PopoverContent align="center" side="bottom" sideOffset={6} className="w-72 p-3">
                   <div className="mb-2 flex items-center justify-between text-[11px]">
-                    <span className="text-muted-foreground">CC(并发)</span>
+                    <span className="text-muted-foreground">月消费上限 (USD)</span>
                     <div className="flex items-center gap-2 text-muted-foreground">
                       <span>无限</span>
-                      <Switch checked={ccInfinite} onCheckedChange={setCcInfinite} aria-label="无限" />
+                      <Switch checked={limitMonthlyInfinite} onCheckedChange={setLimitMonthlyInfinite} aria-label="无限" />
                     </div>
                   </div>
                   <div className="flex items-center gap-3">
                     <Slider
-                      min={PROVIDER_LIMITS.CC.MIN}
-                      max={PROVIDER_LIMITS.CC.MAX}
-                      step={1}
-                      value={[ccInfinite ? PROVIDER_LIMITS.CC.MIN : ccValue]}
-                      onValueChange={(v) => !ccInfinite && setCcValue(v?.[0] ?? PROVIDER_LIMITS.CC.MIN)}
-                      disabled={ccInfinite}
+                      min={PROVIDER_LIMITS.LIMIT_MONTHLY_USD.MIN}
+                      max={PROVIDER_LIMITS.LIMIT_MONTHLY_USD.MAX}
+                      step={PROVIDER_LIMITS.LIMIT_MONTHLY_USD.STEP}
+                      value={[limitMonthlyValue]}
+                      onValueChange={(v) => !limitMonthlyInfinite && setLimitMonthlyValue(v?.[0] ?? PROVIDER_LIMITS.LIMIT_MONTHLY_USD.MIN)}
+                      disabled={limitMonthlyInfinite}
                     />
-                    <span className="w-12 text-right text-xs font-medium">{ccInfinite ? "∞" : ccValue.toLocaleString()}</span>
+                    <span className="w-16 text-right text-xs font-medium">{limitMonthlyInfinite ? "∞" : `$${limitMonthlyValue.toFixed(2)}`}</span>
                   </div>
                 </PopoverContent>
               </Popover>
             ) : (
               <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-                <span>{ccInfinite ? "∞" : ccValue.toLocaleString()}</span>
+                <span>{limitMonthlyInfinite ? "∞" : `$${limitMonthlyValue.toFixed(2)}`}</span>
               </div>
             )}
           </div>
 
+          {/* 并发Session上限 */}
           <div className="min-w-0 text-center">
-            <div className="text-muted-foreground">权重</div>
-            {/* 权重编辑 - 仅管理员可编辑 */}
+            <div className="text-muted-foreground">并发</div>
             {canEdit ? (
-              <Popover open={showWeight} onOpenChange={handleWeightPopover}>
+              <Popover open={showConcurrent} onOpenChange={handleConcurrentPopover}>
                 <PopoverTrigger asChild>
-                  <button
-                    type="button"
-                    aria-label="编辑权重"
-                    className="w-full text-center font-medium tabular-nums truncate text-foreground cursor-pointer hover:text-primary/80 transition-colors"
-                  >
-                    <span>{weight}</span>
+                  <button type="button" className="w-full text-center font-medium tabular-nums truncate text-foreground hover:text-primary/80 transition-colors cursor-pointer">
+                    <span>{concurrentInfinite ? "∞" : concurrentValue.toLocaleString()}</span>
                   </button>
                 </PopoverTrigger>
-                <PopoverContent align="center" side="bottom" sideOffset={6} className="w-64 p-3">
-                  <div className="mb-2 flex items-center justify-between text-[11px] text-muted-foreground">
-                    <span>调整权重</span>
-                    <span className="font-medium text-foreground">{weight}</span>
+                <PopoverContent align="center" side="bottom" sideOffset={6} className="w-72 p-3">
+                  <div className="mb-2 flex items-center justify-between text-[11px]">
+                    <span className="text-muted-foreground">并发Session上限</span>
+                    <div className="flex items-center gap-2 text-muted-foreground">
+                      <span>无限</span>
+                      <Switch checked={concurrentInfinite} onCheckedChange={setConcurrentInfinite} aria-label="无限" />
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-3">
+                    <Slider
+                      min={PROVIDER_LIMITS.CONCURRENT_SESSIONS.MIN}
+                      max={PROVIDER_LIMITS.CONCURRENT_SESSIONS.MAX}
+                      step={1}
+                      value={[concurrentValue]}
+                      onValueChange={(v) => !concurrentInfinite && setConcurrentValue(v?.[0] ?? PROVIDER_LIMITS.CONCURRENT_SESSIONS.MIN)}
+                      disabled={concurrentInfinite}
+                    />
+                    <span className="w-16 text-right text-xs font-medium">{concurrentInfinite ? "∞" : concurrentValue.toLocaleString()}</span>
                   </div>
-                  <Slider min={PROVIDER_LIMITS.WEIGHT.MIN} max={PROVIDER_LIMITS.WEIGHT.MAX} step={1} value={[weight]} onValueChange={(v) => setWeight(v?.[0] ?? PROVIDER_LIMITS.WEIGHT.MIN)} />
                 </PopoverContent>
               </Popover>
             ) : (
               <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-                <span>{weight}</span>
+                <span>{concurrentInfinite ? "∞" : concurrentValue.toLocaleString()}</span>
               </div>
             )}
           </div>

+ 1 - 1
src/app/settings/providers/page.tsx

@@ -22,7 +22,7 @@ export default async function SettingsProvidersPage() {
 
       <Section
         title="服务商管理"
-        description="(TPM/RPM/RPD/CC 功能尚未实现,近期即将更新)"
+        description="配置上游服务商的金额限流和并发限制,留空表示无限制。"
         actions={<AddProviderDialog />}
       >
         <ProviderManager providers={providers} currentUser={session?.user} />

+ 9 - 0
src/app/v1/_lib/proxy-handler.ts

@@ -1,6 +1,7 @@
 import type { Context } from "hono";
 import { ProxySession } from "./proxy/session";
 import { ProxyAuthenticator } from "./proxy/auth-guard";
+import { ProxyRateLimitGuard } from "./proxy/rate-limit-guard";
 import { ProxyProviderResolver } from "./proxy/provider-selector";
 import { ProxyMessageService } from "./proxy/message-service";
 import { ProxyForwarder } from "./proxy/forwarder";
@@ -12,11 +13,19 @@ export async function handleProxyRequest(c: Context): Promise<Response> {
   const session = await ProxySession.fromContext(c);
 
   try {
+    // 1. 认证检查
     const unauthorized = await ProxyAuthenticator.ensure(session);
     if (unauthorized) {
       return unauthorized;
     }
 
+    // 2. 限流检查(新增)
+    const rateLimited = await ProxyRateLimitGuard.ensure(session);
+    if (rateLimited) {
+      return rateLimited;
+    }
+
+    // 3. 供应商选择
     const providerUnavailable = await ProxyProviderResolver.ensure(session);
     if (providerUnavailable) {
       return providerUnavailable;

+ 100 - 5
src/app/v1/_lib/proxy/forwarder.ts

@@ -1,15 +1,77 @@
 import { HeaderProcessor } from "../headers";
 import { buildProxyUrl } from "../url";
+import { recordFailure, recordSuccess } from "@/lib/circuit-breaker";
+import { ProxyProviderResolver } from "./provider-selector";
 import type { ProxySession } from "./session";
 
+const MAX_RETRY_ATTEMPTS = 3;
+
 export class ProxyForwarder {
   static async send(session: ProxySession): Promise<Response> {
     if (!session.provider || !session.authState?.success) {
       throw new Error("代理上下文缺少供应商或鉴权信息");
     }
 
-    const processedHeaders = ProxyForwarder.buildHeaders(session);
-    const proxyUrl = buildProxyUrl(session.provider.url, session.requestUrl);
+    let lastError: Error | null = null;
+    let attemptCount = 0;
+    let currentProvider = session.provider;
+
+    // 智能重试循环
+    while (attemptCount <= MAX_RETRY_ATTEMPTS) {
+      try {
+        const response = await ProxyForwarder.doForward(session, currentProvider);
+
+        // 成功:记录健康状态
+        recordSuccess(currentProvider.id);
+
+        console.debug(`[ProxyForwarder] Request successful with provider ${currentProvider.id} (attempt ${attemptCount + 1})`);
+
+        return response;
+
+      } catch (error) {
+        attemptCount++;
+        lastError = error as Error;
+
+        // 记录失败
+        recordFailure(currentProvider.id, lastError);
+
+        console.warn(
+          `[ProxyForwarder] Provider ${currentProvider.id} failed (attempt ${attemptCount}/${MAX_RETRY_ATTEMPTS + 1}): ${lastError.message}`
+        );
+
+        // 如果还有重试机会,选择新的供应商
+        if (attemptCount <= MAX_RETRY_ATTEMPTS) {
+          const alternativeProvider = await ProxyForwarder.selectAlternative(session, currentProvider.id);
+
+          if (!alternativeProvider) {
+            console.error(`[ProxyForwarder] No alternative provider available, stopping retries`);
+            break;
+          }
+
+          currentProvider = alternativeProvider;
+          session.setProvider(currentProvider);
+
+          console.info(`[ProxyForwarder] Retry ${attemptCount}: Switched to provider ${currentProvider.id}`);
+        }
+      }
+    }
+
+    // 所有重试都失败
+    throw new Error(
+      `All providers failed after ${attemptCount} attempts. Last error: ${lastError?.message || 'Unknown error'}`
+    );
+  }
+
+  /**
+   * 实际转发请求
+   */
+  private static async doForward(session: ProxySession, provider: typeof session.provider): Promise<Response> {
+    if (!provider) {
+      throw new Error("Provider is required");
+    }
+
+    const processedHeaders = ProxyForwarder.buildHeaders(session, provider);
+    const proxyUrl = buildProxyUrl(provider.url, session.requestUrl);
 
     const hasBody = session.method !== "GET" && session.method !== "HEAD";
     const init: RequestInit = {
@@ -20,11 +82,44 @@ export class ProxyForwarder {
 
     (init as Record<string, unknown>).verbose = true;
 
-    return await fetch(proxyUrl, init);
+    const response = await fetch(proxyUrl, init);
+
+    // 检查 HTTP 错误状态(5xx 视为失败,触发重试)
+    if (response.status >= 500) {
+      throw new Error(`Provider returned ${response.status}: ${response.statusText}`);
+    }
+
+    return response;
+  }
+
+  /**
+   * 选择替代供应商(排除已失败的)
+   */
+  private static async selectAlternative(
+    session: ProxySession,
+    excludeProviderId: number
+  ): Promise<typeof session.provider | null> {
+    // 临时清除当前供应商,强制重新选择
+    session.setProvider(null);
+
+    // 使用供应商选择器重新选择(会自动过滤掉熔断的供应商)
+    const result = await ProxyProviderResolver.ensure(session);
+
+    // 如果返回了错误响应,说明没有可用供应商
+    if (result) {
+      return null;
+    }
+
+    // 确保不是同一个供应商
+    if (session.provider?.id === excludeProviderId) {
+      console.warn(`[ProxyForwarder] Provider selector returned the same failed provider ${excludeProviderId}`);
+      return null;
+    }
+
+    return session.provider;
   }
 
-  private static buildHeaders(session: ProxySession): Headers {
-    const provider = session.provider!;
+  private static buildHeaders(session: ProxySession, provider: NonNullable<typeof session.provider>): Headers {
     const outboundKey = provider.key;
 
     const headerProcessor = HeaderProcessor.createForProxy({

+ 131 - 11
src/app/v1/_lib/proxy/provider-selector.ts

@@ -1,6 +1,8 @@
 import type { Provider } from "@/types/provider";
 import { findProviderList, findProviderById } from "@/repository/provider";
 import { findLatestMessageRequestByKey } from "@/repository/message";
+import { RateLimitService } from "@/lib/rate-limit";
+import { isCircuitOpen } from "@/lib/circuit-breaker";
 import { ProxyLogger } from "./logger";
 import { ProxyResponses } from "./responses";
 import type { ProxySession } from "./session";
@@ -10,7 +12,7 @@ export class ProxyProviderResolver {
     session.setProvider(await ProxyProviderResolver.findReusable(session));
 
     if (!session.provider) {
-      session.setProvider(await ProxyProviderResolver.pickRandomProvider());
+      session.setProvider(await ProxyProviderResolver.pickRandomProvider(session));
     }
 
     if (session.provider) {
@@ -47,35 +49,153 @@ export class ProxyProviderResolver {
     return provider;
   }
 
-  private static async pickRandomProvider(): Promise<Provider | null> {
-    const providers = await findProviderList();
-    const enabledProviders = providers.filter((provider) => provider.isEnabled);
+  private static async pickRandomProvider(session?: ProxySession): Promise<Provider | null> {
+    const allProviders = await findProviderList();
+
+    const enabledProviders = allProviders.filter((provider) => provider.isEnabled);
 
     if (enabledProviders.length === 0) {
       return null;
     }
 
-    if (enabledProviders.length === 1) {
-      return enabledProviders[0];
+    // Step 0: 用户分组过滤(如果用户指定了分组)
+    let candidateProviders = enabledProviders;
+    const userGroup = session?.authState?.user?.providerGroup;
+    if (userGroup) {
+      const groupFiltered = enabledProviders.filter(
+        (p) => p.groupTag === userGroup
+      );
+
+      if (groupFiltered.length > 0) {
+        candidateProviders = groupFiltered;
+        console.debug(
+          `[ProviderSelector] User group '${userGroup}' filter: ${groupFiltered.length} providers`
+        );
+      } else {
+        console.warn(
+          `[ProviderSelector] User group '${userGroup}' has no providers, falling back to all`
+        );
+      }
+    }
+
+    // Step 1: 过滤超限供应商(健康度过滤)
+    const healthyProviders = await this.filterByLimits(candidateProviders);
+
+    if (healthyProviders.length === 0) {
+      console.warn('[ProviderSelector] All providers rate limited, falling back to random');
+      // Fail Open:降级到随机选择(让上游拒绝)
+      return this.weightedRandom(candidateProviders);
+    }
+
+    // Step 2: 优先级分层(只选择最高优先级的供应商)
+    const topPriorityProviders = this.selectTopPriority(healthyProviders);
+
+    // Step 3: 成本排序 + 加权选择
+    return this.selectOptimal(topPriorityProviders);
+  }
+
+  /**
+   * 过滤超限供应商
+   */
+  private static async filterByLimits(providers: Provider[]): Promise<Provider[]> {
+    const results = await Promise.all(
+      providers.map(async (p) => {
+        // 0. 检查熔断器状态
+        if (isCircuitOpen(p.id)) {
+          console.debug(`[ProviderSelector] Provider ${p.id} circuit breaker is open`);
+          return null;
+        }
+
+        // 1. 检查金额限制
+        const costCheck = await RateLimitService.checkCostLimits(p.id, 'provider', {
+          limit_5h_usd: p.limit5hUsd,
+          limit_weekly_usd: p.limitWeeklyUsd,
+          limit_monthly_usd: p.limitMonthlyUsd,
+        });
+
+        if (!costCheck.allowed) {
+          console.debug(`[ProviderSelector] Provider ${p.id} cost limit exceeded`);
+          return null;
+        }
+
+        // 2. 检查并发 Session 限制
+        const sessionCheck = await RateLimitService.checkSessionLimit(
+          p.id,
+          'provider',
+          p.limitConcurrentSessions || 0
+        );
+
+        if (!sessionCheck.allowed) {
+          console.debug(`[ProviderSelector] Provider ${p.id} session limit exceeded`);
+          return null;
+        }
+
+        return p;
+      })
+    );
+
+    return results.filter((p): p is Provider => p !== null);
+  }
+
+  /**
+   * 优先级分层:只选择最高优先级的供应商
+   */
+  private static selectTopPriority(providers: Provider[]): Provider[] {
+    if (providers.length === 0) {
+      return [];
+    }
+
+    // 找到最小的优先级值(最高优先级)
+    const minPriority = Math.min(...providers.map(p => p.priority || 0));
+
+    // 只返回该优先级的供应商
+    return providers.filter(p => (p.priority || 0) === minPriority);
+  }
+
+  /**
+   * 成本排序 + 加权选择:在同优先级内,按成本排序后加权随机
+   */
+  private static selectOptimal(providers: Provider[]): Provider {
+    if (providers.length === 0) {
+      throw new Error('No providers available for selection');
     }
 
-    const totalWeight = enabledProviders.reduce((sum, provider) => sum + provider.weight, 0);
+    if (providers.length === 1) {
+      return providers[0];
+    }
+
+    // 按成本排序(便宜的在前,null 成本视为 0)
+    const sorted = [...providers].sort((a, b) => {
+      const costA = a.costPerMtok ?? 0;
+      const costB = b.costPerMtok ?? 0;
+      return costA - costB;
+    });
+
+    // 加权随机选择(复用现有逻辑)
+    return this.weightedRandom(sorted);
+  }
+
+  /**
+   * 加权随机选择
+   */
+  private static weightedRandom(providers: Provider[]): Provider {
+    const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0);
 
     if (totalWeight === 0) {
-      const randomIndex = Math.floor(Math.random() * enabledProviders.length);
-      return enabledProviders[randomIndex];
+      const randomIndex = Math.floor(Math.random() * providers.length);
+      return providers[randomIndex];
     }
 
     const random = Math.random() * totalWeight;
     let cumulativeWeight = 0;
 
-    for (const provider of enabledProviders) {
+    for (const provider of providers) {
       cumulativeWeight += provider.weight;
       if (random < cumulativeWeight) {
         return provider;
       }
     }
 
-    return enabledProviders[enabledProviders.length - 1];
+    return providers[providers.length - 1];
   }
 }

+ 65 - 0
src/app/v1/_lib/proxy/rate-limit-guard.ts

@@ -0,0 +1,65 @@
+import type { ProxySession } from './session';
+import { RateLimitService } from '@/lib/rate-limit';
+import { ProxyResponses } from './responses';
+
+export class ProxyRateLimitGuard {
+  /**
+   * 检查 Key 限流
+   */
+  static async ensure(session: ProxySession): Promise<Response | null> {
+    const key = session.authState?.key;
+    if (!key) return null;
+
+    // 1. 检查金额限制
+    const costCheck = await RateLimitService.checkCostLimits(key.id, 'key', {
+      limit_5h_usd: key.limit5hUsd,
+      limit_weekly_usd: key.limitWeeklyUsd,
+      limit_monthly_usd: key.limitMonthlyUsd,
+    });
+
+    if (!costCheck.allowed) {
+      return this.buildRateLimitResponse(key.id, 'key', costCheck.reason!);
+    }
+
+    // 2. 检查并发 Session 限制
+    const sessionCheck = await RateLimitService.checkSessionLimit(
+      key.id,
+      'key',
+      key.limitConcurrentSessions || 0
+    );
+
+    if (!sessionCheck.allowed) {
+      return this.buildRateLimitResponse(key.id, 'key', sessionCheck.reason!);
+    }
+
+    return null;
+  }
+
+  /**
+   * 构建 429 响应
+   */
+  private static buildRateLimitResponse(
+    id: number,
+    type: 'key' | 'provider',
+    reason: string
+  ): Response {
+    const headers = new Headers({
+      'Content-Type': 'application/json',
+      'X-RateLimit-Type': type,
+      'Retry-After': '3600', // 1 小时后重试
+    });
+
+    return new Response(
+      JSON.stringify({
+        error: {
+          type: 'rate_limit_error',
+          message: reason,
+        },
+      }),
+      {
+        status: 429,
+        headers,
+      }
+    );
+  }
+}

+ 47 - 0
src/app/v1/_lib/proxy/response-handler.ts

@@ -2,6 +2,7 @@ import { updateMessageRequestDuration, updateMessageRequestCost } from "@/reposi
 import { findLatestPriceByModel } from "@/repository/model-price";
 import { parseSSEData } from "@/lib/utils/sse";
 import { calculateRequestCost } from "@/lib/utils/cost-calculation";
+import { RateLimitService } from "@/lib/rate-limit";
 import type { ProxySession } from "./session";
 import { ProxyLogger } from "./logger";
 import { ProxyStatusTracker } from "@/lib/proxy-status-tracker";
@@ -55,6 +56,9 @@ export class ProxyResponseHandler {
         const messageContext = session.messageContext;
         if (usageRecord && usageMetrics && messageContext) {
           await updateRequestCostFromUsage(messageContext.id, session.request.model, usageMetrics);
+
+          // 追踪消费到 Redis(用于限流)
+          await trackCostToRedis(session, usageMetrics);
         }
 
         if (messageContext) {
@@ -127,6 +131,9 @@ export class ProxyResponseHandler {
         }
 
         await updateRequestCostFromUsage(messageContext.id, session.request.model, usageForCost);
+
+        // 追踪消费到 Redis(用于限流)
+        await trackCostToRedis(session, usageForCost);
       } catch (error) {
         console.error("Failed to save SSE content:", error);
       } finally {
@@ -191,3 +198,43 @@ async function updateRequestCostFromUsage(
     }
   }
 }
+
+/**
+ * 追踪消费到 Redis(用于限流)
+ */
+async function trackCostToRedis(
+  session: ProxySession,
+  usage: UsageMetrics | null
+): Promise<void> {
+  if (!usage) return;
+
+  const messageContext = session.messageContext;
+  const provider = session.provider;
+  const key = session.authState?.key;
+
+  if (!messageContext || !provider || !key) return;
+
+  const modelName = session.request.model;
+  if (!modelName) return;
+
+  // 计算成本
+  const priceData = await findLatestPriceByModel(modelName);
+  if (!priceData?.priceData) return;
+
+  const cost = calculateRequestCost(usage, priceData.priceData);
+  if (cost.lte(0)) return;
+
+  // 获取 sessionId(优先使用 conversation_id)
+  const conversationId = typeof session.request.message === 'object' && session.request.message !== null
+    ? (session.request.message as Record<string, unknown>).conversation_id
+    : null;
+  const sessionId = typeof conversationId === 'string' ? conversationId : `msg_${messageContext.id}`;
+
+  // 追踪到 Redis
+  await RateLimitService.trackCost(
+    key.id,
+    provider.id,
+    sessionId,
+    parseFloat(cost.toString())
+  );
+}

+ 1 - 16
src/components/form/form-field.tsx

@@ -118,29 +118,14 @@ export function EmailField(props: FormFieldProps) {
 /**
  * 数字字段组件
  */
-export function NumberField(props: Omit<FormFieldProps, 'value' | 'onChange'> & {
-  value: number | '';
-  onChange: (value: number | '') => void;
+export function NumberField(props: Omit<FormFieldProps, 'type'> & {
   min?: number;
   max?: number;
 }) {
-  const handleChange = (stringValue: string) => {
-    if (stringValue === '') {
-      props.onChange('');
-      return;
-    }
-    const numValue = parseFloat(stringValue);
-    if (!isNaN(numValue)) {
-      props.onChange(numValue);
-    }
-  };
-
   return (
     <FormField
       {...props}
       type="number"
-      value={props.value}
-      onChange={handleChange}
       min={props.min}
       max={props.max}
     />

+ 26 - 2
src/drizzle/schema.ts

@@ -20,6 +20,7 @@ export const users = pgTable('users', {
   role: varchar('role').default('user'),
   rpmLimit: integer('rpm_limit').default(60),
   dailyLimitUsd: numeric('daily_limit_usd', { precision: 10, scale: 2 }).default('100.00'),
+  providerGroup: varchar('provider_group', { length: 50 }),
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
   deletedAt: timestamp('deleted_at', { withTimezone: true }),
@@ -39,6 +40,13 @@ export const keys = pgTable('keys', {
   name: varchar('name').notNull(),
   isEnabled: boolean('is_enabled').default(true),
   expiresAt: timestamp('expires_at'),
+
+  // 新增:金额限流配置
+  limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }),
+  limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }),
+  limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }),
+  limitConcurrentSessions: integer('limit_concurrent_sessions').default(0),
+
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
   deletedAt: timestamp('deleted_at', { withTimezone: true }),
@@ -58,16 +66,32 @@ export const providers = pgTable('providers', {
   key: varchar('key').notNull(),
   isEnabled: boolean('is_enabled').notNull().default(true),
   weight: integer('weight').notNull().default(1),
+
+  // 新增:优先级和分组配置
+  priority: integer('priority').notNull().default(0),
+  costPerMtok: numeric('cost_per_mtok', { precision: 10, scale: 4 }),
+  groupTag: varchar('group_tag', { length: 50 }),
+
+  // 新增:金额限流配置
+  limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }),
+  limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }),
+  limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }),
+  limitConcurrentSessions: integer('limit_concurrent_sessions').default(0),
+
+  // 废弃(保留向后兼容,但不再使用)
   tpm: integer('tpm').default(0),
   rpm: integer('rpm').default(0),
   rpd: integer('rpd').default(0),
   cc: integer('cc').default(0),
+
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
   deletedAt: timestamp('deleted_at', { withTimezone: true }),
 }, (table) => ({
-  // 优化启用状态的服务商查询(按权重排序)
-  providersEnabledWeightIdx: index('idx_providers_enabled_weight').on(table.isEnabled, table.weight).where(sql`${table.deletedAt} IS NULL`),
+  // 优化启用状态的服务商查询(按优先级和权重排序)
+  providersEnabledPriorityIdx: index('idx_providers_enabled_priority').on(table.isEnabled, table.priority, table.weight).where(sql`${table.deletedAt} IS NULL`),
+  // 分组查询优化
+  providersGroupIdx: index('idx_providers_group').on(table.groupTag).where(sql`${table.deletedAt} IS NULL`),
   // 基础索引
   providersCreatedAtIdx: index('idx_providers_created_at').on(table.createdAt),
   providersDeletedAtIdx: index('idx_providers_deleted_at').on(table.deletedAt),

+ 5 - 0
src/lib/auth.ts

@@ -24,6 +24,7 @@ export async function validateKey(keyString: string): Promise<AuthSession | null
       role: 'admin',
       rpm: 0,
       dailyQuota: 0,
+      providerGroup: null,
       createdAt: now,
       updatedAt: now,
     };
@@ -34,6 +35,10 @@ export async function validateKey(keyString: string): Promise<AuthSession | null
       name: 'ADMIN_TOKEN',
       key: keyString,
       isEnabled: true,
+      limit5hUsd: null,
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitConcurrentSessions: 0,
       createdAt: now,
       updatedAt: now,
     };

+ 130 - 0
src/lib/circuit-breaker.ts

@@ -0,0 +1,130 @@
+/**
+ * 简单的熔断器服务(内存实现)
+ *
+ * 状态机:
+ * - Closed(关闭):正常状态,请求通过
+ * - Open(打开):失败次数超过阈值,请求被拒绝
+ * - Half-Open(半开):等待一段时间后,允许少量请求尝试
+ */
+
+interface ProviderHealth {
+  failureCount: number;
+  lastFailureTime: number | null;
+  circuitState: 'closed' | 'open' | 'half-open';
+  circuitOpenUntil: number | null;
+  halfOpenSuccessCount: number;
+}
+
+// 配置参数
+const CIRCUIT_BREAKER_CONFIG = {
+  failureThreshold: 5,        // 失败 5 次后打开熔断器
+  openDuration: 60 * 1000,    // 熔断器打开 60 秒
+  halfOpenSuccessThreshold: 2 // 半开状态下成功 2 次后关闭
+};
+
+// 内存存储
+const healthMap = new Map<number, ProviderHealth>();
+
+function getOrCreateHealth(providerId: number): ProviderHealth {
+  let health = healthMap.get(providerId);
+  if (!health) {
+    health = {
+      failureCount: 0,
+      lastFailureTime: null,
+      circuitState: 'closed',
+      circuitOpenUntil: null,
+      halfOpenSuccessCount: 0
+    };
+    healthMap.set(providerId, health);
+  }
+  return health;
+}
+
+/**
+ * 检查熔断器是否打开(不允许请求)
+ */
+export function isCircuitOpen(providerId: number): boolean {
+  const health = getOrCreateHealth(providerId);
+
+  if (health.circuitState === 'closed') {
+    return false;
+  }
+
+  if (health.circuitState === 'open') {
+    // 检查是否可以转为半开状态
+    if (health.circuitOpenUntil && Date.now() > health.circuitOpenUntil) {
+      health.circuitState = 'half-open';
+      health.halfOpenSuccessCount = 0;
+      console.info(`[CircuitBreaker] Provider ${providerId} transitioned to half-open`);
+      return false; // 允许尝试
+    }
+    return true; // 仍在打开状态
+  }
+
+  // half-open 状态:允许尝试
+  return false;
+}
+
+/**
+ * 记录请求失败
+ */
+export function recordFailure(providerId: number, error: Error): void {
+  const health = getOrCreateHealth(providerId);
+
+  health.failureCount++;
+  health.lastFailureTime = Date.now();
+
+  console.warn(`[CircuitBreaker] Provider ${providerId} failure recorded (${health.failureCount}/${CIRCUIT_BREAKER_CONFIG.failureThreshold}): ${error.message}`);
+
+  // 检查是否需要打开熔断器
+  if (health.failureCount >= CIRCUIT_BREAKER_CONFIG.failureThreshold) {
+    health.circuitState = 'open';
+    health.circuitOpenUntil = Date.now() + CIRCUIT_BREAKER_CONFIG.openDuration;
+    health.halfOpenSuccessCount = 0;
+
+    console.error(`[CircuitBreaker] Provider ${providerId} circuit opened after ${health.failureCount} failures, will retry at ${new Date(health.circuitOpenUntil).toISOString()}`);
+  }
+}
+
+/**
+ * 记录请求成功
+ */
+export function recordSuccess(providerId: number): void {
+  const health = getOrCreateHealth(providerId);
+
+  if (health.circuitState === 'half-open') {
+    // 半开状态下成功
+    health.halfOpenSuccessCount++;
+
+    if (health.halfOpenSuccessCount >= CIRCUIT_BREAKER_CONFIG.halfOpenSuccessThreshold) {
+      // 关闭熔断器
+      health.circuitState = 'closed';
+      health.failureCount = 0;
+      health.lastFailureTime = null;
+      health.circuitOpenUntil = null;
+      health.halfOpenSuccessCount = 0;
+
+      console.info(`[CircuitBreaker] Provider ${providerId} circuit closed after ${CIRCUIT_BREAKER_CONFIG.halfOpenSuccessThreshold} successes`);
+    } else {
+      console.debug(`[CircuitBreaker] Provider ${providerId} half-open success (${health.halfOpenSuccessCount}/${CIRCUIT_BREAKER_CONFIG.halfOpenSuccessThreshold})`);
+    }
+  } else if (health.circuitState === 'closed') {
+    // 正常状态下成功,重置失败计数
+    if (health.failureCount > 0) {
+      console.debug(`[CircuitBreaker] Provider ${providerId} success, resetting failure count from ${health.failureCount} to 0`);
+      health.failureCount = 0;
+      health.lastFailureTime = null;
+    }
+  }
+}
+
+/**
+ * 获取所有供应商的健康状态(用于监控)
+ */
+export function getAllHealthStatus(): Record<number, ProviderHealth> {
+  const status: Record<number, ProviderHealth> = {};
+  healthMap.forEach((health, providerId) => {
+    status[providerId] = { ...health };
+  });
+  return status;
+}

+ 4 - 8
src/lib/constants/provider.constants.ts

@@ -3,17 +3,13 @@
  */
 export const PROVIDER_LIMITS = {
   WEIGHT: { MIN: 0, MAX: 100 },
-  TPM: { MIN: 10_000, MAX: 3_000_000, STEP: 1000 },
-  RPM: { MIN: 1, MAX: 500 },
-  RPD: { MIN: 1, MAX: 5_000 },
-  CC: { MIN: 1, MAX: 200 },
+  LIMIT_5H_USD: { MIN: 0.01, MAX: 1000, STEP: 0.01 },
+  LIMIT_WEEKLY_USD: { MIN: 0.01, MAX: 10000, STEP: 0.01 },
+  LIMIT_MONTHLY_USD: { MIN: 0.01, MAX: 100000, STEP: 0.01 },
+  CONCURRENT_SESSIONS: { MIN: 1, MAX: 500 },
 } as const;
 
 export const PROVIDER_DEFAULTS = {
   IS_ENABLED: false,
   WEIGHT: 1,
-  TPM: 10_000,
-  RPM: 1,
-  RPD: 1,
-  CC: 1,
 } as const;

+ 1 - 0
src/lib/rate-limit/index.ts

@@ -0,0 +1 @@
+export { RateLimitService } from './service';

+ 174 - 0
src/lib/rate-limit/service.ts

@@ -0,0 +1,174 @@
+import { getRedisClient } from '@/lib/redis';
+
+interface CostLimit {
+  amount: number | null;
+  period: '5h' | 'weekly' | 'monthly';
+  name: string;
+}
+
+export class RateLimitService {
+  private static redis = getRedisClient();
+
+  /**
+   * 检查金额限制(Key 或 Provider)
+   */
+  static async checkCostLimits(
+    id: number,
+    type: 'key' | 'provider',
+    limits: {
+      limit_5h_usd: number | null;
+      limit_weekly_usd: number | null;
+      limit_monthly_usd: number | null;
+    }
+  ): Promise<{ allowed: boolean; reason?: string }> {
+    if (!this.redis) {
+      // Fail Open:Redis 不可用,放行请求
+      return { allowed: true };
+    }
+
+    const costLimits: CostLimit[] = [
+      { amount: limits.limit_5h_usd, period: '5h', name: '5小时' },
+      { amount: limits.limit_weekly_usd, period: 'weekly', name: '周' },
+      { amount: limits.limit_monthly_usd, period: 'monthly', name: '月' },
+    ];
+
+    try {
+      // 使用 Pipeline 批量查询(性能优化)
+      const pipeline = this.redis.pipeline();
+      for (const limit of costLimits) {
+        if (!limit.amount || limit.amount <= 0) continue;
+        pipeline.get(`${type}:${id}:cost_${limit.period}`);
+      }
+
+      const results = await pipeline.exec();
+      if (!results) return { allowed: true };
+
+      let index = 0;
+      for (const limit of costLimits) {
+        if (!limit.amount || limit.amount <= 0) continue;
+
+        const [err, value] = results[index] || [];
+        if (err) {
+          console.error(`[RateLimit] Redis error:`, err);
+          return { allowed: true }; // Fail Open
+        }
+
+        const current = parseFloat((value as string) || '0');
+        if (current >= limit.amount) {
+          return {
+            allowed: false,
+            reason: `${type === 'key' ? 'Key' : '供应商'} ${limit.name}消费上限已达到(${current.toFixed(4)}/${limit.amount})`,
+          };
+        }
+
+        index++;
+      }
+
+      return { allowed: true };
+    } catch (error) {
+      console.error('[RateLimit] Check failed:', error);
+      return { allowed: true }; // Fail Open
+    }
+  }
+
+  /**
+   * 检查并发 Session 限制
+   */
+  static async checkSessionLimit(
+    id: number,
+    type: 'key' | 'provider',
+    limit: number
+  ): Promise<{ allowed: boolean; reason?: string }> {
+    if (!this.redis || limit <= 0) {
+      return { allowed: true };
+    }
+
+    try {
+      const count = await this.redis.scard(`${type}:${id}:active_sessions`);
+      if (count >= limit) {
+        return {
+          allowed: false,
+          reason: `${type === 'key' ? 'Key' : '供应商'}并发 Session 上限已达到(${count}/${limit})`,
+        };
+      }
+
+      return { allowed: true };
+    } catch (error) {
+      console.error('[RateLimit] Session check failed:', error);
+      return { allowed: true }; // Fail Open
+    }
+  }
+
+  /**
+   * 累加消费(请求结束后调用)
+   */
+  static async trackCost(
+    keyId: number,
+    providerId: number,
+    sessionId: string,
+    cost: number
+  ): Promise<void> {
+    if (!this.redis || cost <= 0) return;
+
+    try {
+      const pipeline = this.redis.pipeline();
+
+      // 1. 累加 Key 消费
+      pipeline.incrbyfloat(`key:${keyId}:cost_5h`, cost);
+      pipeline.expire(`key:${keyId}:cost_5h`, 5 * 3600); // 5小时
+
+      pipeline.incrbyfloat(`key:${keyId}:cost_weekly`, cost);
+      pipeline.expire(`key:${keyId}:cost_weekly`, 7 * 24 * 3600); // 7天
+
+      pipeline.incrbyfloat(`key:${keyId}:cost_monthly`, cost);
+      pipeline.expire(`key:${keyId}:cost_monthly`, 31 * 24 * 3600); // 31天
+
+      // 2. 累加 Provider 消费
+      pipeline.incrbyfloat(`provider:${providerId}:cost_5h`, cost);
+      pipeline.expire(`provider:${providerId}:cost_5h`, 5 * 3600);
+
+      pipeline.incrbyfloat(`provider:${providerId}:cost_weekly`, cost);
+      pipeline.expire(`provider:${providerId}:cost_weekly`, 7 * 24 * 3600);
+
+      pipeline.incrbyfloat(`provider:${providerId}:cost_monthly`, cost);
+      pipeline.expire(`provider:${providerId}:cost_monthly`, 31 * 24 * 3600);
+
+      // 3. 追踪 Session
+      const ttl = parseInt(process.env.SESSION_TTL || '300');
+
+      pipeline.sadd(`key:${keyId}:active_sessions`, sessionId);
+      pipeline.expire(`key:${keyId}:active_sessions`, ttl);
+
+      pipeline.sadd(`provider:${providerId}:active_sessions`, sessionId);
+      pipeline.expire(`provider:${providerId}:active_sessions`, ttl);
+
+      pipeline.setex(`session:${sessionId}:last_seen`, ttl, Date.now().toString());
+      pipeline.setex(`session:${sessionId}:key`, ttl, keyId.toString());
+      pipeline.setex(`session:${sessionId}:provider`, ttl, providerId.toString());
+
+      await pipeline.exec();
+    } catch (error) {
+      console.error('[RateLimit] Track cost failed:', error);
+      // 不抛出错误,静默失败
+    }
+  }
+
+  /**
+   * 获取当前消费(用于响应头)
+   */
+  static async getCurrentCost(
+    id: number,
+    type: 'key' | 'provider',
+    period: '5h' | 'weekly' | 'monthly'
+  ): Promise<number> {
+    if (!this.redis) return 0;
+
+    try {
+      const value = await this.redis.get(`${type}:${id}:cost_${period}`);
+      return parseFloat(value || '0');
+    } catch (error) {
+      console.error('[RateLimit] Get cost failed:', error);
+      return 0;
+    }
+  }
+}

+ 57 - 0
src/lib/redis/client.ts

@@ -0,0 +1,57 @@
+import Redis from 'ioredis';
+
+let redisClient: Redis | null = null;
+
+export function getRedisClient(): Redis | null {
+  const redisUrl = process.env.REDIS_URL;
+  const isEnabled = process.env.ENABLE_RATE_LIMIT === 'true';
+
+  if (!isEnabled || !redisUrl) {
+    console.warn('[Redis] Rate limiting disabled or REDIS_URL not configured');
+    return null;
+  }
+
+  if (redisClient) {
+    return redisClient;
+  }
+
+  try {
+    redisClient = new Redis(redisUrl, {
+      enableOfflineQueue: false, // 快速失败
+      maxRetriesPerRequest: 3,
+      retryStrategy(times) {
+        if (times > 5) {
+          console.error('[Redis] Max retries reached, giving up');
+          return null; // 停止重试,降级
+        }
+        const delay = Math.min(times * 200, 2000);
+        console.warn(`[Redis] Retry ${times}/5 after ${delay}ms`);
+        return delay;
+      },
+    });
+
+    redisClient.on('connect', () => {
+      console.info('[Redis] Connected successfully');
+    });
+
+    redisClient.on('error', (error) => {
+      console.error('[Redis] Connection error:', error);
+    });
+
+    redisClient.on('close', () => {
+      console.warn('[Redis] Connection closed');
+    });
+
+    return redisClient;
+  } catch (error) {
+    console.error('[Redis] Failed to initialize:', error);
+    return null;
+  }
+}
+
+export async function closeRedis(): Promise<void> {
+  if (redisClient) {
+    await redisClient.quit();
+    redisClient = null;
+  }
+}

+ 1 - 0
src/lib/redis/index.ts

@@ -0,0 +1 @@
+export { getRedisClient, closeRedis } from './client';

+ 9 - 3
src/lib/utils/validation/provider.ts

@@ -30,15 +30,21 @@ export function clampIntInRange(value: number, min: number, max: number): number
 
 /**
  * 限制TPM值并取整到千位
+ * @deprecated TPM 字段已废弃,将在后续版本中移除
  */
 export function clampTpm(value: number): number {
-  const safeValue = Number.isNaN(value) ? PROVIDER_LIMITS.TPM.MIN : value;
-  const rounded = Math.round(safeValue / PROVIDER_LIMITS.TPM.STEP) * PROVIDER_LIMITS.TPM.STEP;
-  return clampIntInRange(rounded, PROVIDER_LIMITS.TPM.MIN, PROVIDER_LIMITS.TPM.MAX);
+  // 临时保留以兼容现有代码
+  const MIN = 1000;
+  const MAX = 10000000;
+  const STEP = 1000;
+  const safeValue = Number.isNaN(value) ? MIN : value;
+  const rounded = Math.round(safeValue / STEP) * STEP;
+  return clampIntInRange(rounded, MIN, MAX);
 }
 
 /**
  * 格式化TPM显示值
+ * @deprecated TPM 字段已废弃,将在后续版本中移除
  */
 export function formatTpmDisplay(value: number, infinite: boolean): string {
   if (infinite) return "∞";

+ 110 - 32
src/lib/validation/schemas.ts

@@ -15,6 +15,7 @@ export const CreateUserSchema = z.object({
       "用户名只能包含字母、数字、下划线和中文",
     ),
   note: z.string().max(200, "备注不能超过200个字符").optional().default(""),
+  providerGroup: z.string().max(50, "供应商分组不能超过50个字符").optional().default(""),
   rpm: z
     .coerce
     .number()
@@ -46,6 +47,7 @@ export const UpdateUserSchema = z.object({
     )
     .optional(),
   note: z.string().max(200, "备注不能超过200个字符").optional(),
+  providerGroup: z.string().max(50, "供应商分组不能超过50个字符").optional(),
   rpm: z
     .coerce
     .number()
@@ -75,6 +77,36 @@ export const KeyFormSchema = z.object({
     .optional()
     .default("")
     .transform((val) => (val === "" ? undefined : val)),
+  // 新增:金额限流配置
+  limit5hUsd: z
+    .coerce
+    .number()
+    .min(0, "5小时消费上限不能为负数")
+    .max(10000, "5小时消费上限不能超过10000美元")
+    .nullable()
+    .optional(),
+  limitWeeklyUsd: z
+    .coerce
+    .number()
+    .min(0, "周消费上限不能为负数")
+    .max(50000, "周消费上限不能超过50000美元")
+    .nullable()
+    .optional(),
+  limitMonthlyUsd: z
+    .coerce
+    .number()
+    .min(0, "月消费上限不能为负数")
+    .max(200000, "月消费上限不能超过200000美元")
+    .nullable()
+    .optional(),
+  limitConcurrentSessions: z
+    .coerce
+    .number()
+    .int("并发Session上限必须是整数")
+    .min(0, "并发Session上限不能为负数")
+    .max(1000, "并发Session上限不能超过1000")
+    .optional()
+    .default(0),
 });
 
 /**
@@ -102,34 +134,58 @@ export const CreateProviderSchema = z.object({
     .max(PROVIDER_LIMITS.WEIGHT.MAX)
     .optional()
     .default(PROVIDER_DEFAULTS.WEIGHT),
-  tpm: z
+  priority: z
     .number()
-    .int()
-    .min(PROVIDER_LIMITS.TPM.MIN)
-    .max(PROVIDER_LIMITS.TPM.MAX)
+    .int("优先级必须是整数")
+    .min(0, "优先级不能为负数")
+    .optional()
+    .default(0),
+  cost_per_mtok: z
+    .coerce
+    .number()
+    .min(0, "成本不能为负数")
     .nullable()
     .optional(),
-  rpm: z
+  group_tag: z
+    .string()
+    .max(50, "分组标签不能超过50个字符")
+    .nullable()
+    .optional(),
+  // 新增:金额限流配置
+  limit_5h_usd: z
+    .coerce
     .number()
-    .int()
-    .min(PROVIDER_LIMITS.RPM.MIN)
-    .max(PROVIDER_LIMITS.RPM.MAX)
+    .min(0, "5小时消费上限不能为负数")
+    .max(10000, "5小时消费上限不能超过10000美元")
     .nullable()
     .optional(),
-  rpd: z
+  limit_weekly_usd: z
+    .coerce
     .number()
-    .int()
-    .min(PROVIDER_LIMITS.RPD.MIN)
-    .max(PROVIDER_LIMITS.RPD.MAX)
+    .min(0, "周消费上限不能为负数")
+    .max(50000, "周消费上限不能超过50000美元")
     .nullable()
     .optional(),
-  cc: z
+  limit_monthly_usd: z
+    .coerce
     .number()
-    .int()
-    .min(PROVIDER_LIMITS.CC.MIN)
-    .max(PROVIDER_LIMITS.CC.MAX)
+    .min(0, "月消费上限不能为负数")
+    .max(200000, "月消费上限不能超过200000美元")
     .nullable()
     .optional(),
+  limit_concurrent_sessions: z
+    .coerce
+    .number()
+    .int("并发Session上限必须是整数")
+    .min(0, "并发Session上限不能为负数")
+    .max(1000, "并发Session上限不能超过1000")
+    .optional()
+    .default(0),
+  // 废弃字段(保留向后兼容,不再验证范围)
+  tpm: z.number().int().nullable().optional(),
+  rpm: z.number().int().nullable().optional(),
+  rpd: z.number().int().nullable().optional(),
+  cc: z.number().int().nullable().optional(),
 });
 
 /**
@@ -147,34 +203,56 @@ export const UpdateProviderSchema = z
       .min(PROVIDER_LIMITS.WEIGHT.MIN)
       .max(PROVIDER_LIMITS.WEIGHT.MAX)
       .optional(),
-    tpm: z
+    priority: z
       .number()
-      .int()
-      .min(PROVIDER_LIMITS.TPM.MIN)
-      .max(PROVIDER_LIMITS.TPM.MAX)
+      .int("优先级必须是整数")
+      .min(0, "优先级不能为负数")
+      .optional(),
+    cost_per_mtok: z
+      .coerce
+      .number()
+      .min(0, "成本不能为负数")
+      .nullable()
+      .optional(),
+    group_tag: z
+      .string()
+      .max(50, "分组标签不能超过50个字符")
       .nullable()
       .optional(),
-    rpm: z
+    // 新增:金额限流配置
+    limit_5h_usd: z
+      .coerce
       .number()
-      .int()
-      .min(PROVIDER_LIMITS.RPM.MIN)
-      .max(PROVIDER_LIMITS.RPM.MAX)
+      .min(0, "5小时消费上限不能为负数")
+      .max(10000, "5小时消费上限不能超过10000美元")
       .nullable()
       .optional(),
-    rpd: z
+    limit_weekly_usd: z
+      .coerce
       .number()
-      .int()
-      .min(PROVIDER_LIMITS.RPD.MIN)
-      .max(PROVIDER_LIMITS.RPD.MAX)
+      .min(0, "周消费上限不能为负数")
+      .max(50000, "周消费上限不能超过50000美元")
       .nullable()
       .optional(),
-    cc: z
+    limit_monthly_usd: z
+      .coerce
       .number()
-      .int()
-      .min(PROVIDER_LIMITS.CC.MIN)
-      .max(PROVIDER_LIMITS.CC.MAX)
+      .min(0, "月消费上限不能为负数")
+      .max(200000, "月消费上限不能超过200000美元")
       .nullable()
       .optional(),
+    limit_concurrent_sessions: z
+      .coerce
+      .number()
+      .int("并发Session上限必须是整数")
+      .min(0, "并发Session上限不能为负数")
+      .max(1000, "并发Session上限不能超过1000")
+      .optional(),
+    // 废弃字段(保留向后兼容,不再验证范围)
+    tpm: z.number().int().nullable().optional(),
+    rpm: z.number().int().nullable().optional(),
+    rpd: z.number().int().nullable().optional(),
+    cc: z.number().int().nullable().optional(),
   })
   .refine((obj) => Object.keys(obj).length > 0, { message: "更新内容为空" });
 

+ 13 - 1
src/repository/_shared/transformers.ts

@@ -13,9 +13,10 @@ export function toUser(dbUser: any): User {
     description: dbUser?.description || "",
     role: (dbUser?.role as User["role"]) || "user",
     rpm: dbUser?.rpm || 60,
+    dailyQuota: dbUser?.dailyQuota ? parseFloat(dbUser.dailyQuota) : 0,
+    providerGroup: dbUser?.providerGroup ?? null,
     createdAt: dbUser?.createdAt ? new Date(dbUser.createdAt) : new Date(),
     updatedAt: dbUser?.updatedAt ? new Date(dbUser.updatedAt) : new Date(),
-    dailyQuota: dbUser?.dailyQuota ? parseFloat(dbUser.dailyQuota) : 0,
   };
 }
 
@@ -24,6 +25,10 @@ export function toKey(dbKey: any): Key {
   return {
     ...dbKey,
     isEnabled: dbKey?.isEnabled ?? true,
+    limit5hUsd: dbKey?.limit5hUsd ? parseFloat(dbKey.limit5hUsd) : null,
+    limitWeeklyUsd: dbKey?.limitWeeklyUsd ? parseFloat(dbKey.limitWeeklyUsd) : null,
+    limitMonthlyUsd: dbKey?.limitMonthlyUsd ? parseFloat(dbKey.limitMonthlyUsd) : null,
+    limitConcurrentSessions: dbKey?.limitConcurrentSessions ?? 0,
     createdAt: dbKey?.createdAt ? new Date(dbKey.createdAt) : new Date(),
     updatedAt: dbKey?.updatedAt ? new Date(dbKey.updatedAt) : new Date(),
   };
@@ -35,6 +40,13 @@ export function toProvider(dbProvider: any): Provider {
     ...dbProvider,
     isEnabled: dbProvider?.isEnabled ?? true,
     weight: dbProvider?.weight ?? 1,
+    priority: dbProvider?.priority ?? 0,
+    costPerMtok: dbProvider?.costPerMtok ? parseFloat(dbProvider.costPerMtok) : null,
+    groupTag: dbProvider?.groupTag ?? null,
+    limit5hUsd: dbProvider?.limit5hUsd ? parseFloat(dbProvider.limit5hUsd) : null,
+    limitWeeklyUsd: dbProvider?.limitWeeklyUsd ? parseFloat(dbProvider.limitWeeklyUsd) : null,
+    limitMonthlyUsd: dbProvider?.limitMonthlyUsd ? parseFloat(dbProvider.limitMonthlyUsd) : null,
+    limitConcurrentSessions: dbProvider?.limitConcurrentSessions ?? 0,
     tpm: dbProvider?.tpm ?? null,
     rpm: dbProvider?.rpm ?? null,
     rpd: dbProvider?.rpd ?? null,

+ 40 - 0
src/repository/key.ts

@@ -17,6 +17,10 @@ export async function findKeyById(id: number): Promise<Key | null> {
       name: keys.name,
       isEnabled: keys.isEnabled,
       expiresAt: keys.expiresAt,
+      limit5hUsd: keys.limit5hUsd,
+      limitWeeklyUsd: keys.limitWeeklyUsd,
+      limitMonthlyUsd: keys.limitMonthlyUsd,
+      limitConcurrentSessions: keys.limitConcurrentSessions,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -37,6 +41,10 @@ export async function findKeyList(userId: number): Promise<Key[]> {
       name: keys.name,
       isEnabled: keys.isEnabled,
       expiresAt: keys.expiresAt,
+      limit5hUsd: keys.limit5hUsd,
+      limitWeeklyUsd: keys.limitWeeklyUsd,
+      limitMonthlyUsd: keys.limitMonthlyUsd,
+      limitConcurrentSessions: keys.limitConcurrentSessions,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -55,6 +63,10 @@ export async function createKey(keyData: CreateKeyData): Promise<Key> {
     name: keyData.name,
     isEnabled: keyData.is_enabled,
     expiresAt: keyData.expires_at,
+    limit5hUsd: keyData.limit_5h_usd != null ? keyData.limit_5h_usd.toString() : null,
+    limitWeeklyUsd: keyData.limit_weekly_usd != null ? keyData.limit_weekly_usd.toString() : null,
+    limitMonthlyUsd: keyData.limit_monthly_usd != null ? keyData.limit_monthly_usd.toString() : null,
+    limitConcurrentSessions: keyData.limit_concurrent_sessions,
   };
 
   const [key] = await db.insert(keys).values(dbData).returning({
@@ -64,6 +76,10 @@ export async function createKey(keyData: CreateKeyData): Promise<Key> {
     name: keys.name,
     isEnabled: keys.isEnabled,
     expiresAt: keys.expiresAt,
+    limit5hUsd: keys.limit5hUsd,
+    limitWeeklyUsd: keys.limitWeeklyUsd,
+    limitMonthlyUsd: keys.limitMonthlyUsd,
+    limitConcurrentSessions: keys.limitConcurrentSessions,
     createdAt: keys.createdAt,
     updatedAt: keys.updatedAt,
     deletedAt: keys.deletedAt,
@@ -87,6 +103,10 @@ export async function updateKey(
   if (keyData.name !== undefined) dbData.name = keyData.name;
   if (keyData.is_enabled !== undefined) dbData.isEnabled = keyData.is_enabled;
   if (keyData.expires_at !== undefined) dbData.expiresAt = keyData.expires_at;
+  if (keyData.limit_5h_usd !== undefined) dbData.limit5hUsd = keyData.limit_5h_usd != null ? keyData.limit_5h_usd.toString() : null;
+  if (keyData.limit_weekly_usd !== undefined) dbData.limitWeeklyUsd = keyData.limit_weekly_usd != null ? keyData.limit_weekly_usd.toString() : null;
+  if (keyData.limit_monthly_usd !== undefined) dbData.limitMonthlyUsd = keyData.limit_monthly_usd != null ? keyData.limit_monthly_usd.toString() : null;
+  if (keyData.limit_concurrent_sessions !== undefined) dbData.limitConcurrentSessions = keyData.limit_concurrent_sessions;
 
   const [key] = await db
     .update(keys)
@@ -99,6 +119,10 @@ export async function updateKey(
       name: keys.name,
       isEnabled: keys.isEnabled,
       expiresAt: keys.expiresAt,
+      limit5hUsd: keys.limit5hUsd,
+      limitWeeklyUsd: keys.limitWeeklyUsd,
+      limitMonthlyUsd: keys.limitMonthlyUsd,
+      limitConcurrentSessions: keys.limitConcurrentSessions,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -120,6 +144,10 @@ export async function findActiveKeyByUserIdAndName(
       name: keys.name,
       isEnabled: keys.isEnabled,
       expiresAt: keys.expiresAt,
+      limit5hUsd: keys.limit5hUsd,
+      limitWeeklyUsd: keys.limitWeeklyUsd,
+      limitMonthlyUsd: keys.limitMonthlyUsd,
+      limitConcurrentSessions: keys.limitConcurrentSessions,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -203,6 +231,10 @@ export async function findActiveKeyByKeyString(
       name: keys.name,
       isEnabled: keys.isEnabled,
       expiresAt: keys.expiresAt,
+      limit5hUsd: keys.limit5hUsd,
+      limitWeeklyUsd: keys.limitWeeklyUsd,
+      limitMonthlyUsd: keys.limitMonthlyUsd,
+      limitConcurrentSessions: keys.limitConcurrentSessions,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -232,6 +264,10 @@ export async function validateApiKeyAndGetUser(
       keyName: keys.name,
       keyIsEnabled: keys.isEnabled,
       keyExpiresAt: keys.expiresAt,
+      keyLimit5hUsd: keys.limit5hUsd,
+      keyLimitWeeklyUsd: keys.limitWeeklyUsd,
+      keyLimitMonthlyUsd: keys.limitMonthlyUsd,
+      keyLimitConcurrentSessions: keys.limitConcurrentSessions,
       keyCreatedAt: keys.createdAt,
       keyUpdatedAt: keys.updatedAt,
       keyDeletedAt: keys.deletedAt,
@@ -281,6 +317,10 @@ export async function validateApiKeyAndGetUser(
     name: row.keyName,
     isEnabled: row.keyIsEnabled,
     expiresAt: row.keyExpiresAt,
+    limit5hUsd: row.keyLimit5hUsd,
+    limitWeeklyUsd: row.keyLimitWeeklyUsd,
+    limitMonthlyUsd: row.keyLimitMonthlyUsd,
+    limitConcurrentSessions: row.keyLimitConcurrentSessions,
     createdAt: row.keyCreatedAt,
     updatedAt: row.keyUpdatedAt,
     deletedAt: row.keyDeletedAt,

+ 42 - 0
src/repository/provider.ts

@@ -13,6 +13,13 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     key: providerData.key,
     isEnabled: providerData.is_enabled,
     weight: providerData.weight,
+    priority: providerData.priority,
+    costPerMtok: providerData.cost_per_mtok != null ? providerData.cost_per_mtok.toString() : null,
+    groupTag: providerData.group_tag,
+    limit5hUsd: providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null,
+    limitWeeklyUsd: providerData.limit_weekly_usd != null ? providerData.limit_weekly_usd.toString() : null,
+    limitMonthlyUsd: providerData.limit_monthly_usd != null ? providerData.limit_monthly_usd.toString() : null,
+    limitConcurrentSessions: providerData.limit_concurrent_sessions,
     tpm: providerData.tpm,
     rpm: providerData.rpm,
     rpd: providerData.rpd,
@@ -26,6 +33,13 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     key: providers.key,
     isEnabled: providers.isEnabled,
     weight: providers.weight,
+    priority: providers.priority,
+    costPerMtok: providers.costPerMtok,
+    groupTag: providers.groupTag,
+    limit5hUsd: providers.limit5hUsd,
+    limitWeeklyUsd: providers.limitWeeklyUsd,
+    limitMonthlyUsd: providers.limitMonthlyUsd,
+    limitConcurrentSessions: providers.limitConcurrentSessions,
     tpm: providers.tpm,
     rpm: providers.rpm,
     rpd: providers.rpd,
@@ -47,6 +61,13 @@ export async function findProviderList(limit: number = 50, offset: number = 0):
       key: providers.key,
       isEnabled: providers.isEnabled,
       weight: providers.weight,
+      priority: providers.priority,
+      costPerMtok: providers.costPerMtok,
+      groupTag: providers.groupTag,
+      limit5hUsd: providers.limit5hUsd,
+      limitWeeklyUsd: providers.limitWeeklyUsd,
+      limitMonthlyUsd: providers.limitMonthlyUsd,
+      limitConcurrentSessions: providers.limitConcurrentSessions,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,
@@ -73,6 +94,13 @@ export async function findProviderById(id: number): Promise<Provider | null> {
       key: providers.key,
       isEnabled: providers.isEnabled,
       weight: providers.weight,
+      priority: providers.priority,
+      costPerMtok: providers.costPerMtok,
+      groupTag: providers.groupTag,
+      limit5hUsd: providers.limit5hUsd,
+      limitWeeklyUsd: providers.limitWeeklyUsd,
+      limitMonthlyUsd: providers.limitMonthlyUsd,
+      limitConcurrentSessions: providers.limitConcurrentSessions,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,
@@ -102,6 +130,13 @@ export async function updateProvider(id: number, providerData: UpdateProviderDat
   if (providerData.key !== undefined) dbData.key = providerData.key;
   if (providerData.is_enabled !== undefined) dbData.isEnabled = providerData.is_enabled;
   if (providerData.weight !== undefined) dbData.weight = providerData.weight;
+  if (providerData.priority !== undefined) dbData.priority = providerData.priority;
+  if (providerData.cost_per_mtok !== undefined) dbData.costPerMtok = providerData.cost_per_mtok != null ? providerData.cost_per_mtok.toString() : null;
+  if (providerData.group_tag !== undefined) dbData.groupTag = providerData.group_tag;
+  if (providerData.limit_5h_usd !== undefined) dbData.limit5hUsd = providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null;
+  if (providerData.limit_weekly_usd !== undefined) dbData.limitWeeklyUsd = providerData.limit_weekly_usd != null ? providerData.limit_weekly_usd.toString() : null;
+  if (providerData.limit_monthly_usd !== undefined) dbData.limitMonthlyUsd = providerData.limit_monthly_usd != null ? providerData.limit_monthly_usd.toString() : null;
+  if (providerData.limit_concurrent_sessions !== undefined) dbData.limitConcurrentSessions = providerData.limit_concurrent_sessions;
   if (providerData.tpm !== undefined) dbData.tpm = providerData.tpm;
   if (providerData.rpm !== undefined) dbData.rpm = providerData.rpm;
   if (providerData.rpd !== undefined) dbData.rpd = providerData.rpd;
@@ -118,6 +153,13 @@ export async function updateProvider(id: number, providerData: UpdateProviderDat
       key: providers.key,
       isEnabled: providers.isEnabled,
       weight: providers.weight,
+      priority: providers.priority,
+      costPerMtok: providers.costPerMtok,
+      groupTag: providers.groupTag,
+      limit5hUsd: providers.limit5hUsd,
+      limitWeeklyUsd: providers.limitWeeklyUsd,
+      limitMonthlyUsd: providers.limitMonthlyUsd,
+      limitConcurrentSessions: providers.limitConcurrentSessions,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,

+ 7 - 0
src/repository/user.ts

@@ -12,6 +12,7 @@ export async function createUser(userData: CreateUserData): Promise<User> {
     description: userData.description,
     rpmLimit: userData.rpm,
     dailyLimitUsd: userData.dailyQuota?.toString(),
+    providerGroup: userData.providerGroup,
   };
 
   const [user] = await db.insert(users).values(dbData).returning({
@@ -21,6 +22,7 @@ export async function createUser(userData: CreateUserData): Promise<User> {
     role: users.role,
     rpm: users.rpmLimit,
     dailyQuota: users.dailyLimitUsd,
+    providerGroup: users.providerGroup,
     createdAt: users.createdAt,
     updatedAt: users.updatedAt,
     deletedAt: users.deletedAt,
@@ -41,6 +43,7 @@ export async function findUserList(
       role: users.role,
       rpm: users.rpmLimit,
       dailyQuota: users.dailyLimitUsd,
+      providerGroup: users.providerGroup,
       createdAt: users.createdAt,
       updatedAt: users.updatedAt,
       deletedAt: users.deletedAt,
@@ -66,6 +69,7 @@ export async function findUserById(id: number): Promise<User | null> {
       role: users.role,
       rpm: users.rpmLimit,
       dailyQuota: users.dailyLimitUsd,
+      providerGroup: users.providerGroup,
       createdAt: users.createdAt,
       updatedAt: users.updatedAt,
       deletedAt: users.deletedAt,
@@ -91,6 +95,7 @@ export async function updateUser(
     description?: string;
     rpmLimit?: number;
     dailyLimitUsd?: string;
+    providerGroup?: string | null;
     updatedAt?: Date;
   }
 
@@ -101,6 +106,7 @@ export async function updateUser(
   if (userData.description !== undefined) dbData.description = userData.description;
   if (userData.rpm !== undefined) dbData.rpmLimit = userData.rpm;
   if (userData.dailyQuota !== undefined) dbData.dailyLimitUsd = userData.dailyQuota.toString();
+  if (userData.providerGroup !== undefined) dbData.providerGroup = userData.providerGroup;
 
   const [user] = await db
     .update(users)
@@ -113,6 +119,7 @@ export async function updateUser(
       role: users.role,
       rpm: users.rpmLimit,
       dailyQuota: users.dailyLimitUsd,
+      providerGroup: users.providerGroup,
       createdAt: users.createdAt,
       updatedAt: users.updatedAt,
       deletedAt: users.deletedAt,

+ 17 - 0
src/types/key.ts

@@ -8,6 +8,13 @@ export interface Key {
   key: string;
   isEnabled: boolean;
   expiresAt?: Date;
+
+  // 金额限流配置
+  limit5hUsd: number | null;
+  limitWeeklyUsd: number | null;
+  limitMonthlyUsd: number | null;
+  limitConcurrentSessions: number;
+
   createdAt: Date;
   updatedAt: Date;
   deletedAt?: Date;
@@ -22,6 +29,11 @@ export interface CreateKeyData {
   key: string;
   is_enabled?: boolean;
   expires_at?: Date;
+  // 新增:金额限流配置
+  limit_5h_usd?: number | null;
+  limit_weekly_usd?: number | null;
+  limit_monthly_usd?: number | null;
+  limit_concurrent_sessions?: number;
 }
 
 /**
@@ -31,4 +43,9 @@ export interface UpdateKeyData {
   name?: string;
   is_enabled?: boolean;
   expires_at?: Date;
+  // 新增:金额限流配置
+  limit_5h_usd?: number | null;
+  limit_weekly_usd?: number | null;
+  limit_monthly_usd?: number | null;
+  limit_concurrent_sessions?: number;
 }

+ 50 - 0
src/types/provider.ts

@@ -7,6 +7,19 @@ export interface Provider {
   isEnabled: boolean;
   // 权重(0-100)
   weight: number;
+
+  // 新增:优先级和分组配置
+  priority: number;
+  costPerMtok: number | null;
+  groupTag: string | null;
+
+  // 新增:金额限流配置
+  limit5hUsd: number | null;
+  limitWeeklyUsd: number | null;
+  limitMonthlyUsd: number | null;
+  limitConcurrentSessions: number;
+
+  // 废弃(保留向后兼容,但不再使用)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
   tpm: number | null;
   // RPM (Requests Per Minute): 每分钟可发起的API调用次数
@@ -15,6 +28,7 @@ export interface Provider {
   rpd: number | null;
   // CC (Concurrent Connections/Requests): 同一时刻能同时处理的请求数量
   cc: number | null;
+
   createdAt: Date;
   updatedAt: Date;
   deletedAt?: Date;
@@ -28,6 +42,16 @@ export interface ProviderDisplay {
   maskedKey: string;
   isEnabled: boolean;
   weight: number;
+  // 新增:优先级和分组配置
+  priority: number;
+  costPerMtok: number | null;
+  groupTag: string | null;
+  // 新增:金额限流配置
+  limit5hUsd: number | null;
+  limitWeeklyUsd: number | null;
+  limitMonthlyUsd: number | null;
+  limitConcurrentSessions: number;
+  // 废弃字段(保留向后兼容)
   tpm: number | null;
   rpm: number | null;
   rpd: number | null;
@@ -44,6 +68,19 @@ export interface CreateProviderData {
   is_enabled?: boolean;
   // 权重(默认 1)
   weight?: number;
+
+  // 新增:优先级和分组配置
+  priority?: number;
+  cost_per_mtok?: number | null;
+  group_tag?: string | null;
+
+  // 新增:金额限流配置
+  limit_5h_usd?: number | null;
+  limit_weekly_usd?: number | null;
+  limit_monthly_usd?: number | null;
+  limit_concurrent_sessions?: number;
+
+  // 废弃字段(保留向后兼容)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
   tpm: number | null;
   // RPM (Requests Per Minute): 每分钟可发起的API调用次数
@@ -62,6 +99,19 @@ export interface UpdateProviderData {
   is_enabled?: boolean;
   // 权重(0-100)
   weight?: number;
+
+  // 新增:优先级和分组配置
+  priority?: number;
+  cost_per_mtok?: number | null;
+  group_tag?: string | null;
+
+  // 新增:金额限流配置
+  limit_5h_usd?: number | null;
+  limit_weekly_usd?: number | null;
+  limit_monthly_usd?: number | null;
+  limit_concurrent_sessions?: number;
+
+  // 废弃字段(保留向后兼容)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
   tpm?: number | null;
   // RPM (Requests Per Minute): 每分钟可发起的API调用次数

+ 4 - 0
src/types/user.ts

@@ -8,6 +8,7 @@ export interface User {
   role: "admin" | "user";
   rpm: number; // 每分钟请求数限制
   dailyQuota: number; // 每日额度限制(美元)
+  providerGroup: string | null; // 供应商分组
   createdAt: Date;
   updatedAt: Date;
   deletedAt?: Date;
@@ -21,6 +22,7 @@ export interface CreateUserData {
   description: string;
   rpm?: number; // 可选,有默认值
   dailyQuota?: number; // 可选,有默认值
+  providerGroup?: string | null; // 可选,供应商分组
 }
 
 /**
@@ -31,6 +33,7 @@ export interface UpdateUserData {
   description?: string;
   rpm?: number;
   dailyQuota?: number;
+  providerGroup?: string | null; // 可选,供应商分组
 }
 
 /**
@@ -59,6 +62,7 @@ export interface UserDisplay {
   role: "admin" | "user";
   rpm: number;
   dailyQuota: number;
+  providerGroup?: string | null;
   keys: UserKeyDisplay[];
 }