فهرست منبع

feat: add provider group functionality to key management

- Introduced a new provider group feature for key creation and editing forms, allowing users to specify provider group tags.
- Updated related schemas, actions, and UI components to support provider group input and validation.
- Enhanced backend logic to handle provider group restrictions during key validation and provider selection.
- Added translations for provider group labels and descriptions in both English and Chinese.
ding113 2 ماه پیش
والد
کامیت
cc4e9be672

+ 1 - 0
drizzle/0030_unusual_goliath.sql

@@ -0,0 +1 @@
+ALTER TABLE "keys" ADD COLUMN "provider_group" varchar(50);

+ 1823 - 0
drizzle/meta/0030_snapshot.json

@@ -0,0 +1,1823 @@
+{
+  "id": "11225ca4-98e4-4ff3-b2e0-ff3430ae3808",
+  "prevId": "bcba9240-4036-4c31-ac76-1f76b7dd78f2",
+  "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
+        },
+        "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
+        },
+        "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
+        },
+        "error_message": {
+          "name": "error_message",
+          "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_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'"
+        },
+        "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
+        },
+        "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
+        },
+        "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
+        },
+        "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
+        },
+        "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

@@ -211,6 +211,13 @@
       "when": 1765029260679,
       "tag": "0029_chemical_tenebrous",
       "breakpoints": true
+    },
+    {
+      "idx": 30,
+      "version": "7",
+      "when": 1765030525131,
+      "tag": "0030_unusual_goliath",
+      "breakpoints": true
     }
   ]
 }

+ 6 - 0
messages/en/dashboard.json

@@ -646,6 +646,12 @@
       "description": "Number of simultaneous conversations",
       "descriptionWithUserLimit": "Maximum sessions (User limit: {limit})"
     },
+    "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})"
+    },
     "errors": {
       "userIdMissing": "User ID does not exist",
       "createFailed": "Failed to create, please try again later",

+ 6 - 0
messages/en/quota.json

@@ -331,6 +331,12 @@
         "placeholder": "0 means unlimited",
         "description": "Number of simultaneous conversations"
       },
+      "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})"
+      },
       "submitText": "Save Changes",
       "loadingText": "Saving...",
       "success": "Key updated successfully",

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

@@ -646,6 +646,12 @@
       "description": "同时运行的对话数量",
       "descriptionWithUserLimit": "同时运行的对话数量(用户限额: {limit})"
     },
+    "providerGroup": {
+      "label": "供应商分组",
+      "placeholder": "输入供应商分组标签,按回车添加",
+      "description": "限制可使用的供应商分组(留空继承用户设置)",
+      "descriptionWithUserGroup": "限制可使用的供应商分组(用户分组: {group})"
+    },
     "errors": {
       "userIdMissing": "用户ID不存在",
       "createFailed": "创建失败,请稍后重试",

+ 6 - 0
messages/zh-CN/quota.json

@@ -335,6 +335,12 @@
         "description": "同时运行的对话数量",
         "descriptionWithUserLimit": "同时运行的对话数量(用户限额: {limit})"
       },
+      "providerGroup": {
+        "label": "供应商分组",
+        "placeholder": "输入供应商分组标签,按回车添加",
+        "description": "限制可使用的供应商分组(留空继承用户设置)",
+        "descriptionWithUserGroup": "限制可使用的供应商分组(用户分组: {group})"
+      },
       "submitText": "保存修改",
       "loadingText": "保存中...",
       "success": "密钥更新成功",

+ 6 - 1
src/actions/keys.ts

@@ -34,6 +34,7 @@ export async function addKey(data: {
   limitMonthlyUsd?: number | null;
   limitTotalUsd?: number | null;
   limitConcurrentSessions?: number;
+  providerGroup?: string | null;
 }): Promise<ActionResult<{ generatedKey: string; name: string }>> {
   try {
     // 权限检查:用户只能给自己添加Key,管理员可以给所有人添加Key
@@ -57,6 +58,7 @@ export async function addKey(data: {
       limitMonthlyUsd: data.limitMonthlyUsd,
       limitTotalUsd: data.limitTotalUsd,
       limitConcurrentSessions: data.limitConcurrentSessions,
+      providerGroup: data.providerGroup,
     });
 
     // 检查是否存在同名的生效key
@@ -151,6 +153,7 @@ 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,
     });
 
     revalidatePath("/dashboard");
@@ -179,6 +182,7 @@ export async function editKey(
     limitMonthlyUsd?: number | null;
     limitTotalUsd?: number | null;
     limitConcurrentSessions?: number;
+    providerGroup?: string | null;
   }
 ): Promise<ActionResult> {
   try {
@@ -285,12 +289,13 @@ export async function editKey(
       limit_monthly_usd: validatedData.limitMonthlyUsd,
       limit_total_usd: validatedData.limitTotalUsd,
       limit_concurrent_sessions: validatedData.limitConcurrentSessions,
+      provider_group: validatedData.providerGroup || null,
     });
 
     revalidatePath("/dashboard");
     return { ok: true };
   } catch (error) {
-    logger.error("更新密钥失败:", error);
+    logger.error("Failed to update key:", error);
     const message = error instanceof Error ? error.message : "更新密钥失败,请稍后重试";
     return { ok: false, error: message };
   }

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

@@ -1,10 +1,11 @@
 "use client";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
-import { useTransition } from "react";
+import { useEffect, useState, useTransition } from "react";
 import { toast } from "sonner";
 import { addKey } from "@/actions/keys";
-import { DateField, NumberField, TextField } from "@/components/form/form-field";
+import { getAvailableProviderGroups } from "@/actions/providers";
+import { DateField, NumberField, TagInputField, TextField } from "@/components/form/form-field";
 import { DialogFormLayout, FormGrid } from "@/components/form/form-layout";
 import { Label } from "@/components/ui/label";
 import {
@@ -27,8 +28,15 @@ interface AddKeyFormProps {
 
 export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
   const [isPending, startTransition] = useTransition();
+  const [providerGroupSuggestions, setProviderGroupSuggestions] = useState<string[]>([]);
   const router = useRouter();
   const t = useTranslations("dashboard.addKeyForm");
+  const tUI = useTranslations("ui.tagInput");
+
+  // Load provider group suggestions
+  useEffect(() => {
+    getAvailableProviderGroups().then(setProviderGroupSuggestions);
+  }, []);
 
   const form = useZodForm({
     schema: KeyFormSchema,
@@ -36,6 +44,7 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
       name: "",
       expiresAt: "",
       canLoginWebUi: true,
+      providerGroup: "",
       limit5hUsd: null,
       limitDailyUsd: null,
       dailyResetMode: "fixed" as const,
@@ -56,6 +65,7 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
           name: data.name,
           expiresAt: data.expiresAt || undefined,
           canLoginWebUi: data.canLoginWebUi,
+          providerGroup: data.providerGroup || null,
           limit5hUsd: data.limit5hUsd,
           limitDailyUsd: data.limitDailyUsd,
           dailyResetMode: data.dailyResetMode,
@@ -133,6 +143,32 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
         />
       </div>
 
+      <TagInputField
+        label={t("providerGroup.label")}
+        maxTagLength={50}
+        placeholder={t("providerGroup.placeholder")}
+        description={
+          user?.providerGroup
+            ? t("providerGroup.descriptionWithUserGroup", { group: user.providerGroup })
+            : t("providerGroup.description")
+        }
+        suggestions={providerGroupSuggestions}
+        onInvalidTag={(_tag, reason) => {
+          const messages: Record<string, string> = {
+            empty: tUI("emptyTag"),
+            duplicate: tUI("duplicateTag"),
+            too_long: tUI("tooLong", { max: 50 }),
+            invalid_format: tUI("invalidFormat"),
+            max_tags: tUI("maxTags"),
+          };
+          toast.error(messages[reason] || reason);
+        }}
+        value={String(form.getFieldProps("providerGroup").value)}
+        onChange={form.getFieldProps("providerGroup").onChange}
+        error={form.getFieldProps("providerGroup").error}
+        touched={form.getFieldProps("providerGroup").touched}
+      />
+
       <FormGrid columns={2}>
         <NumberField
           label={t("limit5hUsd.label")}

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

@@ -1,10 +1,11 @@
 "use client";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
-import { useTransition } from "react";
+import { useEffect, useState, useTransition } from "react";
 import { toast } from "sonner";
 import { editKey } from "@/actions/keys";
-import { DateField, NumberField, TextField } from "@/components/form/form-field";
+import { getAvailableProviderGroups } from "@/actions/providers";
+import { DateField, NumberField, TagInputField, TextField } from "@/components/form/form-field";
 import { DialogFormLayout, FormGrid } from "@/components/form/form-layout";
 import { Label } from "@/components/ui/label";
 import {
@@ -25,6 +26,7 @@ interface EditKeyFormProps {
     name: string;
     expiresAt: string;
     canLoginWebUi?: boolean;
+    providerGroup?: string | null;
     limit5hUsd?: number | null;
     limitDailyUsd?: number | null;
     dailyResetMode?: "fixed" | "rolling";
@@ -40,8 +42,15 @@ interface EditKeyFormProps {
 
 export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
   const [isPending, startTransition] = useTransition();
+  const [providerGroupSuggestions, setProviderGroupSuggestions] = useState<string[]>([]);
   const router = useRouter();
   const t = useTranslations("quota.keys.editKeyForm");
+  const tUI = useTranslations("ui.tagInput");
+
+  // Load provider group suggestions
+  useEffect(() => {
+    getAvailableProviderGroups().then(setProviderGroupSuggestions);
+  }, []);
 
   const formatExpiresAt = (expiresAt: string) => {
     if (!expiresAt || expiresAt === "永不过期") return "";
@@ -58,6 +67,7 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
       name: keyData?.name || "",
       expiresAt: formatExpiresAt(keyData?.expiresAt || ""),
       canLoginWebUi: keyData?.canLoginWebUi ?? true,
+      providerGroup: keyData?.providerGroup || "",
       limit5hUsd: keyData?.limit5hUsd ?? null,
       limitDailyUsd: keyData?.limitDailyUsd ?? null,
       dailyResetMode: keyData?.dailyResetMode ?? "fixed",
@@ -78,6 +88,7 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
             name: data.name,
             expiresAt: data.expiresAt || undefined,
             canLoginWebUi: data.canLoginWebUi,
+            providerGroup: data.providerGroup || null,
             limit5hUsd: data.limit5hUsd,
             limitDailyUsd: data.limitDailyUsd,
             dailyResetMode: data.dailyResetMode,
@@ -145,6 +156,32 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
         />
       </div>
 
+      <TagInputField
+        label={t("providerGroup.label")}
+        maxTagLength={50}
+        placeholder={t("providerGroup.placeholder")}
+        description={
+          user?.providerGroup
+            ? t("providerGroup.descriptionWithUserGroup", { group: user.providerGroup })
+            : t("providerGroup.description")
+        }
+        suggestions={providerGroupSuggestions}
+        onInvalidTag={(_tag, reason) => {
+          const messages: Record<string, string> = {
+            empty: tUI("emptyTag"),
+            duplicate: tUI("duplicateTag"),
+            too_long: tUI("tooLong", { max: 50 }),
+            invalid_format: tUI("invalidFormat"),
+            max_tags: tUI("maxTags"),
+          };
+          toast.error(messages[reason] || reason);
+        }}
+        value={String(form.getFieldProps("providerGroup").value)}
+        onChange={form.getFieldProps("providerGroup").onChange}
+        error={form.getFieldProps("providerGroup").error}
+        touched={form.getFieldProps("providerGroup").touched}
+      />
+
       <FormGrid columns={2}>
         <NumberField
           label={t("limit5hUsd.label")}

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

@@ -495,40 +495,61 @@ export class ProxyProviderResolver {
     }
 
     // 修复:检查用户分组权限(严格分组隔离 + 支持多分组)
+    // Check if session provider matches user's group
+    // Priority: key.providerGroup > user.providerGroup
+    const keyGroup = session?.authState?.key?.providerGroup;
     const userGroup = session?.authState?.user?.providerGroup;
-    if (userGroup) {
-      // 用户有分组,支持多个分组(逗号分隔)
-      const userGroups = userGroup
+    const effectiveGroup = keyGroup || userGroup;
+    if (effectiveGroup) {
+      // User/key has group restriction, support multiple groups (comma-separated)
+      const groups = effectiveGroup
         .split(",")
         .map((g) => g.trim())
         .filter(Boolean);
 
-      // 检查供应商的 groupTag 与用户的分组是否有交集
-      // 修复 #190: 支持供应商多标签(如 "cli,chat")与用户单标签(如 "cli")的匹配
-      if (provider.groupTag) {
-        // 将供应商的 groupTag 拆分成标签数组
-        const providerTags = provider.groupTag
-          .split(",")
-          .map((tag) => tag.trim())
-          .filter(Boolean);
-
-        // 检查是否有交集
-        const hasIntersection = providerTags.some((tag) => userGroups.includes(tag));
-
-        if (!hasIntersection) {
-          logger.warn("ProviderSelector: Session provider not in user groups", {
+      // Check if provider's groupTag intersects with the effective groups
+      // Fix #190: Support provider multi-tags (e.g. "cli,chat") matching user single-tag (e.g. "cli")
+      // Fix #281: Reject providers without groupTag when user/key has group restrictions
+      if (!provider.groupTag) {
+        // Provider has no group tag but user/key requires group - reject reuse
+        logger.warn(
+          "ProviderSelector: Session provider has no group tag but user/key requires group",
+          {
             sessionId: session.sessionId,
             providerId: provider.id,
             providerName: provider.name,
-            providerTags: providerTags.join(","),
-            userGroups: userGroups.join(","),
-            message: "Strict group isolation: rejecting cross-group session reuse",
-          });
-          return null; // 不允许复用,重新选择
-        }
+            effectiveGroups: groups.join(","),
+            keyGroupOverride: !!keyGroup,
+            message:
+              "Strict group isolation: rejecting untagged provider for group-scoped user/key",
+          }
+        );
+        return null; // Reject reuse, re-select
+      }
+
+      // Split provider's groupTag into tag array
+      const providerTags = provider.groupTag
+        .split(",")
+        .map((tag) => tag.trim())
+        .filter(Boolean);
+
+      // Check for intersection
+      const hasIntersection = providerTags.some((tag) => groups.includes(tag));
+
+      if (!hasIntersection) {
+        logger.warn("ProviderSelector: Session provider not in user groups", {
+          sessionId: session.sessionId,
+          providerId: provider.id,
+          providerName: provider.name,
+          providerTags: providerTags.join(","),
+          effectiveGroups: groups.join(","),
+          keyGroupOverride: !!keyGroup,
+          message: "Strict group isolation: rejecting cross-group session reuse",
+        });
+        return null; // Reject reuse, re-select
       }
     }
-    // 全局用户(userGroup 为空)可以复用任何供应商
+    // Global user/key (effectiveGroup is empty) can reuse any provider
 
     logger.info("ProviderSelector: Reusing provider", {
       providerName: provider.name,
@@ -660,55 +681,59 @@ export class ProxyProviderResolver {
       return { provider: null, context };
     }
 
-    // Step 2: 用户分组过滤(如果用户指定了分组)
+    // Step 2: Provider group filter (key > user priority)
     let candidateProviders = enabledProviders;
-    const userGroup = session?.authState?.user?.providerGroup;
+    const keyGroupPick = session?.authState?.key?.providerGroup;
+    const userGroupPick = session?.authState?.user?.providerGroup;
+    const effectiveGroupPick = keyGroupPick || userGroupPick;
 
-    if (userGroup) {
-      context.userGroup = userGroup;
+    if (effectiveGroupPick) {
+      context.userGroup = effectiveGroupPick;
 
-      // 修复:支持多个分组(逗号分隔,如 "fero,chen")
-      const userGroups = userGroup
+      // Support multiple groups (comma-separated, e.g. "fero,chen")
+      const groups = effectiveGroupPick
         .split(",")
         .map((g) => g.trim())
         .filter(Boolean);
 
-      // 过滤:供应商的 groupTag 与用户的分组有交集
-      // 修复 #190: 支持供应商多标签(如 "cli,chat")与用户单标签(如 "cli")的匹配
+      // Filter: provider's groupTag intersects with effective groups
+      // Fix #190: Support provider multi-tags (e.g. "cli,chat") matching user single-tag (e.g. "cli")
       const groupFiltered = enabledProviders.filter((p) => {
         if (!p.groupTag) return false;
 
-        // 将供应商的 groupTag 拆分成标签数组
+        // Split provider's groupTag into tag array
         const providerTags = p.groupTag
           .split(",")
           .map((tag) => tag.trim())
           .filter(Boolean);
 
-        // 检查是否有交集:用户的分组中是否有任意一个标签在供应商的标签列表中
-        return providerTags.some((tag) => userGroups.includes(tag));
+        // Check for intersection: any of user's groups in provider's tag list
+        return providerTags.some((tag) => groups.includes(tag));
       });
 
       if (groupFiltered.length > 0) {
         candidateProviders = groupFiltered;
         context.groupFilterApplied = true;
         context.afterGroupFilter = groupFiltered.length;
-        logger.debug("ProviderSelector: User multi-group filter applied", {
-          userGroup,
-          userGroups,
+        logger.debug("ProviderSelector: Effective group filter applied", {
+          effectiveGroup: effectiveGroupPick,
+          keyGroupOverride: !!keyGroupPick,
+          groups,
           count: groupFiltered.length,
         });
       } else {
-        // 修复:严格分组隔离,无可用供应商时返回错误而不是 fallback
+        // Strict group isolation: return error when no available providers instead of fallback
         context.groupFilterApplied = false;
         context.afterGroupFilter = 0;
-        logger.error("ProviderSelector: User groups have no available providers", {
-          userGroup,
-          userGroups,
+        logger.error("ProviderSelector: Effective groups have no available providers", {
+          effectiveGroup: effectiveGroupPick,
+          keyGroupOverride: !!keyGroupPick,
+          groups,
           enabledProviders: enabledProviders.length,
           message: "Strict group isolation: returning null instead of fallback",
         });
 
-        // 返回 null 表示无可用供应商
+        // Return null to indicate no available provider
         return {
           provider: null,
           context,
@@ -778,7 +803,7 @@ export class ProxyProviderResolver {
       totalProviders: allProviders.length,
       enabledCount: enabledProviders.length,
       excludedIds: excludeIds,
-      userGroup: userGroup || "none",
+      userGroup: effectiveGroupPick || "none",
       afterGroupFilter: candidateProviders.map((p) => p.name),
       afterHealthFilter: healthyProviders.length,
       filteredOut: filteredOut.map((p) => p.name),

+ 3 - 0
src/drizzle/schema.ts

@@ -81,6 +81,9 @@ 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 }),
+
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
   deletedAt: timestamp('deleted_at', { withTimezone: true }),

+ 1 - 0
src/lib/auth.ts

@@ -49,6 +49,7 @@ export async function validateKey(
       key: keyString,
       isEnabled: true,
       canLoginWebUi: true, // Admin Token 始终可以登录 Web UI
+      providerGroup: null,
       limit5hUsd: null,
       limitDailyUsd: null,
       dailyResetMode: "fixed",

+ 1 - 0
src/lib/validation/schemas.ts

@@ -198,6 +198,7 @@ export const KeyFormSchema = z.object({
     .max(1000, "并发Session上限不能超过1000")
     .optional()
     .default(0),
+  providerGroup: z.string().max(50, "供应商分组不能超过50个字符").optional().default(""),
 });
 
 /**

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

@@ -43,6 +43,7 @@ export function toKey(dbKey: any): Key {
         ? parseFloat(dbKey.limitTotalUsd)
         : null,
     limitConcurrentSessions: dbKey?.limitConcurrentSessions ?? 0,
+    providerGroup: dbKey?.providerGroup ?? null,
     createdAt: dbKey?.createdAt ? new Date(dbKey.createdAt) : new Date(),
     updatedAt: dbKey?.updatedAt ? new Date(dbKey.updatedAt) : new Date(),
   };

+ 10 - 0
src/repository/key.ts

@@ -26,6 +26,7 @@ export async function findKeyById(id: number): Promise<Key | null> {
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitTotalUsd: keys.limitTotalUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
+      providerGroup: keys.providerGroup,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -55,6 +56,7 @@ export async function findKeyList(userId: number): Promise<Key[]> {
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitTotalUsd: keys.limitTotalUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
+      providerGroup: keys.providerGroup,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -83,6 +85,7 @@ export async function createKey(keyData: CreateKeyData): Promise<Key> {
       keyData.limit_monthly_usd != null ? keyData.limit_monthly_usd.toString() : null,
     limitTotalUsd: keyData.limit_total_usd != null ? keyData.limit_total_usd.toString() : null,
     limitConcurrentSessions: keyData.limit_concurrent_sessions,
+    providerGroup: keyData.provider_group ?? null,
   };
 
   const [key] = await db.insert(keys).values(dbData).returning({
@@ -101,6 +104,7 @@ export async function createKey(keyData: CreateKeyData): Promise<Key> {
     limitMonthlyUsd: keys.limitMonthlyUsd,
     limitTotalUsd: keys.limitTotalUsd,
     limitConcurrentSessions: keys.limitConcurrentSessions,
+    providerGroup: keys.providerGroup,
     createdAt: keys.createdAt,
     updatedAt: keys.updatedAt,
     deletedAt: keys.deletedAt,
@@ -140,6 +144,7 @@ export async function updateKey(id: number, keyData: UpdateKeyData): Promise<Key
       keyData.limit_total_usd != null ? keyData.limit_total_usd.toString() : null;
   if (keyData.limit_concurrent_sessions !== undefined)
     dbData.limitConcurrentSessions = keyData.limit_concurrent_sessions;
+  if (keyData.provider_group !== undefined) dbData.providerGroup = keyData.provider_group;
 
   const [key] = await db
     .update(keys)
@@ -161,6 +166,7 @@ export async function updateKey(id: number, keyData: UpdateKeyData): Promise<Key
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitTotalUsd: keys.limitTotalUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
+      providerGroup: keys.providerGroup,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -191,6 +197,7 @@ export async function findActiveKeyByUserIdAndName(
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitTotalUsd: keys.limitTotalUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
+      providerGroup: keys.providerGroup,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -282,6 +289,7 @@ export async function findActiveKeyByKeyString(keyString: string): Promise<Key |
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitTotalUsd: keys.limitTotalUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
+      providerGroup: keys.providerGroup,
       createdAt: keys.createdAt,
       updatedAt: keys.updatedAt,
       deletedAt: keys.deletedAt,
@@ -322,6 +330,7 @@ export async function validateApiKeyAndGetUser(
       keyLimitMonthlyUsd: keys.limitMonthlyUsd,
       keyLimitTotalUsd: keys.limitTotalUsd,
       keyLimitConcurrentSessions: keys.limitConcurrentSessions,
+      keyProviderGroup: keys.providerGroup,
       keyCreatedAt: keys.createdAt,
       keyUpdatedAt: keys.updatedAt,
       keyDeletedAt: keys.deletedAt,
@@ -390,6 +399,7 @@ export async function validateApiKeyAndGetUser(
     limitMonthlyUsd: row.keyLimitMonthlyUsd,
     limitTotalUsd: row.keyLimitTotalUsd,
     limitConcurrentSessions: row.keyLimitConcurrentSessions,
+    providerGroup: row.keyProviderGroup,
     createdAt: row.keyCreatedAt,
     updatedAt: row.keyUpdatedAt,
     deletedAt: row.keyDeletedAt,

+ 7 - 0
src/types/key.ts

@@ -22,6 +22,9 @@ export interface Key {
   limitTotalUsd?: number | null;
   limitConcurrentSessions: number;
 
+  // Provider group override (null = inherit from user)
+  providerGroup: string | null;
+
   createdAt: Date;
   updatedAt: Date;
   deletedAt?: Date;
@@ -47,6 +50,8 @@ export interface CreateKeyData {
   limit_monthly_usd?: number | null;
   limit_total_usd?: number | null;
   limit_concurrent_sessions?: number;
+  // Provider group override (null = inherit from user)
+  provider_group?: string | null;
 }
 
 /**
@@ -67,4 +72,6 @@ export interface UpdateKeyData {
   limit_monthly_usd?: number | null;
   limit_total_usd?: number | null;
   limit_concurrent_sessions?: number;
+  // Provider group override (null = inherit from user)
+  provider_group?: string | null;
 }