Переглянути джерело

Merge pull request #500 from ding113/dev

release v0.3.40
Ding 1 місяць тому
батько
коміт
6fa595b898
59 змінених файлів з 4081 додано та 224 видалено
  1. 2 0
      drizzle/0042_legal_harrier.sql
  2. 1989 0
      drizzle/meta/0042_snapshot.json
  3. 7 0
      drizzle/meta/_journal.json
  4. 5 0
      messages/en/dashboard.json
  5. 19 1
      messages/en/settings.json
  6. 13 8
      messages/ja/dashboard.json
  7. 1 0
      messages/ja/errors.json
  8. 19 1
      messages/ja/settings.json
  9. 11 5
      messages/ru/dashboard.json
  10. 1 0
      messages/ru/errors.json
  11. 19 1
      messages/ru/settings.json
  12. 5 0
      messages/zh-CN/dashboard.json
  13. 19 1
      messages/zh-CN/settings.json
  14. 11 9
      messages/zh-TW/dashboard.json
  15. 1 0
      messages/zh-TW/errors.json
  16. 19 1
      messages/zh-TW/settings.json
  17. 33 2
      src/actions/active-sessions.ts
  18. 7 6
      src/actions/error-rules.ts
  19. 334 0
      src/actions/providers.ts
  20. 18 15
      src/actions/users.ts
  21. 9 0
      src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx
  22. 20 0
      src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx
  23. 16 4
      src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx
  24. 16 1
      src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx
  25. 5 5
      src/app/[locale]/dashboard/_components/user/forms/user-form.tsx
  26. 10 0
      src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx
  27. 16 1
      src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  28. 8 2
      src/app/[locale]/dashboard/_components/user/user-management-table.tsx
  29. 2 2
      src/app/[locale]/dashboard/quotas/users/_components/types.ts
  30. 1 1
      src/app/[locale]/dashboard/quotas/users/_components/user-quota-list-item.tsx
  31. 2 2
      src/app/[locale]/dashboard/quotas/users/_components/users-quota-client.tsx
  32. 92 8
      src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx
  33. 38 0
      src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx
  34. 23 0
      src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx
  35. 3 0
      src/app/[locale]/dashboard/users/users-page-client.tsx
  36. 71 4
      src/app/[locale]/settings/providers/_components/forms/provider-form.tsx
  37. 143 38
      src/app/[locale]/settings/providers/_components/model-multi-select.tsx
  38. 10 5
      src/app/v1/_lib/proxy/client-guard.ts
  39. 20 0
      src/app/v1/_lib/proxy/forwarder.ts
  40. 65 61
      src/app/v1/_lib/proxy/rate-limit-guard.ts
  41. 31 8
      src/app/v1/_lib/proxy/session-guard.ts
  42. 2 2
      src/drizzle/schema.ts
  43. 2 1
      src/lib/circuit-breaker.ts
  44. 2 7
      src/lib/constants/user.constants.ts
  45. 18 0
      src/lib/emit-event.ts
  46. 11 2
      src/lib/error-rule-detector.ts
  47. 91 0
      src/lib/redis/__tests__/pubsub.test.ts
  48. 97 0
      src/lib/redis/pubsub.ts
  49. 18 0
      src/lib/request-filter-engine.ts
  50. 240 1
      src/lib/session-manager.ts
  51. 5 7
      src/lib/validation/schemas.ts
  52. 1 0
      src/lib/webhook/renderers/wechat.ts
  53. 238 0
      src/repository/_shared/transformers.test.ts
  54. 10 2
      src/repository/_shared/transformers.ts
  55. 2 1
      src/repository/leaderboard.ts
  56. 3 1
      src/repository/user.ts
  57. 7 7
      src/types/user.ts
  58. 1 1
      tests/e2e/users-keys-complete.test.ts
  59. 199 0
      tests/unit/proxy/client-guard.test.ts

+ 2 - 0
drizzle/0042_legal_harrier.sql

@@ -0,0 +1,2 @@
+ALTER TABLE "users" ALTER COLUMN "rpm_limit" DROP DEFAULT;--> statement-breakpoint
+ALTER TABLE "users" ALTER COLUMN "daily_limit_usd" DROP DEFAULT;

+ 1989 - 0
drizzle/meta/0042_snapshot.json

@@ -0,0 +1,1989 @@
+{
+  "id": "21302171-827d-483a-aa6c-1e9c4084bebc",
+  "prevId": "bcbf2ce2-bc13-49f3-b014-8a34a2bf9a6a",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.error_rules": {
+      "name": "error_rules",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "pattern": {
+          "name": "pattern",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'regex'"
+        },
+        "category": {
+          "name": "category",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_response": {
+          "name": "override_response",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_status_code": {
+          "name": "override_status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_error_rules_enabled": {
+          "name": "idx_error_rules_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "unique_pattern": {
+          "name": "unique_pattern",
+          "columns": [
+            {
+              "expression": "pattern",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_category": {
+          "name": "idx_category",
+          "columns": [
+            {
+              "expression": "category",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_match_type": {
+          "name": "idx_match_type",
+          "columns": [
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "can_login_web_ui": {
+          "name": "can_login_web_ui",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "request_sequence": {
+          "name": "request_sequence",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "endpoint": {
+          "name": "endpoint",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "ttfb_ms": {
+          "name": "ttfb_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_5m_input_tokens": {
+          "name": "cache_creation_5m_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_1h_input_tokens": {
+          "name": "cache_creation_1h_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_applied": {
+          "name": "cache_ttl_applied",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_applied": {
+          "name": "context_1m_applied",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_stack": {
+          "name": "error_stack",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_cause": {
+          "name": "error_cause",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_reason": {
+          "name": "blocked_reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "messages_count": {
+          "name": "messages_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_seq": {
+          "name": "idx_message_request_session_seq",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "request_sequence",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_endpoint": {
+          "name": "idx_message_request_endpoint",
+          "columns": [
+            {
+              "expression": "endpoint",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.notification_settings": {
+      "name": "notification_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_enabled": {
+          "name": "circuit_breaker_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_webhook": {
+          "name": "circuit_breaker_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_enabled": {
+          "name": "daily_leaderboard_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "daily_leaderboard_webhook": {
+          "name": "daily_leaderboard_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_time": {
+          "name": "daily_leaderboard_time",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'09:00'"
+        },
+        "daily_leaderboard_top_n": {
+          "name": "daily_leaderboard_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cost_alert_enabled": {
+          "name": "cost_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cost_alert_webhook": {
+          "name": "cost_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_alert_threshold": {
+          "name": "cost_alert_threshold",
+          "type": "numeric(5, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.80'"
+        },
+        "cost_alert_check_interval": {
+          "name": "cost_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "preserve_client_ip": {
+          "name": "preserve_client_ip",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "join_claude_pool": {
+          "name": "join_claude_pool",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "codex_instructions_strategy": {
+          "name": "codex_instructions_strategy",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "mcp_passthrough_type": {
+          "name": "mcp_passthrough_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'none'"
+        },
+        "mcp_passthrough_url": {
+          "name": "mcp_passthrough_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "max_retry_attempts": {
+          "name": "max_retry_attempts",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "circuit_breaker_failure_threshold": {
+          "name": "circuit_breaker_failure_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "circuit_breaker_open_duration": {
+          "name": "circuit_breaker_open_duration",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1800000
+        },
+        "circuit_breaker_half_open_success_threshold": {
+          "name": "circuit_breaker_half_open_success_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 2
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "first_byte_timeout_streaming_ms": {
+          "name": "first_byte_timeout_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "streaming_idle_timeout_ms": {
+          "name": "streaming_idle_timeout_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "request_timeout_non_streaming_ms": {
+          "name": "request_timeout_non_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_preference": {
+          "name": "context_1m_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.request_filters": {
+      "name": "request_filters",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "target": {
+          "name": "target",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "replacement": {
+          "name": "replacement",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "binding_type": {
+          "name": "binding_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'global'"
+        },
+        "provider_ids": {
+          "name": "provider_ids",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "group_tags": {
+          "name": "group_tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_request_filters_enabled": {
+          "name": "idx_request_filters_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_scope": {
+          "name": "idx_request_filters_scope",
+          "columns": [
+            {
+              "expression": "scope",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_action": {
+          "name": "idx_request_filters_action",
+          "columns": [
+            {
+              "expression": "action",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_binding": {
+          "name": "idx_request_filters_binding",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "binding_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.sensitive_words": {
+      "name": "sensitive_words",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "word": {
+          "name": "word",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'contains'"
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_sensitive_words_enabled": {
+          "name": "idx_sensitive_words_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_sensitive_words_created_at": {
+          "name": "idx_sensitive_words_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "currency_display": {
+          "name": "currency_display",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'USD'"
+        },
+        "billing_model_source": {
+          "name": "billing_model_source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'original'"
+        },
+        "enable_auto_cleanup": {
+          "name": "enable_auto_cleanup",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "cleanup_retention_days": {
+          "name": "cleanup_retention_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cleanup_schedule": {
+          "name": "cleanup_schedule",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0 2 * * *'"
+        },
+        "cleanup_batch_size": {
+          "name": "cleanup_batch_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10000
+        },
+        "enable_client_version_check": {
+          "name": "enable_client_version_check",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "verbose_provider_error": {
+          "name": "verbose_provider_error",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_http2": {
+          "name": "enable_http2",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "tags": {
+          "name": "tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_clients": {
+          "name": "allowed_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_enabled_expires_at": {
+          "name": "idx_users_enabled_expires_at",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "expires_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -295,6 +295,13 @@
       "when": 1766696732309,
       "tag": "0041_sticky_jackal",
       "breakpoints": true
+    },
+    {
+      "idx": 42,
+      "version": "7",
+      "when": 1767279598143,
+      "tag": "0042_legal_harrier",
+      "breakpoints": true
     }
   ]
 }

+ 5 - 0
messages/en/dashboard.json

@@ -1070,6 +1070,7 @@
       "sortByName": "Name",
       "sortByTags": "Tags",
       "sortByExpiresAt": "Expires at",
+      "sortByRpm": "RPM Limit",
       "sortByLimit5h": "5h limit",
       "sortByLimitDaily": "Daily limit",
       "sortByLimitWeekly": "Weekly limit",
@@ -1099,6 +1100,7 @@
         "note": "Note",
         "expiresAt": "Expires at",
         "expiresAtHint": "Click to quick renew",
+        "limitRpm": "RPM",
         "limit5h": "5h limit",
         "limitDaily": "Daily limit",
         "limitWeekly": "Weekly limit",
@@ -1257,6 +1259,7 @@
         "fields": {
           "note": "Note",
           "tags": "Tags",
+          "rpm": "RPM Limit",
           "limit5h": "5h Limit (USD)",
           "limitDaily": "Daily Limit (USD)",
           "limitWeekly": "Weekly Limit (USD)",
@@ -1330,6 +1333,7 @@
     "limitRules": {
       "addRule": "Add limit rule",
       "ruleTypes": {
+        "limitRpm": "RPM limit",
         "limit5h": "5-hour limit",
         "limitDaily": "Daily limit",
         "limitWeekly": "Weekly limit",
@@ -1342,6 +1346,7 @@
         "rolling": "Rolling window (24h)"
       },
       "quickValues": {
+        "unlimited": "Unlimited",
         "10": "$10",
         "50": "$50",
         "100": "$100",

+ 19 - 1
messages/en/settings.json

@@ -808,7 +808,12 @@
         "manualDesc": "Support adding any model name (not limited to price table)",
         "claude": "Claude",
         "openai": "OpenAI",
-        "gemini": "Gemini"
+        "gemini": "Gemini",
+        "sourceUpstream": "Upstream",
+        "sourceUpstreamDesc": "Model list from upstream provider API",
+        "sourceFallback": "Local",
+        "sourceFallbackDesc": "Using local price list (upstream unavailable or unsupported)",
+        "refresh": "Refresh model list"
       },
       "modelRedirect": {
         "currentRules": "Current Rules ({count})",
@@ -1349,6 +1354,19 @@
         "cancel": "Cancel",
         "confirm": "Confirm Delete"
       },
+      "failureThresholdConfirmDialog": {
+        "title": "Confirm Special Configuration",
+        "descriptionDisabledPrefix": "You are setting the circuit breaker failure threshold to ",
+        "descriptionDisabledValue": "0",
+        "descriptionDisabledMiddle": ", which means ",
+        "descriptionDisabledAction": "disabling the circuit breaker",
+        "descriptionDisabledSuffix": ". The provider will not be circuit-broken due to consecutive failures.",
+        "descriptionHighValuePrefix": "You are setting the circuit breaker failure threshold to ",
+        "descriptionHighValueSuffix": ", which is a high value and may cause the provider to be circuit-broken only after many failures.",
+        "confirmQuestion": "Are you sure you want to save this configuration?",
+        "cancel": "Cancel",
+        "confirm": "Confirm Save"
+      },
       "errors": {
         "invalidUrl": "Please enter a valid API address",
         "invalidWebsiteUrl": "Please enter a valid provider website URL",

+ 13 - 8
messages/ja/dashboard.json

@@ -911,7 +911,7 @@
     },
     "chart": {
       "title": "可用性トレンド",
-      "description": "時間経過による可用性の変化",
+      "description": "時間ごとの可用性の変化",
       "availabilityScore": "可用性スコア",
       "requestVolume": "リクエスト量",
       "latencyTrend": "遅延トレンド",
@@ -1046,6 +1046,7 @@
       "sortByName": "名前",
       "sortByTags": "タグ",
       "sortByExpiresAt": "有効期限",
+      "sortByRpm": "RPM上限",
       "sortByLimit5h": "5時間上限",
       "sortByLimitDaily": "日次上限",
       "sortByLimitWeekly": "週次上限",
@@ -1070,11 +1071,12 @@
         "note": "メモ",
         "expiresAt": "有効期限",
         "expiresAtHint": "クリックで期限を延長",
-        "limit5h": "5時間上限",
-        "limitDaily": "日次上限",
-        "limitWeekly": "週次上限",
-        "limitMonthly": "月次上限",
-        "limitTotal": "総上限",
+        "limitRpm": "RPM上限",
+        "limit5h": "5時間上限 (USD)",
+        "limitDaily": "日次上限 (USD)",
+        "limitWeekly": "週次上限 (USD)",
+        "limitMonthly": "月次上限 (USD)",
+        "limitTotal": "合計上限",
         "limitSessions": "同時セッション"
       },
       "keyRow": {
@@ -1219,10 +1221,11 @@
         "fields": {
           "note": "メモ",
           "tags": "タグ",
+          "rpm": "RPM上限",
           "limit5h": "5時間上限 (USD)",
           "limitDaily": "日次上限 (USD)",
-          "limitWeekly": "週上限 (USD)",
-          "limitMonthly": "月上限 (USD)"
+          "limitWeekly": "週上限 (USD)",
+          "limitMonthly": "月上限 (USD)"
         },
         "placeholders": {
           "emptyToClear": "空欄でクリア",
@@ -1292,6 +1295,7 @@
     "limitRules": {
       "addRule": "上限ルールを追加",
       "ruleTypes": {
+        "limitRpm": "RPM 上限",
         "limit5h": "5時間上限",
         "limitDaily": "日次上限",
         "limitWeekly": "週次上限",
@@ -1304,6 +1308,7 @@
         "rolling": "ローリングウィンドウ(24時間)"
       },
       "quickValues": {
+        "unlimited": "無制限",
         "10": "$10",
         "50": "$50",
         "100": "$100",

+ 1 - 0
messages/ja/errors.json

@@ -46,6 +46,7 @@
   "QUOTA_EXCEEDED": "クォータを超過しました",
   "BATCH_SIZE_EXCEEDED": "一括操作は {max} 件を超えることはできません",
   "RATE_LIMIT_EXCEEDED": "リクエストが多すぎます。後でもう一度お試しください",
+  "RATE_LIMIT_RPM_EXCEEDED": "リクエストレート制限を超過しました:現在 {current} 回/分(制限:{limit} 回/分)。{resetTime} にリセットされます",
   "RESOURCE_BUSY": "リソースは現在使用中です",
   "INVALID_STATE": "現在の状態では操作が許可されていません",
   "CONFLICT": "操作の競合",

+ 19 - 1
messages/ja/settings.json

@@ -719,7 +719,12 @@
         "manualDesc": "任意のモデル名を追加できます(価格表のモデルに限定されません)",
         "claude": "Claude",
         "openai": "OpenAI",
-        "gemini": "Gemini"
+        "gemini": "Gemini",
+        "sourceUpstream": "上流",
+        "sourceUpstreamDesc": "モデルリストは上流プロバイダーAPIから取得",
+        "sourceFallback": "ローカル",
+        "sourceFallbackDesc": "ローカル価格表のモデルリストを使用(上流が利用不可または未対応)",
+        "refresh": "モデルリストを更新"
       },
       "modelRedirect": {
         "currentRules": "現在のルール ({count})",
@@ -1219,6 +1224,19 @@
         "cancel": "キャンセル",
         "confirm": "削除を確定"
       },
+      "failureThresholdConfirmDialog": {
+        "title": "特別な設定を確認",
+        "descriptionDisabledPrefix": "サーキットブレーカーの失敗閾値を",
+        "descriptionDisabledValue": "0",
+        "descriptionDisabledMiddle": "に設定しています。これは",
+        "descriptionDisabledAction": "サーキットブレーカーを無効化",
+        "descriptionDisabledSuffix": "することを意味し、プロバイダーは連続した失敗によって遮断されません。",
+        "descriptionHighValuePrefix": "サーキットブレーカーの失敗閾値を",
+        "descriptionHighValueSuffix": "に設定しています。これは高い値であり、プロバイダーが多数の失敗の後にのみ遮断される可能性があります。",
+        "confirmQuestion": "この設定を保存してもよろしいですか?",
+        "cancel": "キャンセル",
+        "confirm": "保存を確定"
+      },
       "errors": {
         "invalidUrl": "有効な API アドレスを入力してください",
         "invalidWebsiteUrl": "有効な公式サイト URL を入力してください",

+ 11 - 5
messages/ru/dashboard.json

@@ -981,7 +981,7 @@
       "startTime": "Начало",
       "endTime": "Конец",
       "user": "Пользователь",
-      "provider": "Провайдер",
+      "provider": "Поставщик",
       "limitType": "Тип лимита",
       "allUsers": "Все пользователи",
       "allProviders": "Все провайдеры",
@@ -1048,7 +1048,8 @@
       "sortByName": "Имя",
       "sortByTags": "Теги",
       "sortByExpiresAt": "Срок действия",
-      "sortByLimit5h": "Лимит 5ч",
+      "sortByRpm": "Лимит RPM",
+      "sortByLimit5h": "Лимит 5 ч",
       "sortByLimitDaily": "Дневной лимит",
       "sortByLimitWeekly": "Недельный лимит",
       "sortByLimitMonthly": "Месячный лимит",
@@ -1077,6 +1078,7 @@
         "note": "Примечание",
         "expiresAt": "Дата истечения",
         "expiresAtHint": "Нажмите для быстрого продления",
+        "limitRpm": "RPM",
         "limit5h": "Лимит 5 ч",
         "limitDaily": "Дневной лимит",
         "limitWeekly": "Недельный лимит",
@@ -1230,6 +1232,7 @@
         "fields": {
           "note": "Заметка",
           "tags": "Теги",
+          "rpm": "Лимит RPM",
           "limit5h": "Лимит за 5 часов (USD)",
           "limitDaily": "Дневной лимит (USD)",
           "limitWeekly": "Недельный лимит (USD)",
@@ -1303,6 +1306,7 @@
     "limitRules": {
       "addRule": "Добавить правило лимита",
       "ruleTypes": {
+        "limitRpm": "Лимит RPM",
         "limit5h": "Лимит за 5 часов",
         "limitDaily": "Дневной лимит",
         "limitWeekly": "Недельный лимит",
@@ -1315,6 +1319,7 @@
         "rolling": "Скользящее окно (24ч)"
       },
       "quickValues": {
+        "unlimited": "Без ограничений",
         "10": "$10",
         "50": "$50",
         "100": "$100",
@@ -1445,7 +1450,8 @@
         },
         "description": {
           "label": "Заметка",
-          "placeholder": "Введите заметку (необязательно)"
+          "placeholder": "Введите заметку (необязательно)",
+          "description": "Используется для описания назначения пользователя или примечаний"
         },
         "tags": {
           "label": "Теги пользователя",
@@ -1457,8 +1463,8 @@
         },
         "allowedClients": {
           "label": "Ограничения клиентов",
-          "description": "Ограничьте CLI/IDE клиенты для этой учетной записи. Пусто = без ограничений.",
-          "customLabel": "Пользовательский шаблон клиента",
+          "description": "Ограничьте, какие CLI/IDE клиенты могут использовать эту учетную запись. Пусто = без ограничений.",
+          "customLabel": "Пользовательские шаблоны клиентов",
           "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')"
         },
         "allowedModels": {

+ 1 - 0
messages/ru/errors.json

@@ -46,6 +46,7 @@
   "QUOTA_EXCEEDED": "Квота исчерпана",
   "BATCH_SIZE_EXCEEDED": "Пакетная операция не может превышать {max} элементов",
   "RATE_LIMIT_EXCEEDED": "Слишком много запросов, попробуйте позже",
+  "RATE_LIMIT_RPM_EXCEEDED": "Превышен лимит запросов: {current} запросов в минуту (лимит: {limit}). Сброс в {resetTime}",
   "RESOURCE_BUSY": "Ресурс в настоящее время используется",
   "INVALID_STATE": "Операция не разрешена в текущем состоянии",
   "CONFLICT": "Конфликт операции",

+ 19 - 1
messages/ru/settings.json

@@ -719,7 +719,12 @@
         "manualDesc": "Поддержка добавления любого названия модели (не ограничено прайс-листом)",
         "claude": "Claude",
         "openai": "OpenAI",
-        "gemini": "Gemini"
+        "gemini": "Gemini",
+        "sourceUpstream": "Upstream",
+        "sourceUpstreamDesc": "Список моделей из API провайдера",
+        "sourceFallback": "Локально",
+        "sourceFallbackDesc": "Используется локальный прайс-лист (upstream недоступен или не поддерживается)",
+        "refresh": "Обновить список моделей"
       },
       "modelRedirect": {
         "currentRules": "Текущие правила ({count})",
@@ -1219,6 +1224,19 @@
         "cancel": "Отмена",
         "confirm": "Подтвердить удаление"
       },
+      "failureThresholdConfirmDialog": {
+        "title": "Подтвердите особую конфигурацию",
+        "descriptionDisabledPrefix": "Вы устанавливаете порог сбоев автоматического выключателя на ",
+        "descriptionDisabledValue": "0",
+        "descriptionDisabledMiddle": ", что означает ",
+        "descriptionDisabledAction": "отключение автоматического выключателя",
+        "descriptionDisabledSuffix": ". Провайдер не будет отключаться из-за последовательных сбоев.",
+        "descriptionHighValuePrefix": "Вы устанавливаете порог сбоев автоматического выключателя на ",
+        "descriptionHighValueSuffix": ", что является высоким значением и может привести к отключению провайдера только после многочисленных сбоев.",
+        "confirmQuestion": "Вы уверены, что хотите сохранить эту конфигурацию?",
+        "cancel": "Отмена",
+        "confirm": "Подтвердить сохранение"
+      },
       "errors": {
         "invalidUrl": "Введите корректный адрес API",
         "invalidWebsiteUrl": "Введите корректный адрес сайта провайдера",

+ 5 - 0
messages/zh-CN/dashboard.json

@@ -959,6 +959,7 @@
       "sortByName": "按名称",
       "sortByTags": "按标签",
       "sortByExpiresAt": "按过期时间",
+      "sortByRpm": "按RPM限制",
       "sortByLimit5h": "按5小时限额",
       "sortByLimitDaily": "按每日限额",
       "sortByLimitWeekly": "按周限额",
@@ -1100,6 +1101,7 @@
         "note": "备注",
         "expiresAt": "到期时间",
         "expiresAtHint": "点击快捷续期",
+        "limitRpm": "RPM",
         "limit5h": "5h 限额",
         "limitDaily": "每日限额",
         "limitWeekly": "周限额",
@@ -1258,6 +1260,7 @@
         "fields": {
           "note": "备注",
           "tags": "标签",
+          "rpm": "RPM 限制",
           "limit5h": "5h 限额 (USD)",
           "limitDaily": "每日限额 (USD)",
           "limitWeekly": "周限额 (USD)",
@@ -1331,6 +1334,7 @@
     "limitRules": {
       "addRule": "添加限额规则",
       "ruleTypes": {
+        "limitRpm": "RPM 限额",
         "limit5h": "5小时限额",
         "limitDaily": "每日限额",
         "limitWeekly": "周限额",
@@ -1343,6 +1347,7 @@
         "rolling": "滚动窗口(24h)"
       },
       "quickValues": {
+        "unlimited": "无限",
         "10": "$10",
         "50": "$50",
         "100": "$100",

+ 19 - 1
messages/zh-CN/settings.json

@@ -410,7 +410,12 @@
         "manualDesc": "支持添加任意模型名称(不限于价格表中的模型)",
         "claude": "Claude",
         "openai": "OpenAI",
-        "gemini": "Gemini"
+        "gemini": "Gemini",
+        "sourceUpstream": "上游",
+        "sourceUpstreamDesc": "模型列表来自上游服务商 API",
+        "sourceFallback": "本地",
+        "sourceFallbackDesc": "使用本地价格表中的模型列表(上游获取失败或不支持)",
+        "refresh": "刷新模型列表"
       },
       "modelRedirect": {
         "currentRules": "当前规则 ({count})",
@@ -940,6 +945,19 @@
         "cancel": "取消",
         "confirm": "确认删除"
       },
+      "failureThresholdConfirmDialog": {
+        "title": "确认特殊配置",
+        "descriptionDisabledPrefix": "您将熔断失败阈值设置为 ",
+        "descriptionDisabledValue": "0",
+        "descriptionDisabledMiddle": ",这表示",
+        "descriptionDisabledAction": "禁用熔断器",
+        "descriptionDisabledSuffix": ",供应商将不会因为连续失败而被熔断。",
+        "descriptionHighValuePrefix": "您将熔断失败阈值设置为 ",
+        "descriptionHighValueSuffix": ",这是一个较高的值,可能会导致供应商在大量失败后才被熔断。",
+        "confirmQuestion": "是否确认保存此配置?",
+        "cancel": "取消",
+        "confirm": "确认保存"
+      },
       "errors": {
         "invalidUrl": "请输入有效的 API 地址",
         "invalidWebsiteUrl": "请输入有效的供应商官网地址",

+ 11 - 9
messages/zh-TW/dashboard.json

@@ -474,10 +474,7 @@
     },
     "labels": {
       "byName": "按名稱",
-      "byUsageRate": "按使用率",
-      "all": "全部",
-      "warning": "接近額度 (>60%)",
-      "exceeded": "已超額 (≥100%)"
+      "byUsageRate": "按使用率"
     },
     "users": {
       "title": "用戶額度統計",
@@ -1049,6 +1046,7 @@
       "sortByName": "按名稱",
       "sortByTags": "按標籤",
       "sortByExpiresAt": "按過期時間",
+      "sortByRpm": "按RPM限制",
       "sortByLimit5h": "按5小時限額",
       "sortByLimitDaily": "按每日限額",
       "sortByLimitWeekly": "按週限額",
@@ -1078,11 +1076,12 @@
         "note": "備註",
         "expiresAt": "到期時間",
         "expiresAtHint": "點擊快速續期",
-        "limit5h": "5h 限額",
-        "limitDaily": "每日限額",
-        "limitWeekly": "週限額",
-        "limitMonthly": "月限額",
-        "limitTotal": "總限額",
+        "limitRpm": "RPM 限制",
+        "limit5h": "5h 限額 (USD)",
+        "limitDaily": "每日限額 (USD)",
+        "limitWeekly": "週限額 (USD)",
+        "limitMonthly": "月限額 (USD)",
+        "limitTotal": "總限額 (USD)",
         "limitSessions": "並發"
       },
       "keyRow": {
@@ -1231,6 +1230,7 @@
         "fields": {
           "note": "備註",
           "tags": "標籤",
+          "rpm": "RPM 限制",
           "limit5h": "5h 限額 (USD)",
           "limitDaily": "每日限額 (USD)",
           "limitWeekly": "週限額 (USD)",
@@ -1304,6 +1304,7 @@
     "limitRules": {
       "addRule": "新增限額規則",
       "ruleTypes": {
+        "limitRpm": "RPM 限額",
         "limit5h": "5小時限額",
         "limitDaily": "每日限額",
         "limitWeekly": "週限額",
@@ -1316,6 +1317,7 @@
         "rolling": "滾動視窗(24h)"
       },
       "quickValues": {
+        "unlimited": "無限",
         "10": "$10",
         "50": "$50",
         "100": "$100",

+ 1 - 0
messages/zh-TW/errors.json

@@ -46,6 +46,7 @@
   "QUOTA_EXCEEDED": "配額已用盡",
   "BATCH_SIZE_EXCEEDED": "批次操作不能超過 {max} 個項目",
   "RATE_LIMIT_EXCEEDED": "請求過於頻繁,請稍後重試",
+  "RATE_LIMIT_RPM_EXCEEDED": "請求頻率超限:當前 {current} 次/分鐘(限制:{limit} 次/分鐘)。將於 {resetTime} 重置",
   "RESOURCE_BUSY": "資源正在使用中",
   "INVALID_STATE": "當前狀態不允許此操作",
   "CONFLICT": "操作衝突",

+ 19 - 1
messages/zh-TW/settings.json

@@ -757,7 +757,12 @@
         "manualDesc": "支援新增任意模型名稱(不限於價格表中的模型)",
         "claude": "Claude",
         "openai": "OpenAI",
-        "gemini": "Gemini"
+        "gemini": "Gemini",
+        "sourceUpstream": "上游",
+        "sourceUpstreamDesc": "模型列表來自上游服務商 API",
+        "sourceFallback": "本地",
+        "sourceFallbackDesc": "使用本地價格表中的模型列表(上游獲取失敗或不支援)",
+        "refresh": "重新整理模型列表"
       },
       "modelRedirect": {
         "currentRules": "目前規則 ({count})",
@@ -1225,6 +1230,19 @@
         "cancel": "取消",
         "confirm": "確認刪除"
       },
+      "failureThresholdConfirmDialog": {
+        "title": "確認特殊設定",
+        "descriptionDisabledPrefix": "您將熔斷失敗閾值設定為 ",
+        "descriptionDisabledValue": "0",
+        "descriptionDisabledMiddle": ",這表示",
+        "descriptionDisabledAction": "停用熔斷器",
+        "descriptionDisabledSuffix": ",供應商將不會因為連續失敗而被熔斷。",
+        "descriptionHighValuePrefix": "您將熔斷失敗閾值設定為 ",
+        "descriptionHighValueSuffix": ",這是一個較高的值,可能會導致供應商在大量失敗後才被熔斷。",
+        "confirmQuestion": "是否確認儲存此設定?",
+        "cancel": "取消",
+        "confirm": "確認儲存"
+      },
       "errors": {
         "invalidUrl": "請輸入有效的 API 位址",
         "invalidWebsiteUrl": "請輸入有效的供應商官網",

+ 33 - 2
src/actions/active-sessions.ts

@@ -511,10 +511,13 @@ export async function getSessionDetails(
   requestSequence?: number
 ): Promise<
   ActionResult<{
+    requestBody: unknown | null;
     messages: unknown | null;
     response: string | null;
     requestHeaders: Record<string, string> | null;
     responseHeaders: Record<string, string> | null;
+    requestMeta: { clientUrl: string | null; upstreamUrl: string | null; method: string | null };
+    responseMeta: { upstreamUrl: string | null; statusCode: number | null };
     sessionStats: Awaited<
       ReturnType<typeof import("@/repository/message").aggregateSessionStats>
     > | null;
@@ -603,24 +606,52 @@ export async function getSessionDetails(
       }
     };
 
-    // 6. 并行获取 messages 和 response(不缓存,因为这些数据较大)
-    const [messages, response, requestHeaders, responseHeaders] = await Promise.all([
+    // 6. 并行获取 messages、requestBody 和 response(不缓存,因为这些数据较大)
+    const [
+      requestBody,
+      messages,
+      response,
+      requestHeaders,
+      responseHeaders,
+      clientReqMeta,
+      upstreamReqMeta,
+      upstreamResMeta,
+    ] = await Promise.all([
+      SessionManager.getSessionRequestBody(sessionId, effectiveSequence),
       SessionManager.getSessionMessages(sessionId, effectiveSequence),
       SessionManager.getSessionResponse(sessionId, effectiveSequence),
       SessionManager.getSessionRequestHeaders(sessionId, effectiveSequence),
       SessionManager.getSessionResponseHeaders(sessionId, effectiveSequence),
+      SessionManager.getSessionClientRequestMeta(sessionId, effectiveSequence),
+      SessionManager.getSessionUpstreamRequestMeta(sessionId, effectiveSequence),
+      SessionManager.getSessionUpstreamResponseMeta(sessionId, effectiveSequence),
     ]);
 
     // 兼容:历史/异常数据可能是 JSON 字符串(前端需要根级对象/数组)
     const normalizedMessages = parseJsonStringOrNull(messages);
+    const normalizedRequestBody = parseJsonStringOrNull(requestBody);
+
+    const requestMeta = {
+      clientUrl: clientReqMeta?.url ?? null,
+      upstreamUrl: upstreamReqMeta?.url ?? null,
+      method: clientReqMeta?.method ?? upstreamReqMeta?.method ?? null,
+    };
+
+    const responseMeta = {
+      upstreamUrl: upstreamResMeta?.url ?? upstreamReqMeta?.url ?? null,
+      statusCode: upstreamResMeta?.statusCode ?? null,
+    };
 
     return {
       ok: true,
       data: {
+        requestBody: normalizedRequestBody,
         messages: normalizedMessages,
         response,
         requestHeaders,
         responseHeaders,
+        requestMeta,
+        responseMeta,
         sessionStats,
         currentSequence: effectiveSequence ?? null,
         prevSequence: adjacent.prevSequence,

+ 7 - 6
src/actions/error-rules.ts

@@ -3,6 +3,7 @@
 import { revalidatePath } from "next/cache";
 import safeRegex from "safe-regex";
 import { getSession } from "@/lib/auth";
+import { emitErrorRulesUpdated } from "@/lib/emit-event";
 import { validateErrorOverrideResponse } from "@/lib/error-override-validator";
 import { errorRuleDetector } from "@/lib/error-rule-detector";
 import { logger } from "@/lib/logger";
@@ -175,8 +176,8 @@ export async function createErrorRuleAction(data: {
       overrideStatusCode: data.overrideStatusCode ?? null,
     });
 
-    // 刷新缓存(直接调用 reload,不再触发事件避免重复刷新
-    await errorRuleDetector.reload();
+    // 刷新缓存(事件广播,支持多 worker 同步
+    await emitErrorRulesUpdated();
 
     revalidatePath("/settings/error-rules");
 
@@ -308,8 +309,8 @@ export async function updateErrorRuleAction(
       };
     }
 
-    // 刷新缓存(直接调用 reload,不再触发事件避免重复刷新
-    await errorRuleDetector.reload();
+    // 刷新缓存(事件广播,支持多 worker 同步
+    await emitErrorRulesUpdated();
 
     revalidatePath("/settings/error-rules");
 
@@ -362,8 +363,8 @@ export async function deleteErrorRuleAction(id: number): Promise<ActionResult> {
       };
     }
 
-    // 刷新缓存(直接调用 reload,不再触发事件避免重复刷新
-    await errorRuleDetector.reload();
+    // 刷新缓存(事件广播,支持多 worker 同步
+    await emitErrorRulesUpdated();
 
     revalidatePath("/settings/error-rules");
 

+ 334 - 0
src/actions/providers.ts

@@ -2783,3 +2783,337 @@ export async function getProviderTestPresets(
     };
   }
 }
+
+// ============================================================================
+// Fetch Upstream Models
+// ============================================================================
+
+/**
+ * 上游模型列表获取参数
+ */
+export type FetchUpstreamModelsArgs = {
+  providerUrl: string;
+  apiKey: string;
+  providerType: ProviderType;
+  proxyUrl?: string | null;
+  proxyFallbackToDirect?: boolean;
+  /** 超时时间(毫秒),默认 10000 */
+  timeoutMs?: number;
+};
+
+/**
+ * 上游模型列表获取结果
+ */
+export type FetchUpstreamModelsResult = ActionResult<{
+  models: string[];
+  source: "upstream";
+}>;
+
+// OpenAI /v1/models 响应类型
+type OpenAIModelsResponse = {
+  object: "list";
+  data: Array<{
+    id: string;
+    object: "model";
+    created?: number;
+    owned_by?: string;
+  }>;
+};
+
+// Gemini /v1beta/models 响应类型
+type GeminiModelsResponse = {
+  models: Array<{
+    name: string;
+    displayName?: string;
+    description?: string;
+    supportedGenerationMethods?: string[];
+  }>;
+  nextPageToken?: string;
+};
+
+// Anthropic /v1/models 响应类型
+type AnthropicModelsResponse = {
+  data: Array<{
+    id: string;
+    created_at: string;
+    display_name: string;
+    type: "model";
+  }>;
+  first_id: string;
+  has_more: boolean;
+  last_id: string;
+};
+
+const UPSTREAM_FETCH_TIMEOUT_MS = 10000;
+
+// 通用 fetch 选项类型(undici 兼容)
+interface UndiciFetchOptions extends RequestInit {
+  dispatcher?: unknown;
+}
+
+/**
+ * 执行带代理的 fetch 请求(通用函数)
+ */
+async function executeProxiedFetch(
+  proxyConfig: { proxyUrl: string | null; proxyFallbackToDirect: boolean },
+  url: string,
+  headers: Record<string, string>,
+  timeoutMs: number
+): Promise<Response> {
+  const tempProvider: ProviderProxyConfig = {
+    id: -1,
+    name: "fetch-models",
+    proxyUrl: proxyConfig.proxyUrl,
+    proxyFallbackToDirect: proxyConfig.proxyFallbackToDirect,
+  };
+
+  const proxy = createProxyAgentForProvider(tempProvider, url);
+
+  const init: UndiciFetchOptions = {
+    method: "GET",
+    headers,
+    signal: AbortSignal.timeout(timeoutMs),
+  };
+
+  if (proxy) {
+    init.dispatcher = proxy.agent;
+  }
+
+  return fetch(url, init);
+}
+
+/**
+ * 处理 HTTP 错误响应
+ */
+function handleHttpError(
+  response: Response,
+  errorText: string,
+  logPrefix: string
+): FetchUpstreamModelsResult {
+  logger.warn(`${logPrefix}: API returned error`, {
+    status: response.status,
+    errorPreview: errorText.substring(0, 200),
+  });
+  return { ok: false, error: `API 返回错误: HTTP ${response.status}` };
+}
+
+/**
+ * 处理 fetch 异常
+ */
+function handleFetchException(error: unknown, logPrefix: string): FetchUpstreamModelsResult {
+  const err = error as Error & { code?: string };
+  logger.warn(`${logPrefix}: request failed`, {
+    error: err.message,
+    code: err.code,
+  });
+  return { ok: false, error: `请求失败: ${err.message}` };
+}
+
+/**
+ * 构建成功响应
+ */
+function buildSuccessResult(models: string[], logPrefix: string): FetchUpstreamModelsResult {
+  logger.debug(`${logPrefix}: success`, { modelCount: models.length });
+  return { ok: true, data: { models, source: "upstream" } };
+}
+
+/**
+ * 从上游服务商获取模型列表
+ *
+ * 支持的服务商类型:
+ * - claude / claude-auth: 调用 /v1/models (Anthropic API)
+ * - codex / openai-compatible: 调用 /v1/models (OpenAI 兼容 API)
+ * - gemini / gemini-cli: 调用 /v1beta/models (Google AI API)
+ *
+ * @returns 模型列表或错误
+ */
+export async function fetchUpstreamModels(
+  data: FetchUpstreamModelsArgs
+): Promise<FetchUpstreamModelsResult> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    // 验证 URL
+    const urlValidation = validateProviderUrlForConnectivity(data.providerUrl);
+    if (!urlValidation.valid) {
+      return { ok: false, error: urlValidation.error.message };
+    }
+
+    // 验证代理 URL
+    if (data.proxyUrl && !isValidProxyUrl(data.proxyUrl)) {
+      return { ok: false, error: "代理地址格式无效" };
+    }
+
+    const normalizedUrl = urlValidation.normalizedUrl.replace(/\/$/, "");
+    const timeoutMs = data.timeoutMs ?? UPSTREAM_FETCH_TIMEOUT_MS;
+
+    // 根据供应商类型选择不同的 API
+    if (data.providerType === "claude" || data.providerType === "claude-auth") {
+      return await fetchAnthropicModels(data, normalizedUrl, timeoutMs);
+    }
+
+    if (data.providerType === "gemini" || data.providerType === "gemini-cli") {
+      return await fetchGeminiModels(data, normalizedUrl, timeoutMs);
+    }
+
+    // OpenAI 兼容 API (codex, openai-compatible)
+    return await fetchOpenAIModels(data, normalizedUrl, timeoutMs);
+  } catch (error) {
+    logger.error("fetchUpstreamModels error", { error, providerType: data.providerType });
+    return {
+      ok: false,
+      error: error instanceof Error ? error.message : "获取上游模型列表失败",
+    };
+  }
+}
+
+/**
+ * 从 OpenAI 兼容 API 获取模型列表
+ */
+async function fetchOpenAIModels(
+  data: FetchUpstreamModelsArgs,
+  normalizedUrl: string,
+  timeoutMs: number
+): Promise<FetchUpstreamModelsResult> {
+  const url = `${normalizedUrl}/v1/models`;
+
+  try {
+    const response = await executeProxiedFetch(
+      {
+        proxyUrl: data.proxyUrl ?? null,
+        proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
+      },
+      url,
+      { Authorization: `Bearer ${data.apiKey}` },
+      timeoutMs
+    );
+
+    if (!response.ok) {
+      return handleHttpError(response, await response.text(), "fetchOpenAIModels");
+    }
+
+    const result = (await response.json()) as OpenAIModelsResponse;
+
+    if (!result.data || !Array.isArray(result.data)) {
+      return { ok: false, error: "响应格式无效:缺少 data 数组" };
+    }
+
+    return buildSuccessResult(result.data.map((m) => m.id).sort(), "fetchOpenAIModels");
+  } catch (error) {
+    return handleFetchException(error, "fetchOpenAIModels");
+  }
+}
+
+/**
+ * 从 Gemini API 获取模型列表
+ * 注意:保留了 401/403 重试逻辑,因为 Gemini 支持多种认证方式
+ */
+async function fetchGeminiModels(
+  data: FetchUpstreamModelsArgs,
+  normalizedUrl: string,
+  timeoutMs: number
+): Promise<FetchUpstreamModelsResult> {
+  const proxyConfig = {
+    proxyUrl: data.proxyUrl ?? null,
+    proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
+  };
+
+  // Gemini 认证处理
+  let processedApiKey = data.apiKey;
+  let isJsonCreds = false;
+
+  try {
+    processedApiKey = await GeminiAuth.getAccessToken(data.apiKey);
+    isJsonCreds = GeminiAuth.isJson(data.apiKey);
+  } catch (e) {
+    logger.warn("fetchGeminiModels: auth process failed", { error: e });
+  }
+
+  const url = `${normalizedUrl}/v1beta/models?pageSize=100`;
+  const headers: Record<string, string> = isJsonCreds
+    ? { Authorization: `Bearer ${processedApiKey}` }
+    : { "x-goog-api-key": processedApiKey };
+
+  try {
+    let response = await executeProxiedFetch(proxyConfig, url, headers, timeoutMs);
+
+    // 如果 header 认证失败(401/403),尝试 URL 参数认证(不动此逻辑)
+    if (!isJsonCreds && (response.status === 401 || response.status === 403)) {
+      logger.debug("fetchGeminiModels: header auth failed, trying URL param auth");
+      const urlWithKey = `${normalizedUrl}/v1beta/models?pageSize=100&key=${encodeURIComponent(processedApiKey)}`;
+      response = await executeProxiedFetch(
+        proxyConfig,
+        urlWithKey,
+        { "x-goog-api-key": processedApiKey },
+        timeoutMs
+      );
+    }
+
+    if (!response.ok) {
+      return handleHttpError(response, await response.text(), "fetchGeminiModels");
+    }
+
+    const result = (await response.json()) as GeminiModelsResponse;
+
+    if (!result.models || !Array.isArray(result.models)) {
+      return { ok: false, error: "响应格式无效:缺少 models 数组" };
+    }
+
+    // Gemini 模型名称格式: "models/gemini-pro" -> "gemini-pro"
+    // 注意:部分代理返回 supportedGenerationMethods 为 null,此时不过滤
+    const models = result.models
+      .filter(
+        (m) =>
+          !m.supportedGenerationMethods || m.supportedGenerationMethods.includes("generateContent")
+      )
+      .map((m) => m.name.replace(/^models\//, ""))
+      .sort();
+
+    return buildSuccessResult(models, "fetchGeminiModels");
+  } catch (error) {
+    return handleFetchException(error, "fetchGeminiModels");
+  }
+}
+
+/**
+ * 从 Anthropic API 获取模型列表
+ */
+async function fetchAnthropicModels(
+  data: FetchUpstreamModelsArgs,
+  normalizedUrl: string,
+  timeoutMs: number
+): Promise<FetchUpstreamModelsResult> {
+  const url = `${normalizedUrl}/v1/models`;
+
+  // 复用认证逻辑:官方 API 用 x-api-key,代理用 Bearer token
+  const authHeaders = resolveAnthropicAuthHeaders(data.apiKey, normalizedUrl);
+
+  try {
+    const response = await executeProxiedFetch(
+      {
+        proxyUrl: data.proxyUrl ?? null,
+        proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
+      },
+      url,
+      authHeaders,
+      timeoutMs
+    );
+
+    if (!response.ok) {
+      return handleHttpError(response, await response.text(), "fetchAnthropicModels");
+    }
+
+    const result = (await response.json()) as AnthropicModelsResponse;
+
+    if (!result.data || !Array.isArray(result.data)) {
+      return { ok: false, error: "响应格式无效:缺少 data 数组" };
+    }
+
+    return buildSuccessResult(result.data.map((m) => m.id).sort(), "fetchAnthropicModels");
+  } catch (error) {
+    return handleFetchException(error, "fetchAnthropicModels");
+  }
+}

+ 18 - 15
src/actions/users.ts

@@ -8,7 +8,6 @@ import { db } from "@/drizzle/db";
 import { users as usersTable } from "@/drizzle/schema";
 import { getSession } from "@/lib/auth";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
-import { USER_DEFAULTS } from "@/lib/constants/user.constants";
 import { logger } from "@/lib/logger";
 import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions";
 import { ERROR_CODES } from "@/lib/utils/error-messages";
@@ -48,6 +47,7 @@ export interface GetUsersBatchParams {
     | "name"
     | "tags"
     | "expiresAt"
+    | "rpm"
     | "limit5hUsd"
     | "limitDailyUsd"
     | "limitWeeklyUsd"
@@ -73,6 +73,7 @@ export interface BatchUpdateUsersParams {
   updates: {
     note?: string;
     tags?: string[];
+    rpm?: number | null;
     dailyQuota?: number | null;
     limit5hUsd?: number | null;
     limitWeeklyUsd?: number | null;
@@ -612,6 +613,7 @@ export async function batchUpdateUsers(
     const updatesSchema = UpdateUserSchema.pick({
       note: true,
       tags: true,
+      rpm: true,
       dailyQuota: true,
       limit5hUsd: true,
       limitWeeklyUsd: true,
@@ -654,6 +656,7 @@ export async function batchUpdateUsers(
 
       if (updates.note !== undefined) dbUpdates.description = updates.note;
       if (updates.tags !== undefined) dbUpdates.tags = updates.tags;
+      if (updates.rpm !== undefined) dbUpdates.rpmLimit = updates.rpm;
       if (updates.dailyQuota !== undefined)
         dbUpdates.dailyLimitUsd =
           updates.dailyQuota === null ? null : updates.dailyQuota.toString();
@@ -706,7 +709,7 @@ export async function addUser(data: {
   note?: string;
   providerGroup?: string | null;
   tags?: string[];
-  rpm?: number;
+  rpm?: number | null;
   dailyQuota?: number | null;
   limit5hUsd?: number | null;
   limitWeeklyUsd?: number | null;
@@ -728,8 +731,8 @@ export async function addUser(data: {
       role: string;
       isEnabled: boolean;
       expiresAt: Date | null;
-      rpm: number;
-      dailyQuota: number;
+      rpm: number | null;
+      dailyQuota: number | null;
       providerGroup?: string;
       tags: string[];
       limit5hUsd: number | null;
@@ -766,7 +769,7 @@ export async function addUser(data: {
       note: data.note || "",
       providerGroup: data.providerGroup || "",
       tags: data.tags || [],
-      rpm: data.rpm || USER_DEFAULTS.RPM,
+      rpm: data.rpm ?? null,
       dailyQuota: data.dailyQuota ?? null,
       limit5hUsd: data.limit5hUsd,
       limitWeeklyUsd: data.limitWeeklyUsd,
@@ -898,8 +901,8 @@ export async function createUserOnly(data: {
   note?: string;
   providerGroup?: string | null;
   tags?: string[];
-  rpm?: number;
-  dailyQuota?: number;
+  rpm?: number | null;
+  dailyQuota?: number | null;
   limit5hUsd?: number | null;
   limitWeeklyUsd?: number | null;
   limitMonthlyUsd?: number | null;
@@ -920,8 +923,8 @@ export async function createUserOnly(data: {
       role: string;
       isEnabled: boolean;
       expiresAt: Date | null;
-      rpm: number;
-      dailyQuota: number;
+      rpm: number | null;
+      dailyQuota: number | null;
       providerGroup?: string;
       tags: string[];
       limit5hUsd: number | null;
@@ -951,7 +954,7 @@ export async function createUserOnly(data: {
       note: data.note || "",
       providerGroup: data.providerGroup || "",
       tags: data.tags || [],
-      rpm: data.rpm || USER_DEFAULTS.RPM,
+      rpm: data.rpm ?? null,
       dailyQuota: data.dailyQuota ?? null,
       limit5hUsd: data.limit5hUsd,
       limitWeeklyUsd: data.limitWeeklyUsd,
@@ -1067,7 +1070,7 @@ export async function editUser(
     note?: string;
     providerGroup?: string | null;
     tags?: string[];
-    rpm?: number;
+    rpm?: number | null;
     dailyQuota?: number | null;
     limit5hUsd?: number | null;
     limitWeeklyUsd?: number | null;
@@ -1229,8 +1232,8 @@ export async function removeUser(userId: number): Promise<ActionResult> {
  */
 export async function getUserLimitUsage(userId: number): Promise<
   ActionResult<{
-    rpm: { current: number; limit: number; window: "per_minute" };
-    dailyCost: { current: number; limit: number; resetAt: Date };
+    rpm: { current: number; limit: number | null; window: "per_minute" };
+    dailyCost: { current: number; limit: number | null; resetAt: Date };
   }>
 > {
   try {
@@ -1273,12 +1276,12 @@ export async function getUserLimitUsage(userId: number): Promise<
       data: {
         rpm: {
           current: rpmCurrent,
-          limit: user.rpm || 60,
+          limit: user.rpm,
           window: "per_minute",
         },
         dailyCost: {
           current: dailyCost,
-          limit: user.dailyQuota ?? 100,
+          limit: user.dailyQuota,
           resetAt: getDailyResetTime(),
         },
       },

+ 9 - 0
src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx

@@ -46,6 +46,7 @@ type ValidationMessages = {
 type UserFieldLabels = {
   note: string;
   tags: string;
+  rpm: string;
   limit5h: string;
   limitDaily: string;
   limitWeekly: string;
@@ -67,6 +68,8 @@ const INITIAL_USER_STATE: BatchUserSectionState = {
   note: "",
   tagsEnabled: false,
   tags: [],
+  rpmEnabled: false,
+  rpm: "",
   limit5hUsdEnabled: false,
   limit5hUsd: "",
   dailyQuotaEnabled: false,
@@ -125,6 +128,11 @@ function buildUserUpdates(
     updates.tags = state.tags;
     enabledFields.push(args.fieldLabels.tags);
   }
+  if (state.rpmEnabled) {
+    const rpmValue = parseNumberOrNull(state.rpm, args.validationMessages);
+    updates.rpm = rpmValue !== null ? Math.floor(rpmValue) : null;
+    enabledFields.push(args.fieldLabels.rpm);
+  }
   if (state.limit5hUsdEnabled) {
     updates.limit5hUsd = parseNumberOrNull(state.limit5hUsd, args.validationMessages);
     enabledFields.push(args.fieldLabels.limit5h);
@@ -229,6 +237,7 @@ function BatchEditDialogInner({
     () => ({
       note: t("user.fields.note"),
       tags: t("user.fields.tags"),
+      rpm: t("user.fields.rpm"),
       limit5h: t("user.fields.limit5h"),
       limitDaily: t("user.fields.limitDaily"),
       limitWeekly: t("user.fields.limitWeekly"),

+ 20 - 0
src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx

@@ -10,6 +10,8 @@ export interface BatchUserSectionState {
   note: string;
   tagsEnabled: boolean;
   tags: string[];
+  rpmEnabled: boolean;
+  rpm: string;
   limit5hUsdEnabled: boolean;
   limit5hUsd: string;
   dailyQuotaEnabled: boolean;
@@ -31,6 +33,7 @@ export interface BatchUserSectionProps {
     fields: {
       note: string;
       tags: string;
+      rpm: string;
       limit5h: string;
       limitDaily: string;
       limitWeekly: string;
@@ -88,6 +91,23 @@ export function BatchUserSection({
           />
         </FieldCard>
 
+        <FieldCard
+          title={translations.fields.rpm}
+          enabled={state.rpmEnabled}
+          onEnabledChange={(enabled) => onChange({ rpmEnabled: enabled })}
+          enableFieldAria={translations.enableFieldAria}
+        >
+          <Input
+            type="number"
+            inputMode="numeric"
+            min={0}
+            value={state.rpm}
+            onChange={(e) => onChange({ rpm: e.target.value })}
+            disabled={!state.rpmEnabled}
+            placeholder={translations.placeholders.emptyNoLimit}
+          />
+        </FieldCard>
+
         <FieldCard
           title={translations.fields.limit5h}
           enabled={state.limit5hUsdEnabled}

+ 16 - 4
src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx

@@ -23,6 +23,7 @@ import {
 import { cn } from "@/lib/utils";
 
 export type LimitType =
+  | "limitRpm"
   | "limit5h"
   | "limitDaily"
   | "limitWeekly"
@@ -54,6 +55,7 @@ export interface LimitRulePickerProps {
 }
 
 const LIMIT_TYPE_OPTIONS: Array<{ type: LimitType; fallbackLabel: string }> = [
+  { type: "limitRpm", fallbackLabel: "RPM 限额" },
   { type: "limit5h", fallbackLabel: "5小时限额" },
   { type: "limitDaily", fallbackLabel: "每日限额" },
   { type: "limitWeekly", fallbackLabel: "周限额" },
@@ -63,7 +65,8 @@ const LIMIT_TYPE_OPTIONS: Array<{ type: LimitType; fallbackLabel: string }> = [
 ];
 
 const QUICK_VALUES = [10, 50, 100, 500] as const;
-const SESSION_QUICK_VALUES = [5, 10, 15, 20] as const;
+const SESSION_QUICK_VALUES = [0, 5, 10, 15, 20] as const;
+const RPM_QUICK_VALUES = [0, 30, 60, 120, 300] as const;
 
 function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
   const value = path.split(".").reduce<unknown>((acc, key) => {
@@ -205,7 +208,7 @@ export function LimitRulePicker({
               <Input
                 type="number"
                 min={0}
-                step={type === "limitSessions" ? 1 : 0.01}
+                step={type === "limitSessions" || type === "limitRpm" ? 1 : 0.01}
                 inputMode="decimal"
                 autoFocus
                 value={rawValue}
@@ -215,7 +218,12 @@ export function LimitRulePicker({
               />
 
               <div className="flex flex-wrap gap-2">
-                {(type === "limitSessions" ? SESSION_QUICK_VALUES : QUICK_VALUES).map((v) => (
+                {(type === "limitSessions"
+                  ? SESSION_QUICK_VALUES
+                  : type === "limitRpm"
+                    ? RPM_QUICK_VALUES
+                    : QUICK_VALUES
+                ).map((v) => (
                   <Button
                     key={v}
                     type="button"
@@ -223,7 +231,11 @@ export function LimitRulePicker({
                     size="sm"
                     onClick={() => setRawValue(String(v))}
                   >
-                    {type === "limitSessions" ? v : `$${v}`}
+                    {v === 0
+                      ? getTranslation(translations, "quickValues.unlimited", "无限")
+                      : type === "limitSessions" || type === "limitRpm"
+                        ? v
+                        : `$${v}`}
                   </Button>
                 ))}
               </div>

+ 16 - 1
src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx

@@ -34,6 +34,7 @@ export interface UserEditSectionProps {
     expiresAt?: Date | null;
     providerGroup?: string | null;
     // 所有限额字段
+    rpm?: number | null;
     limit5hUsd?: number | null;
     dailyQuota?: number | null; // 新增:用户每日限额
     limitWeeklyUsd?: number | null;
@@ -180,6 +181,8 @@ export function UserEditSection({
       items.push({ type, value: numeric, ...extra });
     };
 
+    // RPM: user.rpm > 0 表示有限制
+    add("limitRpm", user.rpm);
     add("limit5h", user.limit5hUsd);
     // 新增:添加每日限额到 rules
     add("limitDaily", user.dailyQuota, {
@@ -193,6 +196,7 @@ export function UserEditSection({
 
     return items;
   }, [
+    user.rpm,
     user.limit5hUsd,
     user.dailyQuota,
     user.dailyResetMode,
@@ -212,11 +216,19 @@ export function UserEditSection({
     return {
       title: translations.limitRules.addRule,
       limitTypes: translations.limitRules.ruleTypes,
+      quickValues: translations.limitRules.quickValues,
     } satisfies Record<string, unknown>;
-  }, [translations.limitRules.addRule, translations.limitRules.ruleTypes]);
+  }, [
+    translations.limitRules.addRule,
+    translations.limitRules.ruleTypes,
+    translations.limitRules.quickValues,
+  ]);
 
   const handleRemoveRule = (type: string) => {
     switch (type) {
+      case "limitRpm":
+        emitChange("rpm", 0); // 0 = 无限制
+        return;
       case "limit5h":
         emitChange("limit5hUsd", null);
         return;
@@ -247,6 +259,9 @@ export function UserEditSection({
 
   const handleAddRule = (type: LimitType, value: number, mode?: DailyResetMode, time?: string) => {
     switch (type) {
+      case "limitRpm":
+        emitChange("rpm", Math.floor(value)); // RPM 应为整数
+        return;
       case "limit5h":
         emitChange("limit5hUsd", value);
         return;

+ 5 - 5
src/app/[locale]/dashboard/_components/user/forms/user-form.tsx

@@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
 import { Label } from "@/components/ui/label";
 import { Switch } from "@/components/ui/switch";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
-import { USER_DEFAULTS, USER_LIMITS } from "@/lib/constants/user.constants";
+import { USER_LIMITS } from "@/lib/constants/user.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { getErrorMessage } from "@/lib/utils/error-messages";
 import { setZodErrorMap } from "@/lib/utils/zod-i18n";
@@ -37,8 +37,8 @@ interface UserFormProps {
     id: number;
     name: string;
     note?: string;
-    rpm: number;
-    dailyQuota: number;
+    rpm: number | null;
+    dailyQuota: number | null;
     providerGroup?: string | null;
     tags?: string[];
     limit5hUsd?: number | null;
@@ -89,8 +89,8 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
     defaultValues: {
       name: user?.name || "",
       note: user?.note || "",
-      rpm: user?.rpm || USER_DEFAULTS.RPM,
-      dailyQuota: user?.dailyQuota ?? USER_DEFAULTS.DAILY_QUOTA,
+      rpm: user?.rpm ?? null,
+      dailyQuota: user?.dailyQuota ?? null,
       providerGroup: user?.providerGroup || PROVIDER_GROUP.DEFAULT,
       tags: user?.tags || [],
       limit5hUsd: user?.limit5hUsd ?? null,

+ 10 - 0
src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx

@@ -130,6 +130,8 @@ function buildDefaultValues(
         note: "",
         tags: [],
         expiresAt: undefined,
+        providerGroup: PROVIDER_GROUP.DEFAULT,
+        rpm: 0,
         limit5hUsd: null,
         dailyQuota: null,
         limitWeeklyUsd: null,
@@ -175,6 +177,7 @@ function buildDefaultValues(
       tags: user.tags || [],
       expiresAt: user.expiresAt ?? undefined,
       providerGroup: normalizeProviderGroup(user.providerGroup),
+      rpm: user.rpm ?? 0,
       limit5hUsd: user.limit5hUsd ?? null,
       dailyQuota: user.dailyQuota ?? null,
       limitWeeklyUsd: user.limitWeeklyUsd ?? null,
@@ -349,6 +352,7 @@ function UnifiedEditDialogInner({
                 note: data.user.note,
                 tags: data.user.tags,
                 expiresAt: data.user.expiresAt ?? null,
+                rpm: data.user.rpm,
                 limit5hUsd: data.user.limit5hUsd,
                 dailyQuota: data.user.dailyQuota ?? undefined,
                 limitWeeklyUsd: data.user.limitWeeklyUsd,
@@ -413,6 +417,7 @@ function UnifiedEditDialogInner({
                 tags: data.user.tags,
                 expiresAt: data.user.expiresAt ?? null,
                 providerGroup: normalizeProviderGroup(data.user.providerGroup),
+                rpm: data.user.rpm,
                 limit5hUsd: data.user.limit5hUsd,
                 dailyQuota: data.user.dailyQuota,
                 limitWeeklyUsd: data.user.limitWeeklyUsd,
@@ -586,6 +591,7 @@ function UnifiedEditDialogInner({
       limitRules: {
         addRule: t("limitRules.addRule"),
         ruleTypes: {
+          limitRpm: t("limitRules.ruleTypes.limitRpm"),
           limit5h: t("limitRules.ruleTypes.limit5h"),
           limitDaily: t("limitRules.ruleTypes.limitDaily"),
           limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
@@ -594,6 +600,7 @@ function UnifiedEditDialogInner({
           limitSessions: t("limitRules.ruleTypes.limitSessions"),
         },
         quickValues: {
+          unlimited: t("limitRules.quickValues.unlimited"),
           "10": t("limitRules.quickValues.10"),
           "50": t("limitRules.quickValues.50"),
           "100": t("limitRules.quickValues.100"),
@@ -652,6 +659,7 @@ function UnifiedEditDialogInner({
       limitRules: {
         title: t("keyEditSection.limitRules.title"),
         limitTypes: {
+          limitRpm: t("limitRules.ruleTypes.limitRpm"),
           limit5h: t("limitRules.ruleTypes.limit5h"),
           limitDaily: t("limitRules.ruleTypes.limitDaily"),
           limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
@@ -660,6 +668,7 @@ function UnifiedEditDialogInner({
           limitSessions: t("limitRules.ruleTypes.limitSessions"),
         },
         quickValues: {
+          unlimited: t("limitRules.quickValues.unlimited"),
           "10": t("limitRules.quickValues.10"),
           "50": t("limitRules.quickValues.50"),
           "100": t("limitRules.quickValues.100"),
@@ -877,6 +886,7 @@ function UnifiedEditDialogInner({
                   tags: currentUserDraft.tags || [],
                   expiresAt: currentUserDraft.expiresAt ?? null,
                   providerGroup: normalizeProviderGroup(currentUserDraft.providerGroup),
+                  rpm: currentUserDraft.rpm ?? 0,
                   limit5hUsd: currentUserDraft.limit5hUsd ?? null,
                   dailyQuota: currentUserDraft.dailyQuota ?? null,
                   limitWeeklyUsd: currentUserDraft.limitWeeklyUsd ?? null,

+ 16 - 1
src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx

@@ -43,6 +43,7 @@ export interface UserKeyTableRowProps {
       note: string;
       expiresAt: string;
       expiresAtHint?: string;
+      limitRpm: string;
       limit5h: string;
       limitDaily: string;
       limitWeekly: string;
@@ -67,7 +68,7 @@ export interface UserKeyTableRowProps {
   };
 }
 
-const DEFAULT_GRID_COLUMNS_CLASS = "grid-cols-[minmax(260px,1fr)_120px_repeat(6,90px)_80px]";
+const DEFAULT_GRID_COLUMNS_CLASS = "grid-cols-[minmax(260px,1fr)_120px_repeat(7,90px)_80px]";
 const EXPIRING_SOON_MS = 72 * 60 * 60 * 1000; // 72小时
 const MAX_VISIBLE_GROUPS = 2; // 最多显示的分组数量
 
@@ -164,6 +165,8 @@ export function UserKeyTableRow({
   const visibleGroups = userGroups.slice(0, MAX_VISIBLE_GROUPS);
   const remainingGroupsCount = Math.max(0, userGroups.length - MAX_VISIBLE_GROUPS);
 
+  // RPM: null 或 0 或负值表示无限制
+  const rpm = user.rpm !== null && user.rpm > 0 ? user.rpm : null;
   const limit5h = normalizeLimitValue(user.limit5hUsd);
   const limitDaily = normalizeLimitValue(user.dailyQuota);
   const limitWeekly = normalizeLimitValue(user.limitWeeklyUsd);
@@ -310,6 +313,18 @@ export function UserKeyTableRow({
           {expiresText}
         </div>
 
+        {/* RPM 限额 */}
+        <div className="px-2 flex items-center justify-center">
+          <Badge
+            variant={rpm ? "secondary" : "outline"}
+            className="px-2 py-0.5 tabular-nums text-xs"
+            title={`${translations.columns.limitRpm}: ${rpm ?? "-"}`}
+            aria-label={`${translations.columns.limitRpm}: ${rpm ?? "-"}`}
+          >
+            {rpm ?? "-"}
+          </Badge>
+        </div>
+
         {/* 5h 限额 */}
         <div className="px-2 flex items-center justify-center">
           <UserLimitBadge

+ 8 - 2
src/app/[locale]/dashboard/_components/user/user-management-table.tsx

@@ -44,6 +44,7 @@ export interface UserManagementTableProps {
         note: string;
         expiresAt: string;
         expiresAtHint?: string;
+        limitRpm: string;
         limit5h: string;
         limitDaily: string;
         limitWeekly: string;
@@ -97,8 +98,8 @@ const KEY_ROW_BORDER_HEIGHT = 1;
 const EXPANDED_SECTION_PADDING = 24; // py-3 * 2
 const KEY_LIST_BORDER_HEIGHT = 2; // top + bottom border
 const EMPTY_KEYS_HEIGHT = 68; // py-6 * 2 + line height
-const MIN_TABLE_WIDTH_CLASS = "min-w-[980px]";
-const GRID_COLUMNS_CLASS = "grid-cols-[minmax(260px,1fr)_120px_repeat(6,90px)_80px]";
+const MIN_TABLE_WIDTH_CLASS = "min-w-[1070px]";
+const GRID_COLUMNS_CLASS = "grid-cols-[minmax(260px,1fr)_120px_repeat(7,90px)_80px]";
 
 export function UserManagementTable({
   users,
@@ -446,6 +447,11 @@ export function UserManagementTable({
                     {translations.table.columns.expiresAt}
                   </span>
                 </div>
+                <div className="px-2 text-center min-w-0">
+                  <span className="block truncate" title={translations.table.columns.limitRpm}>
+                    {translations.table.columns.limitRpm}
+                  </span>
+                </div>
                 <div className="px-2 text-center min-w-0">
                   <span className="block truncate" title={translations.table.columns.limit5h}>
                     {translations.table.columns.limit5h}

+ 2 - 2
src/app/[locale]/dashboard/quotas/users/_components/types.ts

@@ -1,8 +1,8 @@
 import type { CurrencyCode } from "@/lib/utils/currency";
 
 export interface UserQuotaSnapshot {
-  rpm: { current: number; limit: number; window: "per_minute" };
-  dailyCost: { current: number; limit: number; resetAt: Date };
+  rpm: { current: number; limit: number | null; window: "per_minute" };
+  dailyCost: { current: number; limit: number | null; resetAt: Date };
 }
 
 export interface UserKeyWithUsage {

+ 1 - 1
src/app/[locale]/dashboard/quotas/users/_components/user-quota-list-item.tsx

@@ -158,7 +158,7 @@ export function UserQuotaListItem({ user, currencyCode = "USD" }: UserQuotaListI
             </div>
             <Progress
               value={
-                user.quota?.rpm && user.quota.rpm.limit > 0
+                user.quota?.rpm && user.quota.rpm.limit !== null && user.quota.rpm.limit > 0
                   ? (user.quota.rpm.current / user.quota.rpm.limit) * 100
                   : 0
               }

+ 2 - 2
src/app/[locale]/dashboard/quotas/users/_components/users-quota-client.tsx

@@ -15,11 +15,11 @@ const COLLAPSIBLE_TRIGGER_CLASS =
 function getUsageRate(user: UserQuotaWithUsage): number {
   // W-014: 添加 NaN 防护
   const rpmRate =
-    user.quota?.rpm && user.quota.rpm.limit > 0
+    user.quota?.rpm && user.quota.rpm.limit !== null && user.quota.rpm.limit > 0
       ? ((user.quota.rpm.current ?? 0) / user.quota.rpm.limit) * 100
       : 0;
   const dailyRate =
-    user.quota?.dailyCost && user.quota.dailyCost.limit > 0
+    user.quota?.dailyCost && user.quota.dailyCost.limit !== null && user.quota.dailyCost.limit > 0
       ? ((user.quota.dailyCost.current ?? 0) / user.quota.dailyCost.limit) * 100
       : 0;
   const result = Math.max(rpmRate, dailyRate);

+ 92 - 8
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx

@@ -8,48 +8,117 @@ import { isSSEText } from "@/lib/utils/sse";
 
 export type SessionMessages = Record<string, unknown> | Record<string, unknown>[];
 
-function formatHeaders(headers: Record<string, string> | null): string | null {
-  if (!headers || Object.keys(headers).length === 0) return null;
-  return Object.entries(headers)
-    .map(([key, value]) => `${key}: ${value}`)
-    .join("\n");
+function formatHeaders(
+  headers: Record<string, string> | null,
+  preambleLines?: string[]
+): string | null {
+  const normalizedPreamble = (preambleLines ?? []).map((line) => line.trim()).filter(Boolean);
+  const preamble = normalizedPreamble.length > 0 ? normalizedPreamble.join("\n") : null;
+
+  const headerLines =
+    headers && Object.keys(headers).length > 0
+      ? Object.entries(headers)
+          .map(([key, value]) => `${key}: ${value}`)
+          .join("\n")
+      : null;
+
+  const combined = [preamble, headerLines].filter(Boolean).join("\n\n");
+  return combined.length > 0 ? combined : null;
 }
 
 interface SessionMessagesDetailsTabsProps {
+  requestBody: unknown | null;
   messages: SessionMessages | null;
   requestHeaders: Record<string, string> | null;
   responseHeaders: Record<string, string> | null;
   response: string | null;
+  requestMeta: { clientUrl: string | null; upstreamUrl: string | null; method: string | null };
+  responseMeta: { upstreamUrl: string | null; statusCode: number | null };
 }
 
 export function SessionMessagesDetailsTabs({
+  requestBody,
   messages,
   response,
   requestHeaders,
   responseHeaders,
+  requestMeta,
+  responseMeta,
 }: SessionMessagesDetailsTabsProps) {
   const t = useTranslations("dashboard.sessions");
   const codeExpandedMaxHeight = "calc(100vh - 260px)";
 
   const requestBodyContent = useMemo(() => {
+    if (requestBody === null) return null;
+    return JSON.stringify(requestBody, null, 2);
+  }, [requestBody]);
+
+  const requestMessagesContent = useMemo(() => {
     if (messages === null) return null;
     return JSON.stringify(messages, null, 2);
   }, [messages]);
 
-  const formattedRequestHeaders = useMemo(() => formatHeaders(requestHeaders), [requestHeaders]);
-  const formattedResponseHeaders = useMemo(() => formatHeaders(responseHeaders), [responseHeaders]);
+  const requestHeadersPreamble = useMemo(() => {
+    const lines: string[] = [];
+    const method = requestMeta.method?.trim() || null;
+
+    if (requestMeta.upstreamUrl) {
+      lines.push(
+        method
+          ? `UPSTREAM: ${method} ${requestMeta.upstreamUrl}`
+          : `UPSTREAM: ${requestMeta.upstreamUrl}`
+      );
+    }
+    if (requestMeta.clientUrl) {
+      lines.push(
+        method ? `CLIENT: ${method} ${requestMeta.clientUrl}` : `CLIENT: ${requestMeta.clientUrl}`
+      );
+    }
+
+    return lines;
+  }, [requestMeta.clientUrl, requestMeta.method, requestMeta.upstreamUrl]);
+
+  const responseHeadersPreamble = useMemo(() => {
+    const lines: string[] = [];
+
+    if (responseMeta.statusCode !== null && responseMeta.upstreamUrl) {
+      lines.push(`UPSTREAM: HTTP ${responseMeta.statusCode} ${responseMeta.upstreamUrl}`);
+      return lines;
+    }
+    if (responseMeta.statusCode !== null) {
+      lines.push(`UPSTREAM: HTTP ${responseMeta.statusCode}`);
+      return lines;
+    }
+    if (responseMeta.upstreamUrl) {
+      lines.push(`UPSTREAM: ${responseMeta.upstreamUrl}`);
+    }
+
+    return lines;
+  }, [responseMeta.statusCode, responseMeta.upstreamUrl]);
+
+  const formattedRequestHeaders = useMemo(
+    () => formatHeaders(requestHeaders, requestHeadersPreamble),
+    [requestHeaders, requestHeadersPreamble]
+  );
+  const formattedResponseHeaders = useMemo(
+    () => formatHeaders(responseHeaders, responseHeadersPreamble),
+    [responseHeaders, responseHeadersPreamble]
+  );
 
   const responseLanguage = response && isSSEText(response) ? "sse" : "json";
 
   return (
     <Tabs defaultValue="requestBody" className="w-full" data-testid="session-details-tabs">
-      <TabsList className="grid w-full grid-cols-4">
+      <TabsList className="grid w-full grid-cols-5">
         <TabsTrigger value="requestHeaders" data-testid="session-tab-trigger-request-headers">
           {t("details.requestHeaders")}
         </TabsTrigger>
         <TabsTrigger value="requestBody" data-testid="session-tab-trigger-request-body">
           {t("details.requestBody")}
         </TabsTrigger>
+        <TabsTrigger value="requestMessages" data-testid="session-tab-trigger-request-messages">
+          {t("details.requestMessages")}
+        </TabsTrigger>
         <TabsTrigger value="responseHeaders" data-testid="session-tab-trigger-response-headers">
           {t("details.responseHeaders")}
         </TabsTrigger>
@@ -88,6 +157,21 @@ export function SessionMessagesDetailsTabs({
         )}
       </TabsContent>
 
+      <TabsContent value="requestMessages" data-testid="session-tab-request-messages">
+        {requestMessagesContent === null ? (
+          <div className="text-muted-foreground p-4">{t("details.noData")}</div>
+        ) : (
+          <CodeDisplay
+            content={requestMessagesContent}
+            language="json"
+            fileName="request.messages.json"
+            maxHeight="600px"
+            defaultExpanded
+            expandedMaxHeight={codeExpandedMaxHeight}
+          />
+        )}
+      </TabsContent>
+
       <TabsContent value="responseHeaders" data-testid="session-tab-response-headers">
         {formattedResponseHeaders === null ? (
           <div className="text-muted-foreground p-4">{t("details.noHeaders")}</div>

+ 38 - 0
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx

@@ -15,6 +15,7 @@ const messages = {
       details: {
         requestHeaders: "Request Headers",
         requestBody: "Request Body",
+        requestMessages: "Request Messages",
         responseHeaders: "Response Headers",
         responseBody: "Response Body",
         noHeaders: "No data",
@@ -78,19 +79,42 @@ describe("SessionMessagesDetailsTabs", () => {
 
     const { container, unmount } = renderWithIntl(
       <SessionMessagesDetailsTabs
+        requestBody={{ model: "gpt-5.2", instructions: "test" }}
         messages={{ role: "user", content: "hi" }}
         response={sse}
         requestHeaders={{ a: "1" }}
         responseHeaders={{ b: "2" }}
+        requestMeta={{
+          clientUrl: "https://example.com/v1/responses",
+          upstreamUrl: null,
+          method: "POST",
+        }}
+        responseMeta={{ upstreamUrl: "https://api.example.com/v1/responses", statusCode: 200 }}
       />
     );
 
     expect(container.querySelector("[data-testid='session-details-tabs']")).not.toBeNull();
+    expect(
+      container.querySelector("[data-testid='session-tab-trigger-request-messages']")
+    ).not.toBeNull();
 
     const requestBody = container.querySelector(
       "[data-testid='session-tab-request-body'] [data-testid='code-display']"
     ) as HTMLElement;
     expect(requestBody.getAttribute("data-language")).toBe("json");
+    expect(container.textContent).toContain('"model": "gpt-5.2"');
+
+    const requestHeadersTrigger = container.querySelector(
+      "[data-testid='session-tab-trigger-request-headers']"
+    ) as HTMLElement;
+    click(requestHeadersTrigger);
+    expect(container.textContent).toContain("CLIENT: POST https://example.com/v1/responses");
+
+    const requestMessagesTrigger = container.querySelector(
+      "[data-testid='session-tab-trigger-request-messages']"
+    ) as HTMLElement;
+    click(requestMessagesTrigger);
+    expect(container.textContent).toContain('"content": "hi"');
 
     const responseBodyTrigger = container.querySelector(
       "[data-testid='session-tab-trigger-response-body']"
@@ -102,16 +126,27 @@ describe("SessionMessagesDetailsTabs", () => {
     ) as HTMLElement;
     expect(responseBody.getAttribute("data-language")).toBe("sse");
 
+    const responseHeadersTrigger = container.querySelector(
+      "[data-testid='session-tab-trigger-response-headers']"
+    ) as HTMLElement;
+    click(responseHeadersTrigger);
+    expect(container.textContent).toContain(
+      "UPSTREAM: HTTP 200 https://api.example.com/v1/responses"
+    );
+
     unmount();
   });
 
   test("detects JSON response when response is not SSE", () => {
     const { container, unmount } = renderWithIntl(
       <SessionMessagesDetailsTabs
+        requestBody={{ model: "gpt-5.2", instructions: "test" }}
         messages={{ role: "user", content: "hi" }}
         response='{"ok":true}'
         requestHeaders={{}}
         responseHeaders={{}}
+        requestMeta={{ clientUrl: null, upstreamUrl: null, method: null }}
+        responseMeta={{ upstreamUrl: null, statusCode: null }}
       />
     );
 
@@ -131,10 +166,13 @@ describe("SessionMessagesDetailsTabs", () => {
   test("renders empty states for missing data", () => {
     const { container, unmount } = renderWithIntl(
       <SessionMessagesDetailsTabs
+        requestBody={null}
         messages={null}
         response={null}
         requestHeaders={null}
         responseHeaders={null}
+        requestMeta={{ clientUrl: null, upstreamUrl: null, method: null }}
+        responseMeta={{ upstreamUrl: null, statusCode: null }}
       />
     );
 

+ 23 - 0
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx

@@ -56,9 +56,19 @@ export function SessionMessagesClient() {
   })();
 
   const [messages, setMessages] = useState<SessionMessages | null>(null);
+  const [requestBody, setRequestBody] = useState<unknown | null>(null);
   const [response, setResponse] = useState<string | null>(null);
   const [requestHeaders, setRequestHeaders] = useState<Record<string, string> | null>(null);
   const [responseHeaders, setResponseHeaders] = useState<Record<string, string> | null>(null);
+  const [requestMeta, setRequestMeta] = useState<{
+    clientUrl: string | null;
+    upstreamUrl: string | null;
+    method: string | null;
+  }>({ clientUrl: null, upstreamUrl: null, method: null });
+  const [responseMeta, setResponseMeta] = useState<{
+    upstreamUrl: string | null;
+    statusCode: number | null;
+  }>({ upstreamUrl: null, statusCode: null });
   const [sessionStats, setSessionStats] =
     useState<
       Extract<Awaited<ReturnType<typeof getSessionDetails>>, { ok: true }>["data"]["sessionStats"]
@@ -104,20 +114,29 @@ export function SessionMessagesClient() {
         if (cancelled) return;
 
         if (result.ok) {
+          setRequestBody(result.data.requestBody);
           const maybeMessages = result.data.messages;
           setMessages(isSessionMessages(maybeMessages) ? maybeMessages : null);
           setResponse(result.data.response);
           setRequestHeaders(result.data.requestHeaders);
           setResponseHeaders(result.data.responseHeaders);
+          setRequestMeta(result.data.requestMeta);
+          setResponseMeta(result.data.responseMeta);
           setSessionStats(result.data.sessionStats);
           setCurrentSequence(result.data.currentSequence);
           setPrevSequence(result.data.prevSequence);
           setNextSequence(result.data.nextSequence);
         } else {
+          setRequestBody(null);
+          setRequestMeta({ clientUrl: null, upstreamUrl: null, method: null });
+          setResponseMeta({ upstreamUrl: null, statusCode: null });
           setError(result.error || t("status.fetchFailed"));
         }
       } catch (err) {
         if (cancelled) return;
+        setRequestBody(null);
+        setRequestMeta({ clientUrl: null, upstreamUrl: null, method: null });
+        setResponseMeta({ upstreamUrl: null, statusCode: null });
         setError(err instanceof Error ? err.message : t("status.unknownError"));
       } finally {
         if (!cancelled) {
@@ -325,9 +344,12 @@ export function SessionMessagesClient() {
                   )}
                   <SessionMessagesDetailsTabs
                     messages={messages}
+                    requestBody={requestBody}
                     response={response}
                     requestHeaders={requestHeaders}
                     responseHeaders={responseHeaders}
+                    requestMeta={requestMeta}
+                    responseMeta={responseMeta}
                   />
 
                   <div className="flex items-center justify-between">
@@ -353,6 +375,7 @@ export function SessionMessagesClient() {
                 {/* 无数据提示 */}
                 {!sessionStats?.userAgent &&
                   !messages &&
+                  !requestBody &&
                   !response &&
                   !requestHeaders &&
                   !responseHeaders && (

+ 3 - 0
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -80,6 +80,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
     | "name"
     | "tags"
     | "expiresAt"
+    | "rpm"
     | "limit5hUsd"
     | "limitDailyUsd"
     | "limitWeeklyUsd"
@@ -428,6 +429,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
           username: tUserMgmt("table.columns.username"),
           note: tUserMgmt("table.columns.note"),
           expiresAt: tUserMgmt("table.columns.expiresAt"),
+          limitRpm: tUserMgmt("table.columns.limitRpm"),
           limit5h: tUserMgmt("table.columns.limit5h"),
           limitDaily: tUserMgmt("table.columns.limitDaily"),
           limitWeekly: tUserMgmt("table.columns.limitWeekly"),
@@ -590,6 +592,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
                 <SelectItem value="name">{t("toolbar.sortByName")}</SelectItem>
                 <SelectItem value="tags">{t("toolbar.sortByTags")}</SelectItem>
                 <SelectItem value="expiresAt">{t("toolbar.sortByExpiresAt")}</SelectItem>
+                <SelectItem value="rpm">{t("toolbar.sortByRpm")}</SelectItem>
                 <SelectItem value="limit5hUsd">{t("toolbar.sortByLimit5h")}</SelectItem>
                 <SelectItem value="limitDailyUsd">{t("toolbar.sortByLimitDaily")}</SelectItem>
                 <SelectItem value="limitWeeklyUsd">{t("toolbar.sortByLimitWeekly")}</SelectItem>

+ 71 - 4
src/app/[locale]/settings/providers/_components/forms/provider-form.tsx

@@ -10,6 +10,7 @@ import {
   AlertDialogCancel,
   AlertDialogContent,
   AlertDialogDescription,
+  AlertDialogFooter,
   AlertDialogTrigger,
   AlertDialogHeader as AlertHeader,
   AlertDialogTitle as AlertTitle,
@@ -200,6 +201,9 @@ export function ProviderForm({
     mcpPassthrough: false,
   });
 
+  // failureThreshold 确认对话框状态
+  const [showFailureThresholdConfirm, setShowFailureThresholdConfirm] = useState(false);
+
   // 从 localStorage 加载折叠偏好
   useEffect(() => {
     const saved = localStorage.getItem("provider-form-sections");
@@ -276,6 +280,19 @@ export function ProviderForm({
       return;
     }
 
+    // 检查 failureThreshold 是否为特殊值(0 或大于 100)
+    const threshold = failureThreshold ?? 5;
+    if (threshold === 0 || threshold > 100) {
+      setShowFailureThresholdConfirm(true);
+      return;
+    }
+
+    // 正常提交
+    performSubmit();
+  };
+
+  // 实际提交逻辑
+  const performSubmit = () => {
     // 处理模型重定向(空对象转为 null)
     const parsedModelRedirects = Object.keys(modelRedirects).length > 0 ? modelRedirects : null;
 
@@ -745,6 +762,7 @@ export function ProviderForm({
                   providerType={
                     providerType as
                       | "claude"
+                      | "claude-auth"
                       | "codex"
                       | "gemini"
                       | "gemini-cli"
@@ -753,6 +771,11 @@ export function ProviderForm({
                   selectedModels={allowedModels}
                   onChange={setAllowedModels}
                   disabled={isPending}
+                  providerUrl={url}
+                  apiKey={key}
+                  proxyUrl={proxyUrl}
+                  proxyFallbackToDirect={proxyFallbackToDirect}
+                  providerId={isEdit ? provider?.id : undefined}
                 />
 
                 {allowedModels.length > 0 && (
@@ -1183,8 +1206,7 @@ export function ProviderForm({
                     }}
                     placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")}
                     disabled={isPending}
-                    min="1"
-                    max="100"
+                    min="0"
                     step="1"
                   />
                   <p className="text-xs text-muted-foreground">
@@ -1670,6 +1692,51 @@ export function ProviderForm({
           </CollapsibleContent>
         </Collapsible>
 
+        {/* failureThreshold 特殊值确认对话框 */}
+        <AlertDialog
+          open={showFailureThresholdConfirm}
+          onOpenChange={setShowFailureThresholdConfirm}
+        >
+          <AlertDialogContent>
+            <AlertHeader>
+              <AlertTitle>{t("failureThresholdConfirmDialog.title")}</AlertTitle>
+              <AlertDialogDescription asChild>
+                <div className="space-y-3">
+                  {failureThreshold === 0 ? (
+                    <p>
+                      {t("failureThresholdConfirmDialog.descriptionDisabledPrefix")}
+                      <strong>{t("failureThresholdConfirmDialog.descriptionDisabledValue")}</strong>
+                      {t("failureThresholdConfirmDialog.descriptionDisabledMiddle")}
+                      <strong>
+                        {t("failureThresholdConfirmDialog.descriptionDisabledAction")}
+                      </strong>
+                      {t("failureThresholdConfirmDialog.descriptionDisabledSuffix")}
+                    </p>
+                  ) : (
+                    <p>
+                      {t("failureThresholdConfirmDialog.descriptionHighValuePrefix")}
+                      <strong>{failureThreshold}</strong>
+                      {t("failureThresholdConfirmDialog.descriptionHighValueSuffix")}
+                    </p>
+                  )}
+                  <p>{t("failureThresholdConfirmDialog.confirmQuestion")}</p>
+                </div>
+              </AlertDialogDescription>
+            </AlertHeader>
+            <AlertDialogFooter>
+              <AlertDialogCancel>{t("failureThresholdConfirmDialog.cancel")}</AlertDialogCancel>
+              <AlertDialogAction
+                onClick={() => {
+                  setShowFailureThresholdConfirm(false);
+                  performSubmit();
+                }}
+              >
+                {t("failureThresholdConfirmDialog.confirm")}
+              </AlertDialogAction>
+            </AlertDialogFooter>
+          </AlertDialogContent>
+        </AlertDialog>
+
         {isEdit ? (
           <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-4">
             <AlertDialog>
@@ -1687,7 +1754,7 @@ export function ProviderForm({
                     })}
                   </AlertDialogDescription>
                 </AlertHeader>
-                <div className="flex flex-col-reverse sm:flex-row gap-2 justify-end">
+                <AlertDialogFooter>
                   <AlertDialogCancel>{t("deleteDialog.cancel")}</AlertDialogCancel>
                   <AlertDialogAction
                     onClick={() => {
@@ -1709,7 +1776,7 @@ export function ProviderForm({
                   >
                     {t("deleteDialog.confirm")}
                   </AlertDialogAction>
-                </div>
+                </AlertDialogFooter>
               </AlertDialogContent>
             </AlertDialog>
 

+ 143 - 38
src/app/[locale]/settings/providers/_components/model-multi-select.tsx

@@ -1,8 +1,9 @@
 "use client";
-import { Check, ChevronsUpDown, Loader2, Plus } from "lucide-react";
+import { Check, ChevronsUpDown, Cloud, Database, Loader2, Plus, RefreshCw } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
 import { getAvailableModelsByProviderType } from "@/actions/model-prices";
+import { fetchUpstreamModels, getUnmaskedProviderKey } from "@/actions/providers";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Checkbox } from "@/components/ui/checkbox";
@@ -17,12 +18,26 @@ import {
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import type { ProviderType } from "@/types/provider";
+
+type ModelSource = "upstream" | "fallback" | "loading";
 
 interface ModelMultiSelectProps {
-  providerType: "claude" | "claude-auth" | "codex" | "gemini" | "gemini-cli" | "openai-compatible";
+  providerType: ProviderType;
   selectedModels: string[];
   onChange: (models: string[]) => void;
   disabled?: boolean;
+  /** 供应商 URL(用于获取上游模型列表) */
+  providerUrl?: string;
+  /** API Key(用于获取上游模型列表) */
+  apiKey?: string;
+  /** 代理 URL */
+  proxyUrl?: string | null;
+  /** 代理失败时是否回退到直连 */
+  proxyFallbackToDirect?: boolean;
+  /** 供应商 ID(编辑模式下用于获取未脱敏的 API Key) */
+  providerId?: number;
 }
 
 export function ModelMultiSelect({
@@ -30,12 +45,17 @@ export function ModelMultiSelect({
   selectedModels,
   onChange,
   disabled = false,
+  providerUrl,
+  apiKey,
+  proxyUrl,
+  proxyFallbackToDirect,
+  providerId,
 }: ModelMultiSelectProps) {
   const t = useTranslations("settings.providers.form.modelSelect");
   const [open, setOpen] = useState(false);
   const [availableModels, setAvailableModels] = useState<string[]>([]);
   const [loading, setLoading] = useState(true);
-  // 新增:手动输入自定义模型的状态
+  const [modelSource, setModelSource] = useState<ModelSource>("loading");
   const [customModel, setCustomModel] = useState("");
 
   // 供应商类型到显示名称的映射
@@ -51,16 +71,52 @@ export function ModelMultiSelect({
     return typeMap[type] || t("openai");
   };
 
-  // 当供应商类型变化时,重新加载模型列表
-  useEffect(() => {
-    async function loadModels() {
-      setLoading(true);
-      const models = await getAvailableModelsByProviderType();
-      setAvailableModels(models);
-      setLoading(false);
+  // 加载模型列表(优先上游,失败则回退)
+  const loadModels = useCallback(async () => {
+    setLoading(true);
+    setModelSource("loading");
+
+    // 尝试从上游获取模型列表
+    if (providerUrl) {
+      // 解析 API Key:优先使用表单中的 key,否则从数据库获取
+      let resolvedKey = apiKey?.trim() || "";
+
+      if (!resolvedKey && providerId) {
+        const keyResult = await getUnmaskedProviderKey(providerId);
+        if (keyResult.ok && keyResult.data?.key) {
+          resolvedKey = keyResult.data.key;
+        }
+      }
+
+      if (resolvedKey) {
+        const upstreamResult = await fetchUpstreamModels({
+          providerUrl,
+          apiKey: resolvedKey,
+          providerType,
+          proxyUrl,
+          proxyFallbackToDirect,
+        });
+
+        if (upstreamResult.ok && upstreamResult.data) {
+          setAvailableModels(upstreamResult.data.models);
+          setModelSource("upstream");
+          setLoading(false);
+          return;
+        }
+      }
     }
+
+    // 回退到全量模型列表
+    const fallbackModels = await getAvailableModelsByProviderType();
+    setAvailableModels(fallbackModels);
+    setModelSource("fallback");
+    setLoading(false);
+  }, [providerUrl, apiKey, providerId, providerType, proxyUrl, proxyFallbackToDirect]);
+
+  // 组件挂载时加载模型
+  useEffect(() => {
     loadModels();
-  }, []);
+  }, [loadModels]);
 
   const toggleModel = (model: string) => {
     if (selectedModels.includes(model)) {
@@ -73,22 +129,45 @@ export function ModelMultiSelect({
   const selectAll = () => onChange(availableModels);
   const clearAll = () => onChange([]);
 
-  // 新增:手动添加自定义模型
   const handleAddCustomModel = () => {
     const trimmed = customModel.trim();
     if (!trimmed) return;
 
     if (selectedModels.includes(trimmed)) {
-      // 已存在,清空输入框
       setCustomModel("");
       return;
     }
 
-    // 添加到选中列表
     onChange([...selectedModels, trimmed]);
     setCustomModel("");
   };
 
+  // 数据来源指示器
+  const SourceIndicator = () => {
+    if (loading) return null;
+
+    const isUpstream = modelSource === "upstream";
+    const Icon = isUpstream ? Cloud : Database;
+    const label = isUpstream ? t("sourceUpstream") : t("sourceFallback");
+    const description = isUpstream ? t("sourceUpstreamDesc") : t("sourceFallbackDesc");
+
+    return (
+      <TooltipProvider>
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 text-xs text-muted-foreground">
+              <Icon className="h-3 w-3" />
+              <span>{label}</span>
+            </div>
+          </TooltipTrigger>
+          <TooltipContent side="top" className="max-w-[200px]">
+            <p className="text-xs">{description}</p>
+          </TooltipContent>
+        </Tooltip>
+      </TooltipProvider>
+    );
+  };
+
   return (
     <Popover open={open} onOpenChange={setOpen}>
       <PopoverTrigger asChild>
@@ -135,32 +214,58 @@ export function ModelMultiSelect({
 
             {!loading && (
               <>
-                {/* 快捷操作 */}
+                {/* 数据来源指示 + 快捷操作 */}
                 <CommandGroup>
-                  <div className="flex gap-2 p-2">
-                    <Button
-                      size="sm"
-                      variant="outline"
-                      onClick={selectAll}
-                      className="flex-1"
-                      type="button"
-                    >
-                      {t("selectAll", { count: availableModels.length })}
-                    </Button>
-                    <Button
-                      size="sm"
-                      variant="outline"
-                      onClick={clearAll}
-                      disabled={selectedModels.length === 0}
-                      className="flex-1"
-                      type="button"
-                    >
-                      {t("clear")}
-                    </Button>
+                  <div className="flex items-center justify-between gap-2 p-2">
+                    <div className="flex items-center gap-2">
+                      <SourceIndicator />
+                      <TooltipProvider>
+                        <Tooltip>
+                          <TooltipTrigger asChild>
+                            <Button
+                              size="icon"
+                              variant="ghost"
+                              className="h-6 w-6"
+                              onClick={(e) => {
+                                e.stopPropagation();
+                                loadModels();
+                              }}
+                              type="button"
+                            >
+                              <RefreshCw className="h-3 w-3" />
+                            </Button>
+                          </TooltipTrigger>
+                          <TooltipContent side="top">
+                            <p className="text-xs">{t("refresh")}</p>
+                          </TooltipContent>
+                        </Tooltip>
+                      </TooltipProvider>
+                    </div>
+                    <div className="flex gap-2">
+                      <Button
+                        size="sm"
+                        variant="outline"
+                        onClick={selectAll}
+                        className="h-7 text-xs"
+                        type="button"
+                      >
+                        {t("selectAll", { count: availableModels.length })}
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant="outline"
+                        onClick={clearAll}
+                        disabled={selectedModels.length === 0}
+                        className="h-7 text-xs"
+                        type="button"
+                      >
+                        {t("clear")}
+                      </Button>
+                    </div>
                   </div>
                 </CommandGroup>
 
-                {/* 模型列表(不分组,字母排序) */}
+                {/* 模型列表 */}
                 <CommandGroup>
                   {availableModels.map((model) => (
                     <CommandItem
@@ -184,7 +289,7 @@ export function ModelMultiSelect({
           </CommandList>
         </Command>
 
-        {/* 新增:手动输入区域 */}
+        {/* 手动输入区域 */}
         <div className="border-t p-3 space-y-2">
           <Label className="text-xs font-medium">{t("manualAdd")}</Label>
           <div className="flex gap-2">

+ 10 - 5
src/app/v1/_lib/proxy/client-guard.ts

@@ -43,11 +43,16 @@ export class ProxyClientGuard {
       );
     }
 
-    // Case-insensitive substring match
-    const userAgentLower = userAgent.toLowerCase();
-    const isAllowed = allowedClients.some((pattern) =>
-      userAgentLower.includes(pattern.toLowerCase())
-    );
+    // Case-insensitive substring match with hyphen/underscore normalization
+    // This handles variations like "gemini-cli" matching "GeminiCLI" or "gemini_cli"
+    const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, "");
+    const userAgentNorm = normalize(userAgent);
+    const isAllowed = allowedClients.some((pattern) => {
+      const normalizedPattern = normalize(pattern);
+      // Skip empty patterns to prevent includes("") matching everything
+      if (normalizedPattern === "") return false;
+      return userAgentNorm.includes(normalizedPattern);
+    });
 
     if (!isAllowed) {
       return ProxyResponses.buildError(

+ 20 - 0
src/app/v1/_lib/proxy/forwarder.ts

@@ -879,6 +879,12 @@ export class ProxyForwarder {
       processedHeaders = headers;
 
       if (session.sessionId) {
+        void SessionManager.storeSessionUpstreamRequestMeta(
+          session.sessionId,
+          { url: proxyUrl, method: session.method },
+          session.requestSequence
+        ).catch((err) => logger.error("Failed to store upstream request meta:", err));
+
         void SessionManager.storeSessionRequestHeaders(
           session.sessionId,
           processedHeaders,
@@ -1100,6 +1106,14 @@ export class ProxyForwarder {
         usedBaseUrl: effectiveBaseUrl,
       });
 
+      if (session.sessionId) {
+        void SessionManager.storeSessionUpstreamRequestMeta(
+          session.sessionId,
+          { url: proxyUrl, method: session.method },
+          session.requestSequence
+        ).catch((err) => logger.error("Failed to store upstream request meta:", err));
+      }
+
       const hasBody = session.method !== "GET" && session.method !== "HEAD";
 
       if (hasBody) {
@@ -1899,6 +1913,12 @@ export class ProxyForwarder {
         responseHeaders,
         session.requestSequence
       ).catch((err) => logger.error("Failed to store response headers:", err));
+
+      void SessionManager.storeSessionUpstreamResponseMeta(
+        session.sessionId,
+        { url, statusCode: undiciRes.statusCode },
+        session.requestSequence
+      ).catch((err) => logger.error("Failed to store upstream response meta:", err));
     }
 
     // 检测响应是否为 gzip 压缩

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

@@ -143,30 +143,32 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 4. User RPM(频率闸门,挡住高频噪声)
-    const rpmCheck = await RateLimitService.checkUserRPM(user.id, user.rpm);
-    if (!rpmCheck.allowed) {
-      logger.warn(`[RateLimit] User RPM exceeded: user=${user.id}, ${rpmCheck.reason}`);
-
-      const resetTime = new Date(Date.now() + 60 * 1000).toISOString();
-
-      const { getLocale } = await import("next-intl/server");
-      const locale = await getLocale();
-      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_RPM_EXCEEDED, {
-        current: String(rpmCheck.current || 0),
-        limit: String(user.rpm),
-        resetTime,
-      });
-
-      throw new RateLimitError(
-        "rate_limit_error",
-        message,
-        "rpm",
-        rpmCheck.current || 0,
-        user.rpm,
-        resetTime,
-        null
-      );
+    // 4. User RPM(频率闸门,挡住高频噪声)- null 表示无限制
+    if (user.rpm !== null) {
+      const rpmCheck = await RateLimitService.checkUserRPM(user.id, user.rpm);
+      if (!rpmCheck.allowed) {
+        logger.warn(`[RateLimit] User RPM exceeded: user=${user.id}, ${rpmCheck.reason}`);
+
+        const resetTime = new Date(Date.now() + 60 * 1000).toISOString();
+
+        const { getLocale } = await import("next-intl/server");
+        const locale = await getLocale();
+        const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_RPM_EXCEEDED, {
+          current: String(rpmCheck.current || 0),
+          limit: String(user.rpm),
+          resetTime,
+        });
+
+        throw new RateLimitError(
+          "rate_limit_error",
+          message,
+          "rpm",
+          rpmCheck.current || 0,
+          user.rpm,
+          resetTime,
+          null
+        );
+      }
     }
 
     // ========== 第三层:短期周期限额(混合检查)==========
@@ -237,45 +239,47 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 7. User 每日额度(User 独有的常用预算)
-    const dailyCheck = await RateLimitService.checkUserDailyCost(
-      user.id,
-      user.dailyQuota,
-      user.dailyResetTime,
-      user.dailyResetMode
-    );
-
-    if (!dailyCheck.allowed) {
-      logger.warn(`[RateLimit] User daily limit exceeded: user=${user.id}, ${dailyCheck.reason}`);
-
-      // 使用用户配置的重置时间和模式计算正确的 resetTime
-      const resetInfo = getResetInfoWithMode("daily", user.dailyResetTime, user.dailyResetMode);
-      // rolling 模式没有 resetAt,使用 24 小时后作为 fallback
-      const resetTime =
-        resetInfo.resetAt?.toISOString() ??
-        new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
-
-      const { getLocale } = await import("next-intl/server");
-      const locale = await getLocale();
-      const message = await getErrorMessageServer(
-        locale,
-        ERROR_CODES.RATE_LIMIT_DAILY_QUOTA_EXCEEDED,
-        {
-          current: (dailyCheck.current || 0).toFixed(4),
-          limit: user.dailyQuota.toFixed(4),
-          resetTime,
-        }
-      );
-
-      throw new RateLimitError(
-        "rate_limit_error",
-        message,
-        "daily_quota",
-        dailyCheck.current || 0,
+    // 7. User 每日额度(User 独有的常用预算)- null 表示无限制
+    if (user.dailyQuota !== null) {
+      const dailyCheck = await RateLimitService.checkUserDailyCost(
+        user.id,
         user.dailyQuota,
-        resetTime,
-        null
+        user.dailyResetTime,
+        user.dailyResetMode
       );
+
+      if (!dailyCheck.allowed) {
+        logger.warn(`[RateLimit] User daily limit exceeded: user=${user.id}, ${dailyCheck.reason}`);
+
+        // 使用用户配置的重置时间和模式计算正确的 resetTime
+        const resetInfo = getResetInfoWithMode("daily", user.dailyResetTime, user.dailyResetMode);
+        // rolling 模式没有 resetAt,使用 24 小时后作为 fallback
+        const resetTime =
+          resetInfo.resetAt?.toISOString() ??
+          new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+        const { getLocale } = await import("next-intl/server");
+        const locale = await getLocale();
+        const message = await getErrorMessageServer(
+          locale,
+          ERROR_CODES.RATE_LIMIT_DAILY_QUOTA_EXCEEDED,
+          {
+            current: (dailyCheck.current || 0).toFixed(4),
+            limit: user.dailyQuota.toFixed(4),
+            resetTime,
+          }
+        );
+
+        throw new RateLimitError(
+          "rate_limit_error",
+          message,
+          "daily_quota",
+          dailyCheck.current || 0,
+          user.dailyQuota,
+          resetTime,
+          null
+        );
+      }
     }
 
     // ========== 第四层:中长期周期限额(混合检查)==========

+ 31 - 8
src/app/v1/_lib/proxy/session-guard.ts

@@ -67,6 +67,37 @@ export class ProxySessionGuard {
       const requestSequence = await SessionManager.getNextRequestSequence(sessionId);
       session.setRequestSequence(requestSequence);
 
+      // 4.2 存储完整请求体与客户端端点(用于 Session 详情调试)
+      // 注意:必须在后续任何格式转换/过滤前触发存储,避免记录被“后处理”污染
+      if (session.sessionId) {
+        void SessionManager.storeSessionRequestBody(
+          session.sessionId,
+          session.request.message,
+          requestSequence
+        ).catch((err) => {
+          logger.error("[ProxySessionGuard] Failed to store session request body:", err);
+        });
+
+        void SessionManager.storeSessionClientRequestMeta(
+          session.sessionId,
+          { url: session.requestUrl, method: session.method },
+          requestSequence
+        ).catch((err) => {
+          logger.error("[ProxySessionGuard] Failed to store client request meta:", err);
+        });
+
+        // 可选:存储 messages(受环境变量控制,按请求序号独立存储)
+        if (messages !== undefined) {
+          void SessionManager.storeSessionMessages(
+            session.sessionId,
+            messages,
+            requestSequence
+          ).catch((err) => {
+            logger.error("[ProxySessionGuard] Failed to store session messages:", err);
+          });
+        }
+      }
+
       // 5. 追踪 session(添加到活跃集合)
       void SessionTracker.trackSession(sessionId, keyId).catch((err) => {
         logger.error("[ProxySessionGuard] Failed to track session:", err);
@@ -87,14 +118,6 @@ export class ProxySessionGuard {
                 apiType: session.originalFormat === "openai" ? "codex" : "chat",
               });
             });
-
-            // 可选:存储 messages(受环境变量控制,按请求序号独立存储,带重试)
-            const messages = session.getMessages();
-            if (messages) {
-              await executeWithRetry(async () => {
-                await SessionManager.storeSessionMessages(sessionId, messages, requestSequence);
-              });
-            }
           }
         } catch (error) {
           // 重试后仍然失败,记录错误但不阻塞请求

+ 2 - 2
src/drizzle/schema.ts

@@ -23,8 +23,8 @@ export const users = pgTable('users', {
   name: varchar('name').notNull(),
   description: text('description'),
   role: varchar('role').default('user'),
-  rpmLimit: integer('rpm_limit').default(60),
-  dailyLimitUsd: numeric('daily_limit_usd', { precision: 10, scale: 2 }).default('100.00'),
+  rpmLimit: integer('rpm_limit'),
+  dailyLimitUsd: numeric('daily_limit_usd', { precision: 10, scale: 2 }),
   providerGroup: varchar('provider_group', { length: 50 }).default('default'),
   // 用户标签(用于分类和筛选)
   tags: jsonb('tags').$type<string[]>().default([]),

+ 2 - 1
src/lib/circuit-breaker.ts

@@ -245,7 +245,8 @@ export async function recordFailure(providerId: number, error: Error): Promise<v
   );
 
   // 检查是否需要打开熔断器
-  if (health.failureCount >= config.failureThreshold) {
+  // failureThreshold = 0 表示禁用熔断器
+  if (config.failureThreshold > 0 && health.failureCount >= config.failureThreshold) {
     health.circuitState = "open";
     health.circuitOpenUntil = Date.now() + config.openDuration;
     health.halfOpenSuccessCount = 0;

+ 2 - 7
src/lib/constants/user.constants.ts

@@ -1,14 +1,9 @@
 /**
- * 用户相关默认值与取值范围
+ * 用户相关取值范围
  */
-export const USER_DEFAULTS = {
-  RPM: 100,
-  DAILY_QUOTA: 100,
-} as const;
-
 export const USER_LIMITS = {
   RPM: {
-    MIN: 1,
+    MIN: 0, // 0 = 无限制
     MAX: 1_000_000, // 提升到 100 万
   },
   DAILY_QUOTA: {

+ 18 - 0
src/lib/emit-event.ts

@@ -16,6 +16,15 @@ export async function emitErrorRulesUpdated(): Promise<void> {
     } catch {
       // 忽略导入错误
     }
+
+    try {
+      const { CHANNEL_ERROR_RULES_UPDATED, publishCacheInvalidation } = await import(
+        "@/lib/redis/pubsub"
+      );
+      await publishCacheInvalidation(CHANNEL_ERROR_RULES_UPDATED);
+    } catch {
+      // 忽略导入错误
+    }
   }
 }
 
@@ -44,5 +53,14 @@ export async function emitRequestFiltersUpdated(): Promise<void> {
     } catch {
       // 忽略导入错误
     }
+
+    try {
+      const { CHANNEL_REQUEST_FILTERS_UPDATED, publishCacheInvalidation } = await import(
+        "@/lib/redis/pubsub"
+      );
+      await publishCacheInvalidation(CHANNEL_REQUEST_FILTERS_UPDATED);
+    } catch {
+      // 忽略导入错误
+    }
   }
 }

+ 11 - 2
src/lib/error-rule-detector.ts

@@ -97,14 +97,23 @@ class ErrorRuleDetector {
     if (typeof process !== "undefined" && process.env.NEXT_RUNTIME !== "edge") {
       try {
         const { eventEmitter } = await import("@/lib/event-emitter");
-        eventEmitter.on("errorRulesUpdated", () => {
+        const handleUpdated = () => {
           // 重置标记,强制下次从数据库重新加载
           this.dbLoadedSuccessfully = false;
           this.isInitialized = false;
           this.reload().catch((error) => {
             logger.error("[ErrorRuleDetector] Failed to reload cache on event:", error);
           });
-        });
+        };
+
+        // 同进程事件(用于单 worker 的即时更新)
+        eventEmitter.on("errorRulesUpdated", handleUpdated);
+
+        // 跨进程通知(用于多 worker / 多实例)
+        const { CHANNEL_ERROR_RULES_UPDATED, subscribeCacheInvalidation } = await import(
+          "@/lib/redis/pubsub"
+        );
+        await subscribeCacheInvalidation(CHANNEL_ERROR_RULES_UPDATED, handleUpdated);
       } catch {
         // 忽略导入错误(可能在 Edge runtime 中)
       }

+ 91 - 0
src/lib/redis/__tests__/pubsub.test.ts

@@ -0,0 +1,91 @@
+import { EventEmitter } from "node:events";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+class MockRedis extends EventEmitter {
+  publish = vi.fn();
+  subscribe = vi.fn();
+  unsubscribe = vi.fn();
+  quit = vi.fn();
+  duplicate = vi.fn();
+}
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis/client", () => ({
+  getRedisClient: vi.fn(),
+}));
+
+describe("Redis Pub/Sub 缓存失效通知", () => {
+  beforeEach(() => {
+    vi.resetModules();
+  });
+
+  test("publishCacheInvalidation: should publish message to channel", async () => {
+    const base = new MockRedis();
+    base.publish.mockResolvedValue(1);
+
+    const { getRedisClient } = await import("@/lib/redis/client");
+    (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(base);
+
+    const { publishCacheInvalidation } = await import("@/lib/redis/pubsub");
+    await publishCacheInvalidation("test-channel");
+
+    expect(base.publish).toHaveBeenCalledTimes(1);
+    const [channel, message] = base.publish.mock.calls[0] as [unknown, unknown];
+    expect(channel).toBe("test-channel");
+    expect(typeof message).toBe("string");
+    expect((message as string).length).toBeGreaterThan(0);
+  });
+
+  test("publishCacheInvalidation: should handle Redis not available gracefully", async () => {
+    const { getRedisClient } = await import("@/lib/redis/client");
+    (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(null);
+
+    const { publishCacheInvalidation } = await import("@/lib/redis/pubsub");
+    await expect(publishCacheInvalidation("test-channel")).resolves.toBeUndefined();
+  });
+
+  test("subscribeCacheInvalidation: should register callback and receive messages", async () => {
+    const base = new MockRedis();
+    const subscriber = new MockRedis();
+    base.duplicate.mockReturnValue(subscriber);
+    subscriber.subscribe.mockResolvedValue(1);
+
+    const { getRedisClient } = await import("@/lib/redis/client");
+    (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(base);
+
+    const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
+    const onInvalidate = vi.fn();
+
+    const cleanup = await subscribeCacheInvalidation("test-channel", onInvalidate);
+    expect(typeof cleanup).toBe("function");
+
+    expect(base.duplicate).toHaveBeenCalledTimes(1);
+    expect(subscriber.subscribe).toHaveBeenCalledWith("test-channel");
+
+    subscriber.emit("message", "test-channel", Date.now().toString());
+    expect(onInvalidate).toHaveBeenCalledTimes(1);
+
+    cleanup();
+    subscriber.emit("message", "test-channel", Date.now().toString());
+    expect(onInvalidate).toHaveBeenCalledTimes(1);
+  });
+
+  test("subscribeCacheInvalidation: should handle Redis not configured gracefully", async () => {
+    const { getRedisClient } = await import("@/lib/redis/client");
+    (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(null);
+
+    const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
+    const cleanup = await subscribeCacheInvalidation("test-channel", vi.fn());
+
+    expect(typeof cleanup).toBe("function");
+    expect(() => cleanup()).not.toThrow();
+  });
+});

+ 97 - 0
src/lib/redis/pubsub.ts

@@ -0,0 +1,97 @@
+import "server-only";
+
+import type Redis from "ioredis";
+import { logger } from "@/lib/logger";
+import { getRedisClient } from "./client";
+
+export const CHANNEL_ERROR_RULES_UPDATED = "cch:cache:error_rules:updated";
+export const CHANNEL_REQUEST_FILTERS_UPDATED = "cch:cache:request_filters:updated";
+
+type CacheInvalidationCallback = () => void;
+
+let subscriberClient: Redis | null = null;
+const subscriptions = new Map<string, Set<CacheInvalidationCallback>>();
+
+function ensureSubscriber(baseClient: Redis): Redis {
+  if (subscriberClient) return subscriberClient;
+
+  // 订阅必须使用独立连接(Pub/Sub 模式下连接不能再执行普通命令)
+  subscriberClient = baseClient.duplicate();
+
+  subscriberClient.on("message", (channel: string) => {
+    const callbacks = subscriptions.get(channel);
+    if (!callbacks || callbacks.size === 0) return;
+
+    for (const cb of callbacks) {
+      try {
+        cb();
+      } catch (error) {
+        logger.error("[RedisPubSub] Callback error", { channel, error });
+      }
+    }
+  });
+
+  subscriberClient.on("error", (error) => {
+    logger.warn("[RedisPubSub] Subscriber connection error", { error });
+  });
+
+  return subscriberClient;
+}
+
+/**
+ * 发布缓存失效通知(失败不抛错,自动降级)
+ */
+export async function publishCacheInvalidation(channel: string): Promise<void> {
+  const redis = getRedisClient();
+  if (!redis) return;
+
+  try {
+    await redis.publish(channel, Date.now().toString());
+  } catch (error) {
+    logger.warn("[RedisPubSub] Failed to publish cache invalidation", { channel, error });
+  }
+}
+
+/**
+ * 订阅缓存失效通知(失败不抛错,自动降级)
+ *
+ * 返回取消订阅函数(用于释放回调引用)
+ */
+export async function subscribeCacheInvalidation(
+  channel: string,
+  callback: CacheInvalidationCallback
+): Promise<() => void> {
+  const baseClient = getRedisClient();
+  if (!baseClient) return () => {};
+
+  try {
+    const sub = ensureSubscriber(baseClient);
+
+    const existing = subscriptions.get(channel);
+    const isFirstSubscriberForChannel = !existing;
+    const callbacks = existing ?? new Set<CacheInvalidationCallback>();
+    callbacks.add(callback);
+    subscriptions.set(channel, callbacks);
+
+    if (isFirstSubscriberForChannel) {
+      await sub.subscribe(channel);
+    }
+
+    return () => {
+      const cbs = subscriptions.get(channel);
+      if (!cbs) return;
+
+      cbs.delete(callback);
+
+      if (cbs.size === 0) {
+        subscriptions.delete(channel);
+        if (subscriberClient) {
+          void subscriberClient.unsubscribe(channel);
+        }
+      }
+    };
+  } catch (error) {
+    logger.warn("[RedisPubSub] Failed to subscribe cache invalidation", { channel, error });
+    return () => {};
+  }
+}

+ 18 - 0
src/lib/request-filter-engine.ts

@@ -121,6 +121,7 @@ export class RequestFilterEngine {
 
   // Optimization #1: Memory leak cleanup
   private eventEmitterCleanup: (() => void) | null = null;
+  private redisPubSubCleanup: (() => void) | null = null;
 
   // Optimization #5: Skip tag parsing when no group filters
   private hasGroupBasedFilters = false;
@@ -144,6 +145,19 @@ export class RequestFilterEngine {
         this.eventEmitterCleanup = () => {
           eventEmitter.off("requestFiltersUpdated", handler);
         };
+
+        // 跨进程通知(用于多 worker / 多实例)
+        try {
+          const { CHANNEL_REQUEST_FILTERS_UPDATED, subscribeCacheInvalidation } = await import(
+            "@/lib/redis/pubsub"
+          );
+          this.redisPubSubCleanup = await subscribeCacheInvalidation(
+            CHANNEL_REQUEST_FILTERS_UPDATED,
+            handler
+          );
+        } catch {
+          // 忽略导入错误
+        }
       } catch {
         // 忽略导入错误
       }
@@ -156,6 +170,10 @@ export class RequestFilterEngine {
       this.eventEmitterCleanup();
       this.eventEmitterCleanup = null;
     }
+    if (this.redisPubSubCleanup) {
+      this.redisPubSubCleanup();
+      this.redisPubSubCleanup = null;
+    }
   }
 
   async reload(): Promise<void> {

+ 240 - 1
src/lib/session-manager.ts

@@ -2,7 +2,7 @@ import "server-only";
 
 import crypto from "node:crypto";
 import { extractCodexSessionId } from "@/app/v1/_lib/codex/session-extractor";
-import { sanitizeHeaders } from "@/app/v1/_lib/proxy/errors";
+import { sanitizeHeaders, sanitizeUrl } from "@/app/v1/_lib/proxy/errors";
 import { logger } from "@/lib/logger";
 import { normalizeRequestSequence } from "@/lib/utils/request-sequence";
 import type {
@@ -57,6 +57,16 @@ function parseHeaderRecord(value: string): Record<string, string> | null {
   }
 }
 
+type SessionRequestMeta = {
+  url: string;
+  method: string;
+};
+
+type SessionResponseMeta = {
+  url: string;
+  statusCode: number;
+};
+
 /**
  * Session 管理器
  *
@@ -1346,6 +1356,235 @@ export class SessionManager {
     }
   }
 
+  /**
+   * 存储 session 完整请求体(客户端原始请求体,临时存储,5分钟过期)
+   *
+   * 注意:此数据可能较大且包含用户输入,仅在 STORE_SESSION_MESSAGES=true 时启用。
+   *
+   * @param sessionId - Session ID
+   * @param requestBody - 请求体(完整 JSON)
+   * @param requestSequence - 可选,请求序号
+   */
+  static async storeSessionRequestBody(
+    sessionId: string,
+    requestBody: unknown,
+    requestSequence?: number
+  ): Promise<void> {
+    if (!SessionManager.STORE_MESSAGES) {
+      logger.trace("SessionManager: STORE_SESSION_MESSAGES is disabled, skipping request body");
+      return;
+    }
+
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence) ?? 1;
+      const key = `session:${sessionId}:req:${sequence}:requestBody`;
+      const payload = JSON.stringify(requestBody);
+      await redis.setex(key, SessionManager.SESSION_TTL, payload);
+      logger.trace("SessionManager: Stored session request body", {
+        sessionId,
+        requestSequence: sequence,
+        key,
+        size: payload.length,
+      });
+    } catch (error) {
+      logger.error("SessionManager: Failed to store session request body", { error, sessionId });
+    }
+  }
+
+  /**
+   * 获取 session 完整请求体(客户端原始请求体)
+   *
+   * @param sessionId - Session ID
+   * @param requestSequence - 请求序号
+   * @returns 解析后的 JSON 对象
+   */
+  static async getSessionRequestBody(
+    sessionId: string,
+    requestSequence?: number
+  ): Promise<unknown | null> {
+    if (!SessionManager.STORE_MESSAGES) {
+      logger.warn("SessionManager: STORE_SESSION_MESSAGES is disabled");
+      return null;
+    }
+
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return null;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence);
+      if (!sequence) return null;
+      const key = `session:${sessionId}:req:${sequence}:requestBody`;
+      const value = await redis.get(key);
+      if (!value) return null;
+      return JSON.parse(value) as unknown;
+    } catch (error) {
+      logger.error("SessionManager: Failed to get session request body", { error, sessionId });
+      return null;
+    }
+  }
+
+  /**
+   * 存储客户端请求元信息(端点/方法,临时存储,5分钟过期)
+   *
+   * @param sessionId - Session ID
+   * @param meta - 元信息
+   * @param requestSequence - 请求序号
+   */
+  static async storeSessionClientRequestMeta(
+    sessionId: string,
+    meta: { url: string | URL; method: string },
+    requestSequence?: number
+  ): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence) ?? 1;
+      const key = `session:${sessionId}:req:${sequence}:clientReqMeta`;
+      const payload: SessionRequestMeta = {
+        url: sanitizeUrl(meta.url),
+        method: meta.method,
+      };
+      await redis.setex(key, SessionManager.SESSION_TTL, JSON.stringify(payload));
+    } catch (error) {
+      logger.error("SessionManager: Failed to store client request meta", { error, sessionId });
+    }
+  }
+
+  static async getSessionClientRequestMeta(
+    sessionId: string,
+    requestSequence?: number
+  ): Promise<SessionRequestMeta | null> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return null;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence);
+      if (!sequence) return null;
+      const key = `session:${sessionId}:req:${sequence}:clientReqMeta`;
+      const value = await redis.get(key);
+      if (!value) return null;
+
+      const parsed: unknown = JSON.parse(value);
+      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
+      const obj = parsed as Record<string, unknown>;
+      if (typeof obj.url !== "string" || typeof obj.method !== "string") return null;
+      return { url: obj.url, method: obj.method };
+    } catch (error) {
+      logger.error("SessionManager: Failed to get client request meta", { error, sessionId });
+      return null;
+    }
+  }
+
+  /**
+   * 存储上游请求元信息(端点/方法,临时存储,5分钟过期)
+   *
+   * @param sessionId - Session ID
+   * @param meta - 元信息
+   * @param requestSequence - 请求序号
+   */
+  static async storeSessionUpstreamRequestMeta(
+    sessionId: string,
+    meta: { url: string | URL; method: string },
+    requestSequence?: number
+  ): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence) ?? 1;
+      const key = `session:${sessionId}:req:${sequence}:upstreamReqMeta`;
+      const payload: SessionRequestMeta = {
+        url: sanitizeUrl(meta.url),
+        method: meta.method,
+      };
+      await redis.setex(key, SessionManager.SESSION_TTL, JSON.stringify(payload));
+    } catch (error) {
+      logger.error("SessionManager: Failed to store upstream request meta", { error, sessionId });
+    }
+  }
+
+  static async getSessionUpstreamRequestMeta(
+    sessionId: string,
+    requestSequence?: number
+  ): Promise<SessionRequestMeta | null> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return null;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence);
+      if (!sequence) return null;
+      const key = `session:${sessionId}:req:${sequence}:upstreamReqMeta`;
+      const value = await redis.get(key);
+      if (!value) return null;
+
+      const parsed: unknown = JSON.parse(value);
+      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
+      const obj = parsed as Record<string, unknown>;
+      if (typeof obj.url !== "string" || typeof obj.method !== "string") return null;
+      return { url: obj.url, method: obj.method };
+    } catch (error) {
+      logger.error("SessionManager: Failed to get upstream request meta", { error, sessionId });
+      return null;
+    }
+  }
+
+  /**
+   * 存储上游响应元信息(端点/状态码,临时存储,5分钟过期)
+   *
+   * @param sessionId - Session ID
+   * @param meta - 元信息
+   * @param requestSequence - 请求序号
+   */
+  static async storeSessionUpstreamResponseMeta(
+    sessionId: string,
+    meta: { url: string | URL; statusCode: number },
+    requestSequence?: number
+  ): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence) ?? 1;
+      const key = `session:${sessionId}:req:${sequence}:upstreamResMeta`;
+      const payload: SessionResponseMeta = {
+        url: sanitizeUrl(meta.url),
+        statusCode: meta.statusCode,
+      };
+      await redis.setex(key, SessionManager.SESSION_TTL, JSON.stringify(payload));
+    } catch (error) {
+      logger.error("SessionManager: Failed to store upstream response meta", { error, sessionId });
+    }
+  }
+
+  static async getSessionUpstreamResponseMeta(
+    sessionId: string,
+    requestSequence?: number
+  ): Promise<SessionResponseMeta | null> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return null;
+
+    try {
+      const sequence = normalizeRequestSequence(requestSequence);
+      if (!sequence) return null;
+      const key = `session:${sessionId}:req:${sequence}:upstreamResMeta`;
+      const value = await redis.get(key);
+      if (!value) return null;
+
+      const parsed: unknown = JSON.parse(value);
+      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
+      const obj = parsed as Record<string, unknown>;
+      if (typeof obj.url !== "string" || typeof obj.statusCode !== "number") return null;
+      return { url: obj.url, statusCode: obj.statusCode };
+    } catch (error) {
+      logger.error("SessionManager: Failed to get upstream response meta", { error, sessionId });
+      return null;
+    }
+  }
+
   static async storeSessionRequestHeaders(
     sessionId: string,
     headers: Headers,

+ 5 - 7
src/lib/validation/schemas.ts

@@ -4,7 +4,7 @@ import {
   PROVIDER_LIMITS,
   PROVIDER_TIMEOUT_LIMITS,
 } from "@/lib/constants/provider.constants";
-import { USER_DEFAULTS, USER_LIMITS } from "@/lib/constants/user.constants";
+import { USER_LIMITS } from "@/lib/constants/user.constants";
 import { CURRENCY_CONFIG } from "@/lib/utils/currency";
 
 const CACHE_TTL_PREFERENCE = z.enum(["inherit", "5m", "1h"]);
@@ -27,8 +27,8 @@ export const CreateUserSchema = z.object({
     .int("RPM必须是整数")
     .min(USER_LIMITS.RPM.MIN, `RPM不能低于${USER_LIMITS.RPM.MIN}`)
     .max(USER_LIMITS.RPM.MAX, `RPM不能超过${USER_LIMITS.RPM.MAX}`)
-    .optional()
-    .default(USER_DEFAULTS.RPM),
+    .nullable()
+    .optional(),
   dailyQuota: z.coerce
     .number()
     .min(USER_LIMITS.DAILY_QUOTA.MIN, `每日额度不能低于${USER_LIMITS.DAILY_QUOTA.MIN}美元`)
@@ -437,8 +437,7 @@ export const CreateProviderSchema = z.object({
   circuit_breaker_failure_threshold: z.coerce
     .number()
     .int("失败阈值必须是整数")
-    .min(1, "失败阈值不能少于1次")
-    .max(100, "失败阈值不能超过100次")
+    .min(0, "失败阈值不能为负数")
     .optional(),
   circuit_breaker_open_duration: z.coerce
     .number()
@@ -626,8 +625,7 @@ export const UpdateProviderSchema = z
     circuit_breaker_failure_threshold: z.coerce
       .number()
       .int("失败阈值必须是整数")
-      .min(1, "失败阈值不能少于1次")
-      .max(100, "失败阈值不能超过100次")
+      .min(0, "失败阈值不能为负数")
       .optional(),
     circuit_breaker_open_duration: z.coerce
       .number()

+ 1 - 0
src/lib/webhook/renderers/wechat.ts

@@ -57,6 +57,7 @@ export class WeChatRenderer implements Renderer {
       case "warning":
         return "⚠️";
       case "info":
+        return "📊";
       default:
         return "📊";
     }

+ 238 - 0
src/repository/_shared/transformers.test.ts

@@ -0,0 +1,238 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+  toKey,
+  toMessageRequest,
+  toModelPrice,
+  toProvider,
+  toSystemSettings,
+  toUser,
+} from "./transformers";
+
+describe("src/repository/_shared/transformers.ts", () => {
+  const now = new Date("2024-01-02T03:04:05.000Z");
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(now);
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  describe("toUser()", () => {
+    const baseDbUser = {
+      id: 1,
+      name: "test-user",
+      description: "",
+      role: "user",
+      providerGroup: null,
+      tags: [],
+      limitTotalUsd: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
+      isEnabled: true,
+      expiresAt: null,
+      allowedClients: [],
+      allowedModels: [],
+      createdAt: new Date("2024-01-01T00:00:00.000Z"),
+      updatedAt: new Date("2024-01-01T00:00:00.000Z"),
+    };
+
+    describe("rpm 字段处理", () => {
+      /**
+       * 注意:rpm <= 0 表示"无限制",在 toUser() 中统一归一化为 null
+       */
+      it.each([
+        { title: "dbUser.rpm = null -> null", rpm: null, expected: null },
+        { title: "dbUser.rpm = undefined -> null", rpm: undefined, expected: null },
+        { title: "dbUser.rpm = 0 -> null(0 表示无限制)", rpm: 0, expected: null },
+        { title: "dbUser.rpm = 60 -> 60", rpm: 60, expected: 60 },
+      ])("$title", ({ rpm, expected }) => {
+        const result = toUser({ ...baseDbUser, rpm });
+        expect(result.rpm).toBe(expected);
+      });
+    });
+
+    describe("dailyQuota 字段处理", () => {
+      /**
+       * 注意:这里显式记录当前行为
+       * - `dailyQuota <= 0`(含字符串 "0" / "0.00")表示“无限制”,在 `toUser()` 中统一归一化为 `null`。
+       */
+      it.each([
+        { title: "dbUser.dailyQuota = null -> null", dailyQuota: null, expected: null },
+        { title: "dbUser.dailyQuota = undefined -> null", dailyQuota: undefined, expected: null },
+        { title: 'dbUser.dailyQuota = "0" -> null', dailyQuota: "0", expected: null },
+        { title: 'dbUser.dailyQuota = "0.00" -> null', dailyQuota: "0.00", expected: null },
+        { title: 'dbUser.dailyQuota = "100.00" -> 100', dailyQuota: "100.00", expected: 100 },
+        { title: 'dbUser.dailyQuota = "0.01" -> 0.01', dailyQuota: "0.01", expected: 0.01 },
+      ])("$title", ({ dailyQuota, expected }) => {
+        const result = toUser({ ...baseDbUser, dailyQuota });
+        expect(result.dailyQuota).toBe(expected);
+      });
+    });
+
+    it("createdAt/updatedAt 缺失时默认使用当前时间", () => {
+      const result = toUser({
+        ...baseDbUser,
+        createdAt: undefined,
+        updatedAt: undefined,
+      });
+
+      expect(result.createdAt).toEqual(now);
+      expect(result.updatedAt).toEqual(now);
+    });
+  });
+
+  describe("toKey()", () => {
+    it("按约定设置默认值与数值转换", () => {
+      const result = toKey({
+        id: 1,
+        userId: 1,
+        name: "k1",
+        key: "sk-test",
+        isEnabled: undefined,
+        canLoginWebUi: undefined,
+        limit5hUsd: "12.34",
+        limitDailyUsd: null,
+        dailyResetTime: undefined,
+        limitWeeklyUsd: "0",
+        limitMonthlyUsd: undefined,
+        limitTotalUsd: "0",
+        limitConcurrentSessions: undefined,
+        providerGroup: undefined,
+        cacheTtlPreference: undefined,
+        createdAt: undefined,
+        updatedAt: undefined,
+      });
+
+      expect(result.isEnabled).toBe(true);
+      expect(result.canLoginWebUi).toBe(true);
+      expect(result.limit5hUsd).toBe(12.34);
+      expect(result.limitDailyUsd).toBeNull();
+      expect(result.dailyResetTime).toBe("00:00");
+      expect(result.limitWeeklyUsd).toBe(0);
+      expect(result.limitMonthlyUsd).toBeNull();
+      expect(result.limitTotalUsd).toBe(0);
+      expect(result.limitConcurrentSessions).toBe(0);
+      expect(result.providerGroup).toBeNull();
+      expect(result.cacheTtlPreference).toBeNull();
+      expect(result.createdAt).toEqual(now);
+      expect(result.updatedAt).toEqual(now);
+    });
+  });
+
+  describe("toProvider()", () => {
+    it("按约定设置默认值与数值转换", () => {
+      const result = toProvider({
+        id: 1,
+        name: "p1",
+        url: "https://example.com",
+        key: "k1",
+        isEnabled: undefined,
+        weight: undefined,
+        priority: undefined,
+        costMultiplier: "1.25",
+        preserveClientIp: undefined,
+        maxRetryAttempts: "3",
+        createdAt: undefined,
+        updatedAt: undefined,
+      });
+
+      expect(result.isEnabled).toBe(true);
+      expect(result.weight).toBe(1);
+      expect(result.priority).toBe(0);
+      expect(result.costMultiplier).toBe(1.25);
+      expect(result.providerType).toBe("claude");
+      expect(result.preserveClientIp).toBe(false);
+      expect(result.groupTag).toBeNull();
+      expect(result.maxRetryAttempts).toBe(3);
+      expect(result.circuitBreakerFailureThreshold).toBe(5);
+      expect(result.circuitBreakerOpenDuration).toBe(1800000);
+      expect(result.firstByteTimeoutStreamingMs).toBe(30000);
+      expect(result.streamingIdleTimeoutMs).toBe(10000);
+      expect(result.requestTimeoutNonStreamingMs).toBe(600000);
+      expect(result.createdAt).toEqual(now);
+      expect(result.updatedAt).toEqual(now);
+    });
+  });
+
+  describe("toMessageRequest()", () => {
+    it("costUsd 归一化为存储字符串(缺失/无效则为 undefined)", () => {
+      const withCost = toMessageRequest({
+        id: 1,
+        costUsd: "1",
+        createdAt: undefined,
+        updatedAt: undefined,
+      });
+      expect(withCost.costUsd).toBe("1.000000000000000");
+
+      const withoutCost = toMessageRequest({
+        id: 2,
+        costUsd: null,
+        createdAt: undefined,
+        updatedAt: undefined,
+      });
+      expect(withoutCost.costUsd).toBeUndefined();
+
+      const emptyCost = toMessageRequest({
+        id: 3,
+        costUsd: "",
+        createdAt: undefined,
+        updatedAt: undefined,
+      });
+      expect(emptyCost.costUsd).toBeUndefined();
+    });
+
+    it("对可选字段进行 null/undefined 归一化", () => {
+      const result = toMessageRequest({
+        id: 1,
+        costMultiplier: "1.5",
+        requestSequence: null,
+        cacheCreation5mInputTokens: 0,
+        cacheCreation1hInputTokens: undefined,
+        cacheTtlApplied: undefined,
+        context1mApplied: undefined,
+        createdAt: undefined,
+        updatedAt: undefined,
+      });
+
+      expect(result.costMultiplier).toBe(1.5);
+      expect(result.requestSequence).toBeUndefined();
+      expect(result.cacheCreation5mInputTokens).toBe(0);
+      expect(result.cacheCreation1hInputTokens).toBeUndefined();
+      expect(result.cacheTtlApplied).toBeNull();
+      expect(result.context1mApplied).toBe(false);
+      expect(result.createdAt).toEqual(now);
+      expect(result.updatedAt).toEqual(now);
+    });
+  });
+
+  describe("toModelPrice()", () => {
+    it("createdAt/updatedAt 缺失时默认使用当前时间", () => {
+      const result = toModelPrice({ id: 1, createdAt: undefined, updatedAt: undefined });
+      expect(result.createdAt).toEqual(now);
+      expect(result.updatedAt).toEqual(now);
+    });
+  });
+
+  describe("toSystemSettings()", () => {
+    it("dbSettings 缺失时返回默认值", () => {
+      const result = toSystemSettings(undefined);
+      expect(result.id).toBe(0);
+      expect(result.siteTitle).toBe("Claude Code Hub");
+      expect(result.allowGlobalUsageView).toBe(true);
+      expect(result.currencyDisplay).toBe("USD");
+      expect(result.billingModelSource).toBe("original");
+      expect(result.enableAutoCleanup).toBe(false);
+      expect(result.cleanupRetentionDays).toBe(30);
+      expect(result.cleanupSchedule).toBe("0 2 * * *");
+      expect(result.cleanupBatchSize).toBe(10000);
+      expect(result.enableClientVersionCheck).toBe(false);
+      expect(result.verboseProviderError).toBe(false);
+      expect(result.enableHttp2).toBe(false);
+      expect(result.createdAt).toEqual(now);
+      expect(result.updatedAt).toEqual(now);
+    });
+  });
+});

+ 10 - 2
src/repository/_shared/transformers.ts

@@ -12,8 +12,16 @@ export function toUser(dbUser: any): User {
     ...dbUser,
     description: dbUser?.description || "",
     role: (dbUser?.role as User["role"]) || "user",
-    rpm: dbUser?.rpm || 60,
-    dailyQuota: dbUser?.dailyQuota ? parseFloat(dbUser.dailyQuota) : 0,
+    rpm: (() => {
+      if (dbUser?.rpm === null || dbUser?.rpm === undefined) return null;
+      const parsed = Number(dbUser.rpm);
+      return parsed > 0 ? parsed : null;
+    })(),
+    dailyQuota: (() => {
+      if (dbUser?.dailyQuota === null || dbUser?.dailyQuota === undefined) return null;
+      const parsed = Number.parseFloat(dbUser.dailyQuota);
+      return parsed > 0 ? parsed : null;
+    })(),
     providerGroup: dbUser?.providerGroup ?? null,
     tags: dbUser?.tags ?? [],
     limitTotalUsd:

+ 2 - 1
src/repository/leaderboard.ts

@@ -338,8 +338,9 @@ async function findProviderLeaderboardWithTimezone(
               AND ${messageRequest.durationMs} IS NOT NULL
               AND ${messageRequest.ttfbMs} IS NOT NULL
               AND ${messageRequest.ttfbMs} < ${messageRequest.durationMs}
+              AND (${messageRequest.durationMs} - ${messageRequest.ttfbMs}) >= 100
             THEN (${messageRequest.outputTokens}::double precision)
-              / NULLIF((${messageRequest.durationMs} - ${messageRequest.ttfbMs}) / 1000.0, 0)
+              / ((${messageRequest.durationMs} - ${messageRequest.ttfbMs}) / 1000.0)
           END
         )::double precision,
         0::double precision

+ 3 - 1
src/repository/user.ts

@@ -24,6 +24,7 @@ export interface UserListBatchFilters {
     | "name"
     | "tags"
     | "expiresAt"
+    | "rpm"
     | "limit5hUsd"
     | "limitDailyUsd"
     | "limitWeeklyUsd"
@@ -257,6 +258,7 @@ export async function findUserListBatch(
     name: users.name,
     tags: users.tags,
     expiresAt: users.expiresAt,
+    rpm: users.rpmLimit,
     limit5hUsd: users.limit5hUsd,
     limitDailyUsd: users.dailyLimitUsd,
     limitWeeklyUsd: users.limitWeeklyUsd,
@@ -351,7 +353,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise<
   interface UpdateDbData {
     name?: string;
     description?: string;
-    rpmLimit?: number;
+    rpmLimit?: number | null;
     dailyLimitUsd?: string | null;
     providerGroup?: string | null;
     tags?: string[];

+ 7 - 7
src/types/user.ts

@@ -6,8 +6,8 @@ export interface User {
   name: string;
   description: string;
   role: "admin" | "user";
-  rpm: number; // 每分钟请求数限制
-  dailyQuota: number; // 每日额度限制(美元)
+  rpm: number | null; // 每分钟请求数限制,null = 无限制
+  dailyQuota: number | null; // 每日额度限制(美元),null = 无限制
   providerGroup: string | null; // 供应商分组
   tags?: string[]; // 用户标签(可选)
   createdAt: Date;
@@ -37,8 +37,8 @@ export interface User {
 export interface CreateUserData {
   name: string;
   description: string;
-  rpm?: number; // 可选,有默认值
-  dailyQuota?: number; // 可选,有默认值
+  rpm?: number | null; // 可选,null = 无限制
+  dailyQuota?: number | null; // 可选,null = 无限制
   providerGroup?: string | null; // 可选,供应商分组
   tags?: string[]; // 可选,用户标签
   // User-level quota fields
@@ -65,7 +65,7 @@ export interface CreateUserData {
 export interface UpdateUserData {
   name?: string;
   description?: string;
-  rpm?: number;
+  rpm?: number | null;
   dailyQuota?: number | null;
   providerGroup?: string | null; // 可选,供应商分组
   tags?: string[]; // 可选,用户标签
@@ -132,8 +132,8 @@ export interface UserDisplay {
   name: string;
   note?: string;
   role: "admin" | "user";
-  rpm: number;
-  dailyQuota: number;
+  rpm: number | null;
+  dailyQuota: number | null;
   providerGroup?: string | null;
   tags?: string[]; // 用户标签
   keys: UserKeyDisplay[];

+ 1 - 1
tests/e2e/users-keys-complete.test.ts

@@ -447,7 +447,7 @@ describe("用户和 Key 管理 - 完整 E2E 测试", () => {
     test("5.2 创建用户 - 应该拒绝无效的 RPM", async () => {
       const error = await expectError("users", "addUser", {
         name: "测试",
-        rpm: 0, // 最小值是 1
+        rpm: -1, // 负数无效,0 表示无限制
         dailyQuota: 10,
       });
 

+ 199 - 0
tests/unit/proxy/client-guard.test.ts

@@ -0,0 +1,199 @@
+import { describe, expect, test, vi, beforeEach } from "vitest";
+import { ProxyClientGuard } from "@/app/v1/_lib/proxy/client-guard";
+import type { ProxySession } from "@/app/v1/_lib/proxy/session";
+
+// Mock ProxyResponses
+vi.mock("@/app/v1/_lib/proxy/responses", () => ({
+  ProxyResponses: {
+    buildError: (status: number, message: string, code: string) =>
+      new Response(JSON.stringify({ error: { message, type: code } }), { status }),
+  },
+}));
+
+// Helper to create mock session
+function createMockSession(
+  userAgent: string | undefined,
+  allowedClients: string[] = []
+): ProxySession {
+  return {
+    userAgent,
+    authState: {
+      user: {
+        allowedClients,
+      },
+    },
+  } as unknown as ProxySession;
+}
+
+describe("ProxyClientGuard", () => {
+  describe("when authState is missing", () => {
+    test("should allow request when authState is undefined", async () => {
+      const session = { userAgent: "SomeClient/1.0" } as unknown as ProxySession;
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should allow request when authState.user is undefined", async () => {
+      const session = {
+        userAgent: "SomeClient/1.0",
+        authState: {},
+      } as unknown as ProxySession;
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+  });
+
+  describe("when no restrictions configured", () => {
+    test("should allow request when allowedClients is empty", async () => {
+      const session = createMockSession("AnyClient/1.0", []);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should allow request when allowedClients is undefined", async () => {
+      const session = createMockSession("AnyClient/1.0", undefined as unknown as string[]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+  });
+
+  describe("when restrictions are configured", () => {
+    test("should reject when User-Agent is missing", async () => {
+      const session = createMockSession(undefined, ["claude-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+
+    test("should reject when User-Agent is empty", async () => {
+      const session = createMockSession("", ["claude-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+
+    test("should reject when User-Agent is whitespace only", async () => {
+      const session = createMockSession("   ", ["claude-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+  });
+
+  describe("pattern matching with hyphen/underscore normalization", () => {
+    test("should match gemini-cli pattern against GeminiCLI User-Agent", async () => {
+      const session = createMockSession("GeminiCLI/0.22.5/gemini-3-pro-preview (darwin; arm64)", [
+        "gemini-cli",
+      ]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should match claude-cli pattern against claude_cli User-Agent", async () => {
+      const session = createMockSession("claude_cli/1.0", ["claude-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should match codex-cli pattern against codexcli User-Agent", async () => {
+      const session = createMockSession("codexcli/2.0", ["codex-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should match factory-cli pattern against FactoryCLI User-Agent", async () => {
+      const session = createMockSession("FactoryCLI/1.0", ["factory-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should be case-insensitive", async () => {
+      const session = createMockSession("GEMINICLI/1.0", ["gemini-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+  });
+
+  describe("pattern matching without normalization needed", () => {
+    test("should match exact substring", async () => {
+      const session = createMockSession("claude-cli/1.0.0", ["claude-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should match when User-Agent contains pattern as substring", async () => {
+      const session = createMockSession("Mozilla/5.0 claude-cli/1.0 Compatible", ["claude-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+  });
+
+  describe("multiple patterns", () => {
+    test("should allow when one of multiple patterns matches", async () => {
+      const session = createMockSession("GeminiCLI/1.0", ["claude-cli", "gemini-cli", "codex-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should reject when no patterns match", async () => {
+      const session = createMockSession("UnknownClient/1.0", ["claude-cli", "gemini-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+  });
+
+  describe("edge cases", () => {
+    test("should handle pattern with multiple hyphens", async () => {
+      const session = createMockSession("my-special-cli/1.0", ["my-special-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should handle pattern with underscores", async () => {
+      const session = createMockSession("my_special_cli/1.0", ["my-special-cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should handle mixed hyphen and underscore", async () => {
+      const session = createMockSession("my_special-cli/1.0", ["my-special_cli"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should reject when pattern normalizes to empty string", async () => {
+      const session = createMockSession("AnyClient/1.0", ["-"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+
+    test("should reject when pattern is only underscores", async () => {
+      const session = createMockSession("AnyClient/1.0", ["___"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+
+    test("should reject when pattern is only hyphens and underscores", async () => {
+      const session = createMockSession("AnyClient/1.0", ["-_-_-"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+
+    test("should reject when all patterns normalize to empty", async () => {
+      const session = createMockSession("AnyClient/1.0", ["-", "_", "--"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+    });
+
+    test("should allow when at least one pattern is valid after normalization", async () => {
+      const session = createMockSession("ValidClient/1.0", ["-", "valid", "_"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+  });
+});