Browse Source

feat: add default provider group handling in database and UI

- Introduced a new SQL migration to set the default value for the provider_group column in the keys and users tables to 'default'.
- Updated the application logic to ensure that the provider group is always set to 'default' when not specified, enhancing consistency across user and key management.
- Modified various UI components and translations to reflect the new default behavior for provider groups, ensuring users are informed of the default setting.

close #400
ding113 1 month ago
parent
commit
b52d247bae
31 changed files with 2289 additions and 185 deletions
  1. 1 0
      .gitignore
  2. 1 1
      biome.json
  3. 5 0
      drizzle/0039_abnormal_marvel_apes.sql
  4. 1945 0
      drizzle/meta/0039_snapshot.json
  5. 7 0
      drizzle/meta/_journal.json
  6. 11 9
      messages/en/dashboard.json
  7. 3 2
      messages/en/quota.json
  8. 17 5
      messages/ja/dashboard.json
  9. 7 0
      messages/ja/quota.json
  10. 14 6
      messages/ru/dashboard.json
  11. 7 0
      messages/ru/quota.json
  12. 10 8
      messages/zh-CN/dashboard.json
  13. 3 2
      messages/zh-CN/quota.json
  14. 14 6
      messages/zh-TW/dashboard.json
  15. 7 0
      messages/zh-TW/quota.json
  16. 72 42
      src/actions/keys.ts
  17. 34 22
      src/actions/providers.ts
  18. 37 19
      src/actions/users.ts
  19. 4 6
      src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
  20. 13 9
      src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  21. 3 2
      src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx
  22. 5 2
      src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx
  23. 4 3
      src/app/[locale]/dashboard/_components/user/forms/user-form.tsx
  24. 1 1
      src/app/[locale]/dashboard/_components/user/key-list-header.tsx
  25. 4 7
      src/app/[locale]/dashboard/_components/user/key-row-item.tsx
  26. 31 17
      src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx
  27. 2 2
      src/app/[locale]/dashboard/_components/user/user-key-manager.tsx
  28. 1 1
      src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx
  29. 22 9
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  30. 1 1
      src/app/v1/_lib/proxy/provider-selector.ts
  31. 3 3
      src/drizzle/schema.ts

+ 1 - 0
.gitignore

@@ -35,6 +35,7 @@ yarn-error.log*
 # Bun lock files
 bun.lock
 bun.lockb
+.bun-cache/
 
 # env files (can opt-in for committing if needed)
 .env*

+ 1 - 1
biome.json

@@ -1,5 +1,5 @@
 {
-  "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
+  "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
   "vcs": {
     "enabled": true,
     "clientKind": "git",

+ 5 - 0
drizzle/0039_abnormal_marvel_apes.sql

@@ -0,0 +1,5 @@
+ALTER TABLE "keys" ALTER COLUMN "provider_group" SET DEFAULT 'default';--> statement-breakpoint
+ALTER TABLE "users" ALTER COLUMN "provider_group" SET DEFAULT 'default';--> statement-breakpoint
+-- Migrate existing NULL values to 'default'
+UPDATE "keys" SET "provider_group" = 'default' WHERE "provider_group" IS NULL;--> statement-breakpoint
+UPDATE "users" SET "provider_group" = 'default' WHERE "provider_group" IS NULL;

+ 1945 - 0
drizzle/meta/0039_snapshot.json

@@ -0,0 +1,1945 @@
+{
+  "id": "b6de6e35-33b4-4a1b-94c9-c6707bb17468",
+  "prevId": "996478ee-aa77-4651-aacb-248e15110423",
+  "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
+        },
+        "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
+        },
+        "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": {}
+        }
+      },
+      "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,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "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

@@ -274,6 +274,13 @@
       "when": 1766151566924,
       "tag": "0038_aberrant_bucky",
       "breakpoints": true
+    },
+    {
+      "idx": 39,
+      "version": "7",
+      "when": 1766461982056,
+      "tag": "0039_abnormal_marvel_apes",
+      "breakpoints": true
     }
   ]
 }

+ 11 - 9
messages/en/dashboard.json

@@ -711,8 +711,9 @@
     "providerGroup": {
       "label": "Provider Group",
       "placeholder": "Enter provider group tags, press Enter to add",
-      "description": "Limit to specific provider groups (empty = inherit from user)",
-      "descriptionWithUserGroup": "Limit to specific provider groups (User group: {group})"
+      "description": "Provider groups for this key (default: default).",
+      "defaultDescription": "default includes providers without groupTag.",
+      "descriptionWithUserGroup": "Provider groups for this key (user groups: {group}; default: default)."
     },
     "errors": {
       "userIdMissing": "User ID does not exist",
@@ -748,8 +749,8 @@
     },
     "providerGroup": {
       "label": "Provider Group",
-      "placeholder": "e.g., premium or premium,economy (optional)",
-      "description": "Specify user-specific provider groups (supports multiple, comma-separated). The system will only select from providers matching the groupTag. Leave blank = use all providers"
+      "placeholder": "e.g., default or premium,economy",
+      "description": "User provider groups (default: default). Providers without groupTag belong to default."
     },
     "tags": {
       "label": "User Tags",
@@ -1323,8 +1324,8 @@
           "placeholder": "Enter tag (press Enter to add)"
         },
         "providerGroup": {
-          "label": "Provider Group (Legacy)",
-          "placeholder": "Enter provider group or leave empty"
+          "label": "Provider Group",
+          "placeholder": "default"
         },
         "allowedClients": {
           "label": "Client Restrictions",
@@ -1376,14 +1377,15 @@
         },
       "providerGroup": {
         "label": "Provider Group",
-        "placeholder": "Leave empty to allow all default group providers",
-        "selectHint": "Select the provider group(s) this key can use.",
+        "placeholder": "Default: default",
+        "selectHint": "Select the provider group(s) this key can use (default: default).",
         "editHint": "Provider group cannot be changed for existing keys.",
         "allGroups": "Use all groups",
-        "noGroupHint": "You have no group restrictions and can access all providers."
+        "noGroupHint": "default includes providers without groupTag."
       },
         "cacheTtl": {
           "label": "Cache TTL Override",
+          "description": "Force Anthropic prompt cache TTL for requests containing cache_control.",
           "options": {
             "inherit": "No override (follow provider/client)",
             "5m": "5m",

+ 3 - 2
messages/en/quota.json

@@ -335,8 +335,9 @@
       "providerGroup": {
         "label": "Provider Group",
         "placeholder": "Enter provider group tags, press Enter to add",
-        "description": "Limit to specific provider groups (empty = inherit from user)",
-        "descriptionWithUserGroup": "Limit to specific provider groups (User group: {group})"
+        "description": "Provider groups for this key (default: default).",
+        "defaultDescription": "default includes providers without groupTag.",
+        "descriptionWithUserGroup": "Provider groups for this key (user groups: {group}; default: default)."
       },
       "submitText": "Save Changes",
       "loadingText": "Saving...",

+ 17 - 5
messages/ja/dashboard.json

@@ -689,6 +689,13 @@
       "placeholder": "0は無制限を意味します",
       "description": "同時に実行される会話の数"
     },
+    "providerGroup": {
+      "label": "プロバイダーグループ",
+      "placeholder": "プロバイダーグループタグを入力し、Enterで追加",
+      "description": "このキーのプロバイダーグループ(既定: default)",
+      "defaultDescription": "default は groupTag 未設定のプロバイダーを含みます",
+      "descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)"
+    },
     "errors": {
       "userIdMissing": "ユーザーIDが存在しません",
       "createFailed": "作成に失敗しました。後でもう一度お試しください",
@@ -723,8 +730,8 @@
     },
     "providerGroup": {
       "label": "プロバイダーグループ",
-      "placeholder": "例: premium または premium,economy(オプション)",
-      "description": "ユーザー専用のプロバイダーグループを指定します(複数可、カンマ区切り)。システムはgroupTagが一致するプロバイダーのみから選択します。空白=すべてのプロバイダーを使用"
+      "placeholder": "例: default または premium,economy",
+      "description": "ユーザーのプロバイダーグループ(既定: default)。groupTag が未設定のプロバイダーは default に属します。"
     },
     "tags": {
       "label": "ユーザータグ",
@@ -1282,8 +1289,8 @@
           "placeholder": "タグを入力(Enterで追加)"
         },
         "providerGroup": {
-          "label": "プロバイダーグループ(レガシー)",
-          "placeholder": "プロバイダーグループを入力または空白のまま"
+          "label": "プロバイダーグループ",
+          "placeholder": "default"
         },
         "allowedClients": {
           "label": "クライアント制限",
@@ -1335,10 +1342,15 @@
         },
         "providerGroup": {
           "label": "プロバイダーグループ",
-          "placeholder": "空欄の場合は全ての default グループのプロバイダーを許可"
+          "placeholder": "既定: default",
+          "selectHint": "このキーで使用できるプロバイダーグループを選択します。",
+          "editHint": "既存のキーのグループは変更できません。",
+          "allGroups": "すべてのグループを使用",
+          "noGroupHint": "default は groupTag 未設定のプロバイダーを含みます"
         },
         "cacheTtl": {
           "label": "Cache TTL上書き",
+          "description": "cache_controlを含むリクエストに対してAnthropic prompt cache TTLを強制します。",
           "options": {
             "inherit": "上書きしない(プロバイダー/クライアントに従う)",
             "5m": "5m",

+ 7 - 0
messages/ja/quota.json

@@ -303,6 +303,13 @@
         "placeholder": "0 = 無制限",
         "description": "同時実行可能な会話数"
       },
+      "providerGroup": {
+        "label": "プロバイダーグループ",
+        "placeholder": "プロバイダーグループタグを入力し、Enterで追加",
+        "description": "このキーのプロバイダーグループ(既定: default)",
+        "defaultDescription": "default は groupTag 未設定のプロバイダーを含みます",
+        "descriptionWithUserGroup": "このキーのプロバイダーグループ(ユーザーのグループ: {group}、既定: default)"
+      },
       "submitText": "変更を保存",
       "loadingText": "保存中...",
       "success": "キーが正常に更新されました",

+ 14 - 6
messages/ru/dashboard.json

@@ -691,6 +691,13 @@
       "placeholder": "0 означает неограниченно",
       "description": "Количество одновременных разговоров"
     },
+    "providerGroup": {
+      "label": "Группа провайдеров",
+      "placeholder": "Введите теги групп провайдеров и нажмите Enter",
+      "description": "Группы провайдеров для этого ключа (по умолчанию: default).",
+      "defaultDescription": "default включает провайдеров без groupTag.",
+      "descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)."
+    },
     "errors": {
       "userIdMissing": "ID пользователя не существует",
       "createFailed": "Не удалось создать, попробуйте позже",
@@ -725,8 +732,8 @@
     },
     "providerGroup": {
       "label": "Группа поставщиков",
-      "placeholder": "например, premium или premium,economy (необязательно)",
-      "description": "Укажите группы поставщиков для конкретного пользователя (поддерживается несколько, разделенных запятыми). Система будет выбирать только из поставщиков, соответствующих groupTag. Пусто = использовать всех поставщиков"
+      "placeholder": "например, default или premium,economy",
+      "description": "Группы провайдеров пользователя (по умолчанию: default). Провайдеры без groupTag относятся к default."
     },
     "tags": {
       "label": "Теги пользователя",
@@ -1295,8 +1302,8 @@
           "placeholder": "Введите тег (Enter для добавления)"
         },
         "providerGroup": {
-          "label": "Группа провайдеров (устаревшее)",
-          "placeholder": "Введите группу провайдеров или оставьте пустым"
+          "label": "Группа провайдеров",
+          "placeholder": "default"
         },
         "allowedClients": {
           "label": "Ограничения клиентов",
@@ -1346,14 +1353,15 @@
         },
       "providerGroup": {
         "label": "Группа провайдеров",
-        "placeholder": "Оставьте пустым для доступа ко всем провайдерам группы default",
+        "placeholder": "По умолчанию: default",
         "selectHint": "Выберите группы провайдеров, доступные для этого ключа",
         "editHint": "Группа провайдеров существующего ключа не может быть изменена",
         "allGroups": "Использовать все группы",
-        "noGroupHint": "Ограничений по группам нет — доступ ко всем провайдерам"
+        "noGroupHint": "default включает провайдеров без groupTag."
       },
         "cacheTtl": {
           "label": "Переопределение Cache TTL",
+          "description": "Принудительно установить Anthropic prompt cache TTL для запросов с cache_control.",
           "options": {
             "inherit": "Не переопределять (следовать провайдеру/клиенту)",
             "5m": "5m",

+ 7 - 0
messages/ru/quota.json

@@ -301,6 +301,13 @@
         "placeholder": "0 = без ограничений",
         "description": "Количество одновременных диалогов"
       },
+      "providerGroup": {
+        "label": "Группа провайдеров",
+        "placeholder": "Введите теги групп провайдеров и нажмите Enter",
+        "description": "Группы провайдеров для этого ключа (по умолчанию: default).",
+        "defaultDescription": "default включает провайдеров без groupTag.",
+        "descriptionWithUserGroup": "Группы провайдеров для этого ключа (группы пользователя: {group}; по умолчанию: default)."
+      },
       "submitText": "Сохранить изменения",
       "loadingText": "Сохранение...",
       "success": "Ключ успешно обновлен",

+ 10 - 8
messages/zh-CN/dashboard.json

@@ -712,8 +712,9 @@
     "providerGroup": {
       "label": "供应商分组",
       "placeholder": "输入供应商分组标签,按回车添加",
-      "description": "限制可使用的供应商分组(留空继承用户设置)",
-      "descriptionWithUserGroup": "限制可使用的供应商分组(用户分组: {group})"
+      "description": "供应商分组(默认:default)",
+      "defaultDescription": "default 分组包含所有未设置 groupTag 的供应商",
+      "descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})"
     },
     "errors": {
       "userIdMissing": "用户ID不存在",
@@ -749,8 +750,8 @@
     },
     "providerGroup": {
       "label": "供应商分组",
-      "placeholder": "输入分组标签",
-      "description": "指定用户专属的供应商分组(支持多个,逗号分隔)。系统将只从 groupTag 匹配的供应商中选择。留空=使用所有供应商"
+      "placeholder": "例如:default 或 premium,economy",
+      "description": "用户供应商分组(默认:default)。default 分组包含所有未设置 groupTag 的供应商。"
     },
     "tags": {
       "label": "用户标签",
@@ -1387,8 +1388,8 @@
           "placeholder": "输入标签(回车添加)"
         },
         "providerGroup": {
-          "label": "供应商分组(旧版)",
-          "placeholder": "输入供应商分组或留空"
+          "label": "供应商分组",
+          "placeholder": "default"
         },
         "allowedClients": {
           "label": "客户端限制",
@@ -1440,14 +1441,15 @@
         },
       "providerGroup": {
         "label": "供应商分组",
-        "placeholder": "留空则允许所有 default 分组供应商",
+        "placeholder": "默认:default",
         "selectHint": "选择此 Key 可使用的供应商分组",
         "editHint": "已有密钥的分组不可修改",
         "allGroups": "使用全部分组",
-        "noGroupHint": "您没有分组限制,可以访问所有供应商"
+        "noGroupHint": "default 分组包含所有未设置 groupTag 的供应商"
       },
         "cacheTtl": {
           "label": "Cache TTL 覆写",
+          "description": "强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。",
           "options": {
             "inherit": "不覆写(跟随供应商/客户端)",
             "5m": "5m",

+ 3 - 2
messages/zh-CN/quota.json

@@ -339,8 +339,9 @@
       "providerGroup": {
         "label": "供应商分组",
         "placeholder": "输入供应商分组标签,按回车添加",
-        "description": "限制可使用的供应商分组(留空继承用户设置)",
-        "descriptionWithUserGroup": "限制可使用的供应商分组(用户分组: {group})"
+        "description": "供应商分组(默认:default)",
+        "defaultDescription": "default 分组包含所有未设置 groupTag 的供应商",
+        "descriptionWithUserGroup": "供应商分组(默认:default;用户分组:{group})"
       },
       "submitText": "保存修改",
       "loadingText": "保存中...",

+ 14 - 6
messages/zh-TW/dashboard.json

@@ -692,6 +692,13 @@
       "placeholder": "0 表示無限制",
       "description": "同時執行的對話數量"
     },
+    "providerGroup": {
+      "label": "供應商分組",
+      "placeholder": "輸入供應商分組標籤,按 Enter 新增",
+      "description": "供應商分組(預設:default)",
+      "defaultDescription": "default 分組包含所有未設定 groupTag 的供應商",
+      "descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})"
+    },
     "errors": {
       "userIdMissing": "使用者 ID 不存在",
       "createFailed": "建立失敗,請稍後重試",
@@ -726,8 +733,8 @@
     },
     "providerGroup": {
       "label": "供應商分組",
-      "placeholder": "例如:premium 或 premium,economy(可選)",
-      "description": "指定使用者專屬的供應商分組(支援多個,逗號分隔)。系統將只從 groupTag 符合的供應商中選擇。留空=使用所有供應商"
+      "placeholder": "例如:default 或 premium,economy",
+      "description": "使用者供應商分組(預設:default)。default 分組包含所有未設定 groupTag 的供應商。"
     },
     "tags": {
       "label": "使用者標籤",
@@ -1294,8 +1301,8 @@
           "placeholder": "輸入標籤(按 Enter 新增)"
         },
         "providerGroup": {
-          "label": "供應商分組(舊版)",
-          "placeholder": "輸入供應商分組或留空"
+          "label": "供應商分組",
+          "placeholder": "default"
         },
         "allowedClients": {
           "label": "用戶端限制",
@@ -1347,14 +1354,15 @@
         },
       "providerGroup": {
         "label": "供應商分組",
-        "placeholder": "留空則允許所有 default 分組供應商",
+        "placeholder": "預設:default",
         "selectHint": "選擇此 Key 可使用的供應商分組",
         "editHint": "已有密鑰的分組不可修改",
         "allGroups": "使用全部群組",
-        "noGroupHint": "您沒有分組限制,可以存取所有供應商"
+        "noGroupHint": "default 分組包含所有未設定 groupTag 的供應商"
       },
         "cacheTtl": {
           "label": "Cache TTL 覆寫",
+          "description": "強制為包含 cache_control 的請求設定 Anthropic prompt cache TTL。",
           "options": {
             "inherit": "不覆寫(跟隨供應商/客戶端)",
             "5m": "5m",

+ 7 - 0
messages/zh-TW/quota.json

@@ -301,6 +301,13 @@
         "placeholder": "0 表示無限制",
         "description": "同時運行的對話數量"
       },
+      "providerGroup": {
+        "label": "供應商分組",
+        "placeholder": "輸入供應商分組標籤,按 Enter 新增",
+        "description": "供應商分組(預設:default)",
+        "defaultDescription": "default 分組包含所有未設定 groupTag 的供應商",
+        "descriptionWithUserGroup": "供應商分組(預設:default;使用者分組:{group})"
+      },
       "submitText": "儲存修改",
       "loadingText": "儲存中...",
       "success": "金鑰更新成功",

+ 72 - 42
src/actions/keys.ts

@@ -7,6 +7,7 @@ import { getTranslations } from "next-intl/server";
 import { db } from "@/drizzle/db";
 import { keys as keysTable } from "@/drizzle/schema";
 import { getSession } from "@/lib/auth";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { logger } from "@/lib/logger";
 import { ERROR_CODES } from "@/lib/utils/error-messages";
 import { KeyFormSchema } from "@/lib/validation/schemas";
@@ -25,17 +26,54 @@ import type { Key } from "@/types/key";
 import type { ActionResult } from "./types";
 import { type BatchUpdateResult, syncUserProviderGroupFromKeys } from "./users";
 
-function normalizeProviderGroup(value: unknown): string | null {
-  if (value === null || value === undefined) return null;
-  if (typeof value !== "string") return null;
-  const groups = value
+function normalizeProviderGroup(value: unknown): string {
+  if (value === null || value === undefined) return PROVIDER_GROUP.DEFAULT;
+  if (typeof value !== "string") return PROVIDER_GROUP.DEFAULT;
+  const trimmed = value.trim();
+  if (trimmed === "") return PROVIDER_GROUP.DEFAULT;
+
+  const groups = trimmed
     .split(",")
     .map((g) => g.trim())
     .filter(Boolean);
-  if (groups.length === 0) return null;
+  if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
+
   return Array.from(new Set(groups)).sort().join(",");
 }
 
+function parseProviderGroups(value: string): string[] {
+  return value
+    .split(",")
+    .map((g) => g.trim())
+    .filter(Boolean);
+}
+
+function validateNonAdminProviderGroup(
+  userProviderGroup: string,
+  requestedProviderGroup: string,
+  options: { hasDefaultKey: boolean }
+): string {
+  const userGroups = parseProviderGroups(userProviderGroup);
+  const requestedGroups = parseProviderGroups(requestedProviderGroup);
+
+  if (userGroups.includes(PROVIDER_GROUP.ALL)) {
+    return requestedProviderGroup;
+  }
+
+  const userGroupSet = new Set(userGroups);
+
+  if (requestedGroups.includes(PROVIDER_GROUP.DEFAULT) && !options.hasDefaultKey) {
+    throw new Error("无权使用 default 分组,您当前没有 default 分组的 Key");
+  }
+
+  const invalidGroups = requestedGroups.filter((g) => !userGroupSet.has(g));
+  if (invalidGroups.length > 0) {
+    throw new Error(`无权使用以下分组: ${invalidGroups.join(", ")}`);
+  }
+
+  return requestedProviderGroup;
+}
+
 export interface BatchUpdateKeysParams {
   keyIds: number[];
   updates: {
@@ -78,10 +116,10 @@ export async function addKey(data: {
   cacheTtlPreference?: "inherit" | "5m" | "1h";
 }): Promise<ActionResult<{ generatedKey: string; name: string }>> {
   try {
-    // providerGroup 安全模型:
-    // - 非管理员创建 Key 时,providerGroup 必须是用户现有分组的子集(防止绕过分组隔离)
-    // - 若用户有分组限制但未指定 providerGroup,则新 Key 继承用户的全部分组
-    // - 若用户无分组限制,则新 Key 的 providerGroup 为空(可访问所有)
+    // NOTE(#400): providerGroup 安全模型(废弃 null 语义)
+    // - Key.providerGroup 必须显式存储(默认 "default"),不再允许 null
+    // - 非管理员创建 Key 时,requested providerGroup 必须是用户现有分组的子集
+    // - 非管理员若要创建包含 default 的 Key,必须已拥有 default 分组的 Key
 
     const tError = await getTranslations("errors");
 
@@ -113,30 +151,25 @@ export async function addKey(data: {
 
     const userProviderGroup = normalizeProviderGroup(user.providerGroup);
     const requestedProviderGroup = normalizeProviderGroup(data.providerGroup);
-    let providerGroupForKey = isAdmin ? requestedProviderGroup : null;
-
-    if (!isAdmin) {
-      const userGroups = userProviderGroup ? userProviderGroup.split(",") : [];
-
-      if (userGroups.length > 0) {
-        // 如果未指定分组,继承用户的全部分组
-        if (!requestedProviderGroup) {
-          providerGroupForKey = userProviderGroup;
-        } else {
-          // 验证请求的分组是用户分组的子集
-          const userGroupSet = new Set(userGroups);
-          const requestedGroups = requestedProviderGroup.split(",");
-          const invalidGroups = requestedGroups.filter((g) => !userGroupSet.has(g));
-          if (invalidGroups.length > 0) {
-            return {
-              ok: false,
-              error: `无权使用以下分组: ${invalidGroups.join(", ")}`,
-              errorCode: ERROR_CODES.PERMISSION_DENIED,
-            };
-          }
-          providerGroupForKey = requestedProviderGroup;
+
+    let providerGroupForKey: string;
+    if (isAdmin) {
+      providerGroupForKey = requestedProviderGroup;
+    } else {
+      // NOTE(#400): Security - require an existing default-group key before allowing default
+      const userKeys = await findKeyList(data.userId);
+      const hasDefaultKey = userKeys.some((k) =>
+        parseProviderGroups(normalizeProviderGroup(k.providerGroup)).includes(
+          PROVIDER_GROUP.DEFAULT
+        )
+      );
+      providerGroupForKey = validateNonAdminProviderGroup(
+        userProviderGroup,
+        requestedProviderGroup,
+        {
+          hasDefaultKey,
         }
-      }
+      );
     }
 
     const validatedData = KeyFormSchema.parse({
@@ -240,12 +273,12 @@ export async function addKey(data: {
       limit_monthly_usd: validatedData.limitMonthlyUsd,
       limit_total_usd: validatedData.limitTotalUsd,
       limit_concurrent_sessions: validatedData.limitConcurrentSessions,
-      provider_group: validatedData.providerGroup || null,
+      provider_group: validatedData.providerGroup,
       cache_ttl_preference: validatedData.cacheTtlPreference,
     });
 
     // 自动同步用户分组(用户分组 = Key 分组并集)
-    if (session.user.role === "admin" && validatedData.providerGroup) {
+    if (session.user.role === "admin") {
       await syncUserProviderGroupFromKeys(data.userId);
     }
 
@@ -405,8 +438,8 @@ export async function editKey(
       validatedData.expiresAt === undefined ? null : new Date(validatedData.expiresAt);
 
     const isAdmin = session.user.role === "admin";
-    const nextProviderGroup = isAdmin ? normalizeProviderGroup(validatedData.providerGroup) : null;
     const prevProviderGroup = normalizeProviderGroup(key.providerGroup);
+    const nextProviderGroup = isAdmin ? normalizeProviderGroup(validatedData.providerGroup) : null;
     const providerGroupChanged = isAdmin && nextProviderGroup !== prevProviderGroup;
 
     await updateKey(keyId, {
@@ -423,7 +456,7 @@ export async function editKey(
       limit_total_usd: validatedData.limitTotalUsd,
       limit_concurrent_sessions: validatedData.limitConcurrentSessions,
       // providerGroup 为 admin-only 字段:非管理员不允许更新该字段
-      ...(isAdmin ? { provider_group: validatedData.providerGroup || null } : {}),
+      ...(isAdmin ? { provider_group: normalizeProviderGroup(validatedData.providerGroup) } : {}),
       cache_ttl_preference: validatedData.cacheTtlPreference,
     });
 
@@ -474,8 +507,8 @@ export async function removeKey(keyId: number): Promise<ActionResult> {
       const remainingGroups = new Set<string>();
       for (const k of userKeys) {
         if (k.id === keyId) continue;
-        if (!k.providerGroup) continue;
-        k.providerGroup
+        const group = k.providerGroup || PROVIDER_GROUP.DEFAULT;
+        group
           .split(",")
           .map((g) => g.trim())
           .filter(Boolean)
@@ -484,10 +517,7 @@ export async function removeKey(keyId: number): Promise<ActionResult> {
 
       const { findUserById } = await import("@/repository/user");
       const user = await findUserById(key.userId);
-      const currentGroups = (user?.providerGroup || "")
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
+      const currentGroups = parseProviderGroups(normalizeProviderGroup(user?.providerGroup));
 
       if (currentGroups.length > 0 && remainingGroups.size === 0) {
         return {

+ 34 - 22
src/actions/providers.ts

@@ -11,7 +11,7 @@ import {
   resetCircuit,
 } from "@/lib/circuit-breaker";
 import { CodexInstructionsCache } from "@/lib/codex-instructions-cache";
-import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants";
+import { PROVIDER_GROUP, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants";
 import { logger } from "@/lib/logger";
 import {
   executeProviderTest,
@@ -255,32 +255,36 @@ export async function getAvailableProviderGroups(userId?: number): Promise<strin
   try {
     const { getDistinctProviderGroups } = await import("@/repository/provider");
     const allGroups = await getDistinctProviderGroups();
+    const allGroupsWithDefault = [
+      PROVIDER_GROUP.DEFAULT,
+      ...allGroups.filter((group) => group !== PROVIDER_GROUP.DEFAULT),
+    ];
 
     // 如果没有提供 userId,返回所有分组(向后兼容)
     if (!userId) {
-      return allGroups;
+      return allGroupsWithDefault;
     }
 
     // 查询用户配置的 providerGroup
     const { findUserById } = await import("@/repository/user");
     const user = await findUserById(userId);
 
-    if (!user || !user.providerGroup) {
-      // 用户未配置 providerGroup,返回所有分组
-      return allGroups;
-    }
-
-    // 解析用户的 providerGroup(逗号分隔)
-    const userGroups = user.providerGroup
+    const userGroups = (user?.providerGroup || PROVIDER_GROUP.DEFAULT)
       .split(",")
       .map((g) => g.trim())
       .filter(Boolean);
 
-    // 过滤:只返回用户配置的分组
-    return allGroups.filter((group) => userGroups.includes(group));
+    // 管理员通配符:可访问所有分组
+    if (userGroups.includes(PROVIDER_GROUP.ALL)) {
+      return allGroupsWithDefault;
+    }
+
+    // 过滤:只返回用户配置的分组(但始终包含 default)
+    const filtered = allGroupsWithDefault.filter((group) => userGroups.includes(group));
+    return [PROVIDER_GROUP.DEFAULT, ...filtered.filter((g) => g !== PROVIDER_GROUP.DEFAULT)];
   } catch (error) {
     logger.error("获取供应商分组失败:", error);
-    return [];
+    return [PROVIDER_GROUP.DEFAULT];
   }
 }
 
@@ -296,21 +300,29 @@ export async function getProviderGroupsWithCount(): Promise<
     const groupCounts = new Map<string, number>();
 
     for (const provider of providers) {
-      if (provider.groupTag) {
-        const groups = provider.groupTag
-          .split(",")
-          .map((g) => g.trim())
-          .filter(Boolean);
-
-        for (const group of groups) {
-          groupCounts.set(group, (groupCounts.get(group) || 0) + 1);
-        }
+      const groupTag = provider.groupTag?.trim();
+      if (!groupTag) {
+        groupCounts.set(PROVIDER_GROUP.DEFAULT, (groupCounts.get(PROVIDER_GROUP.DEFAULT) || 0) + 1);
+        continue;
+      }
+
+      const groups = groupTag
+        .split(",")
+        .map((g) => g.trim())
+        .filter(Boolean);
+
+      for (const group of groups) {
+        groupCounts.set(group, (groupCounts.get(group) || 0) + 1);
       }
     }
 
     const result = Array.from(groupCounts.entries())
       .map(([group, providerCount]) => ({ group, providerCount }))
-      .sort((a, b) => a.group.localeCompare(b.group));
+      .sort((a, b) => {
+        if (a.group === PROVIDER_GROUP.DEFAULT) return -1;
+        if (b.group === PROVIDER_GROUP.DEFAULT) return 1;
+        return a.group.localeCompare(b.group);
+      });
 
     return { ok: true, data: result };
   } catch (error) {

+ 37 - 19
src/actions/users.ts

@@ -75,6 +75,21 @@ class BatchUpdateError extends Error {
   }
 }
 
+function normalizeProviderGroup(value: unknown): string {
+  if (value === null || value === undefined) return PROVIDER_GROUP.DEFAULT;
+  if (typeof value !== "string") return PROVIDER_GROUP.DEFAULT;
+  const trimmed = value.trim();
+  if (trimmed === "") return PROVIDER_GROUP.DEFAULT;
+
+  const groups = trimmed
+    .split(",")
+    .map((g) => g.trim())
+    .filter(Boolean);
+  if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
+
+  return Array.from(new Set(groups)).sort().join(",");
+}
+
 /**
  * 验证过期时间的公共函数
  * @param expiresAt - 过期时间
@@ -128,28 +143,23 @@ export async function syncUserProviderGroupFromKeys(userId: number): Promise<voi
   // and should fail explicitly if provider group sync fails to maintain data consistency.
   const keys = await findKeyList(userId);
   const allGroups = new Set<string>();
-  let hasEmptyGroup = false;
 
   for (const key of keys) {
-    if (key.providerGroup) {
-      const groups = key.providerGroup
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
-      groups.forEach((g) => allGroups.add(g));
-    } else {
-      hasEmptyGroup = true;
-    }
+    // NOTE(#400): Key.providerGroup is now required (no more null semantics).
+    // For backward compatibility, treat null/empty as "default".
+    const group = key.providerGroup || PROVIDER_GROUP.DEFAULT;
+    group
+      .split(",")
+      .map((g) => g.trim())
+      .filter(Boolean)
+      .forEach((g) => allGroups.add(g));
   }
 
-  if (hasEmptyGroup) {
-    allGroups.add(PROVIDER_GROUP.DEFAULT);
-  }
-
-  const newProviderGroup = allGroups.size > 0 ? Array.from(allGroups).sort().join(",") : null;
+  const newProviderGroup =
+    allGroups.size > 0 ? Array.from(allGroups).sort().join(",") : PROVIDER_GROUP.DEFAULT;
   await updateUser(userId, { providerGroup: newProviderGroup });
   logger.info(
-    `[UserAction] Synced user provider group: userId=${userId}, groups=${newProviderGroup || "null"}`
+    `[UserAction] Synced user provider group: userId=${userId}, groups=${newProviderGroup}`
   );
 }
 
@@ -700,11 +710,12 @@ export async function addUser(data: {
     }
 
     const validatedData = validationResult.data;
+    const providerGroup = normalizeProviderGroup(validatedData.providerGroup);
 
     const newUser = await createUser({
       name: validatedData.name,
       description: validatedData.note || "",
-      providerGroup: validatedData.providerGroup || null,
+      providerGroup,
       tags: validatedData.tags,
       rpm: validatedData.rpm,
       dailyQuota: validatedData.dailyQuota ?? undefined,
@@ -729,6 +740,7 @@ export async function addUser(data: {
       key: generatedKey,
       is_enabled: true,
       expires_at: undefined,
+      provider_group: providerGroup,
     });
 
     revalidatePath("/dashboard");
@@ -882,11 +894,12 @@ export async function createUserOnly(data: {
     }
 
     const validatedData = validationResult.data;
+    const providerGroup = normalizeProviderGroup(validatedData.providerGroup);
 
     const newUser = await createUser({
       name: validatedData.name,
       description: validatedData.note || "",
-      providerGroup: validatedData.providerGroup || null,
+      providerGroup,
       tags: validatedData.tags,
       rpm: validatedData.rpm,
       dailyQuota: validatedData.dailyQuota ?? undefined,
@@ -1035,11 +1048,16 @@ export async function editUser(
       };
     }
 
+    const nextProviderGroup =
+      validatedData.providerGroup === undefined
+        ? undefined
+        : normalizeProviderGroup(validatedData.providerGroup);
+
     // Update user with validated data
     await updateUser(userId, {
       name: validatedData.name,
       description: validatedData.note,
-      providerGroup: validatedData.providerGroup,
+      ...(nextProviderGroup !== undefined ? { providerGroup: nextProviderGroup } : {}),
       tags: validatedData.tags,
       rpm: validatedData.rpm,
       dailyQuota: validatedData.dailyQuota,

+ 4 - 6
src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx

@@ -17,6 +17,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { getErrorMessage } from "@/lib/utils/error-messages";
 import { KeyFormSchema } from "@/lib/validation/schemas";
@@ -40,9 +41,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
 
   // Load provider group suggestions
   useEffect(() => {
-    // providerGroup 为 admin-only 字段:仅管理员允许编辑 Key.providerGroup
-    if (!isAdmin) return;
-    if (user?.id) {
+    if (user?.id && !isAdmin) {
       getAvailableProviderGroups(user.id).then(setProviderGroupSuggestions);
     } else {
       getAvailableProviderGroups().then(setProviderGroupSuggestions);
@@ -55,7 +54,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
       name: "",
       expiresAt: "",
       canLoginWebUi: true,
-      providerGroup: "",
+      providerGroup: PROVIDER_GROUP.DEFAULT,
       cacheTtlPreference: "inherit",
       limit5hUsd: null,
       limitDailyUsd: null,
@@ -86,7 +85,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
           limitTotalUsd: data.limitTotalUsd,
           limitConcurrentSessions: data.limitConcurrentSessions,
           cacheTtlPreference: data.cacheTtlPreference,
-          ...(isAdmin ? { providerGroup: data.providerGroup || null } : {}),
+          providerGroup: data.providerGroup || PROVIDER_GROUP.DEFAULT,
         });
 
         if (!result.ok) {
@@ -192,7 +191,6 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF
         onChange={form.getFieldProps("providerGroup").onChange}
         error={form.getFieldProps("providerGroup").error}
         touched={form.getFieldProps("providerGroup").touched}
-        disabled={!isAdmin}
       />
 
       <div className="space-y-2">

+ 13 - 9
src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx

@@ -17,6 +17,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { getErrorMessage } from "@/lib/utils/error-messages";
 import { KeyFormSchema } from "@/lib/validation/schemas";
@@ -49,6 +50,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
   const [providerGroupSuggestions, setProviderGroupSuggestions] = useState<string[]>([]);
   const router = useRouter();
   const t = useTranslations("quota.keys.editKeyForm");
+  const tKeyEdit = useTranslations("userManagement.keyEditSection.fields");
   const tUI = useTranslations("ui.tagInput");
   const tCommon = useTranslations("common");
   const tErrors = useTranslations("errors");
@@ -65,9 +67,11 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
   }, [isAdmin, user?.id]);
 
   const formatExpiresAt = (expiresAt: string) => {
-    if (!expiresAt || expiresAt === "永不过期") return "";
+    if (!expiresAt) return "";
     try {
-      return new Date(expiresAt).toISOString().split("T")[0];
+      const date = new Date(expiresAt);
+      if (Number.isNaN(date.getTime())) return "";
+      return date.toISOString().split("T")[0];
     } catch {
       return "";
     }
@@ -79,7 +83,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
       name: keyData?.name || "",
       expiresAt: formatExpiresAt(keyData?.expiresAt || ""),
       canLoginWebUi: keyData?.canLoginWebUi ?? true,
-      providerGroup: keyData?.providerGroup || "",
+      providerGroup: keyData?.providerGroup || PROVIDER_GROUP.DEFAULT,
       cacheTtlPreference: keyData?.cacheTtlPreference ?? "inherit",
       limit5hUsd: keyData?.limit5hUsd ?? null,
       limitDailyUsd: keyData?.limitDailyUsd ?? null,
@@ -110,7 +114,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
             limitMonthlyUsd: data.limitMonthlyUsd,
             limitTotalUsd: data.limitTotalUsd,
             limitConcurrentSessions: data.limitConcurrentSessions,
-            ...(isAdmin ? { providerGroup: data.providerGroup || null } : {}),
+            ...(isAdmin ? { providerGroup: data.providerGroup || PROVIDER_GROUP.DEFAULT } : {}),
           });
           if (!res.ok) {
             const msg = res.errorCode
@@ -207,7 +211,7 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
       />
 
       <div className="space-y-2">
-        <Label>Cache TTL 覆写</Label>
+        <Label>{tKeyEdit("cacheTtl.label")}</Label>
         <Select
           value={form.values.cacheTtlPreference}
           onValueChange={(val) =>
@@ -218,13 +222,13 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK
             <SelectValue placeholder="inherit" />
           </SelectTrigger>
           <SelectContent>
-            <SelectItem value="inherit">不覆写(跟随供应商/客户端)</SelectItem>
-            <SelectItem value="5m">5m</SelectItem>
-            <SelectItem value="1h">1h</SelectItem>
+            <SelectItem value="inherit">{tKeyEdit("cacheTtl.options.inherit")}</SelectItem>
+            <SelectItem value="5m">{tKeyEdit("cacheTtl.options.5m")}</SelectItem>
+            <SelectItem value="1h">{tKeyEdit("cacheTtl.options.1h")}</SelectItem>
           </SelectContent>
         </Select>
         <p className="text-xs text-muted-foreground">
-          强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。
+          {tKeyEdit("cacheTtl.description")}
         </p>
       </div>
 

+ 3 - 2
src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx

@@ -16,6 +16,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { cn } from "@/lib/utils";
 import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
 import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display";
@@ -117,7 +118,7 @@ function normalizeGroupList(value?: string | null): string {
     .split(",")
     .map((g) => g.trim())
     .filter(Boolean);
-  if (groups.length === 0) return "";
+  if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
   return Array.from(new Set(groups)).sort().join(",");
 }
 
@@ -414,7 +415,7 @@ export function KeyEditSection({
 
         {isAdmin ? (
           <ProviderGroupSelect
-            value={keyData.providerGroup || ""}
+            value={keyData.providerGroup || PROVIDER_GROUP.DEFAULT}
             onChange={(val) => onChange("providerGroup", val)}
             disabled={false}
             translations={{

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

@@ -17,6 +17,7 @@ import {
 import { Button } from "@/components/ui/button";
 import { Label } from "@/components/ui/label";
 import { Switch } from "@/components/ui/switch";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { cn } from "@/lib/utils";
 import { AccessRestrictionsSection } from "./access-restrictions-section";
 import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
@@ -397,8 +398,10 @@ export function UserEditSection({
               <TextField
                 label={translations.fields.providerGroup.label}
                 placeholder={translations.fields.providerGroup.placeholder}
-                value={user.providerGroup || ""}
-                onChange={(val) => emitChange("providerGroup", val?.trim() || null)}
+                value={user.providerGroup || PROVIDER_GROUP.DEFAULT}
+                onChange={(val) =>
+                  emitChange("providerGroup", val?.trim() || PROVIDER_GROUP.DEFAULT)
+                }
                 maxLength={50}
               />
             )}

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

@@ -12,6 +12,7 @@ import { DialogFormLayout, FormGrid } from "@/components/form/form-layout";
 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 { useZodForm } from "@/lib/hooks/use-zod-form";
 import { getErrorMessage } from "@/lib/utils/error-messages";
@@ -90,7 +91,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
       note: user?.note || "",
       rpm: user?.rpm || USER_DEFAULTS.RPM,
       dailyQuota: user?.dailyQuota ?? USER_DEFAULTS.DAILY_QUOTA,
-      providerGroup: user?.providerGroup || "",
+      providerGroup: user?.providerGroup || PROVIDER_GROUP.DEFAULT,
       tags: user?.tags || [],
       limit5hUsd: user?.limit5hUsd ?? null,
       limitWeeklyUsd: user?.limitWeeklyUsd ?? null,
@@ -119,7 +120,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               note: data.note,
               rpm: data.rpm,
               dailyQuota: data.dailyQuota,
-              providerGroup: data.providerGroup || null,
+              providerGroup: data.providerGroup || PROVIDER_GROUP.DEFAULT,
               tags: data.tags,
               limit5hUsd: data.limit5hUsd,
               limitWeeklyUsd: data.limitWeeklyUsd,
@@ -137,7 +138,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               note: data.note,
               rpm: data.rpm,
               dailyQuota: data.dailyQuota,
-              providerGroup: data.providerGroup || null,
+              providerGroup: data.providerGroup || PROVIDER_GROUP.DEFAULT,
               tags: data.tags,
               limit5hUsd: data.limit5hUsd,
               limitWeeklyUsd: data.limitWeeklyUsd,

+ 1 - 1
src/app/[locale]/dashboard/_components/user/key-list-header.tsx

@@ -327,7 +327,7 @@ export function KeyListHeader({
                           role: activeUser.role,
                           rpm: activeUser.rpm,
                           dailyQuota: activeUser.dailyQuota,
-                          providerGroup: activeUser.providerGroup || null,
+                          providerGroup: activeUser.providerGroup || "default",
                           createdAt: new Date(),
                           updatedAt: new Date(),
                           limit5hUsd: activeUser.limit5hUsd ?? undefined,

+ 4 - 7
src/app/[locale]/dashboard/_components/user/key-row-item.tsx

@@ -96,7 +96,7 @@ function splitGroups(value?: string | null): string[] {
 
 export function KeyRowItem({
   keyData,
-  userProviderGroup,
+  userProviderGroup: _userProviderGroup,
   isMultiSelectMode,
   isSelected,
   onSelect,
@@ -119,13 +119,10 @@ export function KeyRowItem({
     currencyCode && currencyCode in CURRENCY_CONFIG ? (currencyCode as CurrencyCode) : "USD";
 
   const keyGroups = splitGroups(keyData.providerGroup);
-  const inheritedGroups = splitGroups(userProviderGroup);
-  const isInherited = keyGroups.length === 0;
-  const effectiveGroups = isInherited ? inheritedGroups : keyGroups;
+  const effectiveGroups = keyGroups.length > 0 ? keyGroups : [translations.defaultGroup];
   const visibleGroups = effectiveGroups.slice(0, 2);
   const remainingGroups = Math.max(0, effectiveGroups.length - visibleGroups.length);
-  const effectiveGroupText =
-    effectiveGroups.length > 0 ? effectiveGroups.join(", ") : translations.defaultGroup;
+  const effectiveGroupText = effectiveGroups.join(", ");
 
   const canReveal = Boolean(keyData.fullKey);
   const canCopy = Boolean(keyData.canCopy && keyData.fullKey);
@@ -246,7 +243,7 @@ export function KeyRowItem({
                     {visibleGroups.map((group) => (
                       <Badge
                         key={group}
-                        variant={isInherited ? "secondary" : "outline"}
+                        variant="outline"
                         className="text-xs font-mono max-w-[120px] truncate"
                         title={group}
                       >

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

@@ -40,6 +40,7 @@ import {
   DialogTitle,
 } from "@/components/ui/dialog";
 import { Separator } from "@/components/ui/separator";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas";
 import type { UserDisplay } from "@/types/user";
@@ -102,6 +103,21 @@ function getKeyExpiresAtIso(expiresAt: string): string | undefined {
   return parsed.toISOString();
 }
 
+function normalizeProviderGroup(value: unknown): string {
+  if (value === null || value === undefined) return PROVIDER_GROUP.DEFAULT;
+  if (typeof value !== "string") return PROVIDER_GROUP.DEFAULT;
+  const trimmed = value.trim();
+  if (trimmed === "") return PROVIDER_GROUP.DEFAULT;
+
+  const groups = trimmed
+    .split(",")
+    .map((g) => g.trim())
+    .filter(Boolean);
+  if (groups.length === 0) return PROVIDER_GROUP.DEFAULT;
+
+  return Array.from(new Set(groups)).sort().join(",");
+}
+
 function buildDefaultValues(
   mode: "create" | "edit",
   user?: UserDisplay,
@@ -132,7 +148,7 @@ function buildDefaultValues(
           isEnabled: true,
           expiresAt: undefined,
           canLoginWebUi: false,
-          providerGroup: keyOnlyMode ? user?.providerGroup || "" : "",
+          providerGroup: PROVIDER_GROUP.DEFAULT,
           cacheTtlPreference: "inherit" as const,
           limit5hUsd: null,
           limitDailyUsd: null,
@@ -158,7 +174,7 @@ function buildDefaultValues(
       note: user.note || "",
       tags: user.tags || [],
       expiresAt: user.expiresAt ?? undefined,
-      providerGroup: user.providerGroup ?? null,
+      providerGroup: normalizeProviderGroup(user.providerGroup),
       limit5hUsd: user.limit5hUsd ?? null,
       dailyQuota: user.dailyQuota ?? null,
       limitWeeklyUsd: user.limitWeeklyUsd ?? null,
@@ -176,7 +192,7 @@ function buildDefaultValues(
       isEnabled: key.status === "enabled",
       expiresAt: getKeyExpiresAtIso(key.expiresAt),
       canLoginWebUi: key.canLoginWebUi ?? false,
-      providerGroup: key.providerGroup || "",
+      providerGroup: normalizeProviderGroup(key.providerGroup),
       cacheTtlPreference: "inherit" as const,
       limit5hUsd: key.limit5hUsd ?? null,
       limitDailyUsd: key.limitDailyUsd ?? null,
@@ -259,7 +275,7 @@ function UnifiedEditDialogInner({
   );
 
   const userProviderGroups = useMemo(() => {
-    return (user?.providerGroup ?? "")
+    return normalizeProviderGroup(user?.providerGroup)
       .split(",")
       .map((g) => g.trim())
       .filter(Boolean);
@@ -297,7 +313,7 @@ function UnifiedEditDialogInner({
                   name: key.name,
                   expiresAt: key.expiresAt || undefined,
                   canLoginWebUi: key.canLoginWebUi,
-                  providerGroup: key.providerGroup?.trim() ? key.providerGroup.trim() : null,
+                  providerGroup: normalizeProviderGroup(key.providerGroup),
                   cacheTtlPreference: key.cacheTtlPreference,
                   limit5hUsd: key.limit5hUsd,
                   limitDailyUsd: key.limitDailyUsd,
@@ -350,7 +366,7 @@ function UnifiedEditDialogInner({
                   name: key.name,
                   expiresAt: key.expiresAt || undefined,
                   canLoginWebUi: key.canLoginWebUi,
-                  providerGroup: key.providerGroup?.trim() ? key.providerGroup.trim() : null,
+                  providerGroup: normalizeProviderGroup(key.providerGroup),
                   cacheTtlPreference: key.cacheTtlPreference,
                   limit5hUsd: key.limit5hUsd,
                   limitDailyUsd: key.limitDailyUsd,
@@ -387,7 +403,7 @@ function UnifiedEditDialogInner({
                 note: data.user.note,
                 tags: data.user.tags,
                 expiresAt: data.user.expiresAt ?? null,
-                providerGroup: data.user.providerGroup ?? null,
+                providerGroup: normalizeProviderGroup(data.user.providerGroup),
                 limit5hUsd: data.user.limit5hUsd,
                 dailyQuota: data.user.dailyQuota,
                 limitWeeklyUsd: data.user.limitWeeklyUsd,
@@ -414,7 +430,7 @@ function UnifiedEditDialogInner({
                   name: key.name,
                   expiresAt: key.expiresAt || undefined,
                   canLoginWebUi: key.canLoginWebUi,
-                  providerGroup: key.providerGroup?.trim() ? key.providerGroup.trim() : null,
+                  providerGroup: normalizeProviderGroup(key.providerGroup),
                   cacheTtlPreference: key.cacheTtlPreference,
                   limit5hUsd: key.limit5hUsd,
                   limitDailyUsd: key.limitDailyUsd,
@@ -438,7 +454,7 @@ function UnifiedEditDialogInner({
                   expiresAt: key.expiresAt || undefined,
                   canLoginWebUi: key.canLoginWebUi,
                   isEnabled: key.isEnabled,
-                  providerGroup: key.providerGroup?.trim() ? key.providerGroup.trim() : null,
+                  providerGroup: normalizeProviderGroup(key.providerGroup),
                   cacheTtlPreference: key.cacheTtlPreference,
                   limit5hUsd: key.limit5hUsd,
                   limitDailyUsd: key.limitDailyUsd,
@@ -716,7 +732,7 @@ function UnifiedEditDialogInner({
       isEnabled: true,
       expiresAt: undefined,
       canLoginWebUi: true,
-      providerGroup: "",
+      providerGroup: PROVIDER_GROUP.DEFAULT,
       cacheTtlPreference: "inherit" as const,
       limit5hUsd: null,
       limitDailyUsd: null,
@@ -849,7 +865,7 @@ function UnifiedEditDialogInner({
                   description: currentUserDraft.note || "",
                   tags: currentUserDraft.tags || [],
                   expiresAt: currentUserDraft.expiresAt ?? null,
-                  providerGroup: currentUserDraft.providerGroup ?? null,
+                  providerGroup: normalizeProviderGroup(currentUserDraft.providerGroup),
                   limit5hUsd: currentUserDraft.limit5hUsd ?? null,
                   dailyQuota: currentUserDraft.dailyQuota ?? null,
                   limitWeeklyUsd: currentUserDraft.limitWeeklyUsd ?? null,
@@ -941,11 +957,9 @@ function UnifiedEditDialogInner({
                           <Badge variant={key.isEnabled ? "default" : "secondary"}>
                             {key.isEnabled ? t("keyStatus.enabled") : t("keyStatus.disabled")}
                           </Badge>
-                          {key.providerGroup && (
-                            <span className="text-sm text-muted-foreground truncate">
-                              {key.providerGroup}
-                            </span>
-                          )}
+                          <span className="text-sm text-muted-foreground truncate">
+                            {normalizeProviderGroup(key.providerGroup)}
+                          </span>
                         </div>
                         {showCollapseButton && (
                           <Button type="button" variant="ghost" size="sm">
@@ -977,7 +991,7 @@ function UnifiedEditDialogInner({
                             isEnabled: key.isEnabled ?? true,
                             expiresAt: key.expiresAt ? new Date(key.expiresAt) : null,
                             canLoginWebUi: key.canLoginWebUi ?? false,
-                            providerGroup: key.providerGroup || "",
+                            providerGroup: normalizeProviderGroup(key.providerGroup),
                             cacheTtlPreference: key.cacheTtlPreference ?? "inherit",
                             limit5hUsd: key.limit5hUsd ?? null,
                             limitDailyUsd: key.limitDailyUsd ?? null,

+ 2 - 2
src/app/[locale]/dashboard/_components/user/user-key-manager.tsx

@@ -49,7 +49,7 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use
                     role: activeUser.role,
                     rpm: activeUser.rpm,
                     dailyQuota: activeUser.dailyQuota,
-                    providerGroup: activeUser.providerGroup || null,
+                    providerGroup: activeUser.providerGroup || "default",
                     createdAt: new Date(),
                     updatedAt: new Date(),
                     limit5hUsd: activeUser.limit5hUsd ?? undefined,
@@ -103,7 +103,7 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use
                     role: activeUser.role,
                     rpm: activeUser.rpm,
                     dailyQuota: activeUser.dailyQuota,
-                    providerGroup: activeUser.providerGroup || null,
+                    providerGroup: activeUser.providerGroup || "default",
                     createdAt: new Date(),
                     updatedAt: new Date(),
                     limit5hUsd: activeUser.limit5hUsd ?? undefined,

+ 1 - 1
src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx

@@ -511,7 +511,7 @@ export function ProviderListItem({
             <Tooltip>
               <TooltipTrigger asChild>
                 <div className="w-full text-center font-medium truncate text-foreground cursor-help">
-                  <span>{item.groupTag || "-"}</span>
+                  <span>{item.groupTag || "default"}</span>
                 </div>
               </TooltipTrigger>
               <TooltipContent side="bottom" className="max-w-xs">

+ 22 - 9
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -42,6 +42,7 @@ import {
   DialogTitle,
 } from "@/components/ui/dialog";
 import { Switch } from "@/components/ui/switch";
+import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils";
 import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard";
 import type { CurrencyCode } from "@/lib/utils/currency";
@@ -294,15 +295,27 @@ export function ProviderRichListItem({
             <span className="font-semibold truncate">{provider.name}</span>
 
             {/* Group Tags (supports comma-separated values) */}
-            {provider.groupTag
-              ?.split(",")
-              .map((t) => t.trim())
-              .filter(Boolean)
-              .map((tag, index) => (
-                <Badge key={`${tag}-${index}`} variant="outline" className="flex-shrink-0">
-                  {tag}
-                </Badge>
-              ))}
+            {(provider.groupTag
+              ? provider.groupTag
+                  .split(",")
+                  .map((t) => t.trim())
+                  .filter(Boolean)
+              : []
+            ).length > 0 ? (
+              provider.groupTag
+                ?.split(",")
+                .map((t) => t.trim())
+                .filter(Boolean)
+                .map((tag, index) => (
+                  <Badge key={`${tag}-${index}`} variant="outline" className="flex-shrink-0">
+                    {tag}
+                  </Badge>
+                ))
+            ) : (
+              <Badge variant="outline" className="flex-shrink-0">
+                {PROVIDER_GROUP.DEFAULT}
+              </Badge>
+            )}
 
             {/* 熔断器警告 */}
             {healthStatus && healthStatus.circuitState === "open" && (

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

@@ -67,7 +67,7 @@ function getEffectiveProviderGroup(session?: ProxySession): string | null {
   if (user) {
     return user.providerGroup || PROVIDER_GROUP.DEFAULT;
   }
-  return null;
+  return PROVIDER_GROUP.DEFAULT;
 }
 
 /**

+ 3 - 3
src/drizzle/schema.ts

@@ -25,7 +25,7 @@ export const users = pgTable('users', {
   role: varchar('role').default('user'),
   rpmLimit: integer('rpm_limit').default(60),
   dailyLimitUsd: numeric('daily_limit_usd', { precision: 10, scale: 2 }).default('100.00'),
-  providerGroup: varchar('provider_group', { length: 50 }),
+  providerGroup: varchar('provider_group', { length: 50 }).default('default'),
   // 用户标签(用于分类和筛选)
   tags: jsonb('tags').$type<string[]>().default([]),
 
@@ -97,8 +97,8 @@ export const keys = pgTable('keys', {
   limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }),
   limitConcurrentSessions: integer('limit_concurrent_sessions').default(0),
 
-  // Provider group override (null = inherit from user)
-  providerGroup: varchar('provider_group', { length: 50 }),
+  // Provider group for this key (explicit; defaults to "default")
+  providerGroup: varchar('provider_group', { length: 50 }).default('default'),
 
   // Cache TTL override:null/NULL 表示遵循供应商或客户端请求
   cacheTtlPreference: varchar('cache_ttl_preference', { length: 10 }),