Przeglądaj źródła

feat: add provider/group binding for request filters

Allow Request Filters to be applied globally, to specific providers, or to provider groups.

## Key Changes

### Database Schema
- Add `bindingType` field ('global' | 'providers' | 'groups')
- Add `providerIds` field (array of provider IDs)
- Add `groupTags` field (array of group tags)
- Migration: 0041_sticky_jackal.sql

### Backend Logic
- Split filter engine into global and provider-specific filters
- `applyGlobal()`: runs BEFORE provider selection
- `applyForProvider()`: runs AFTER provider selection
- Provider matching: checks provider IDs or group tags

### Guard Pipeline
- Add new `providerRequestFilter` guard step
- CHAT_PIPELINE order: [..., provider, providerRequestFilter, messageContext]
- COUNT_TOKENS_PIPELINE includes provider filters

### UI Components
- New: `ProviderMultiSelect` - multi-select for providers
- New: `GroupMultiSelect` - multi-select for group tags
- Updated: `FilterDialog` - binding type selector moved after Name field
- Updated: `FilterTable` - compact layout with icon-only display
- Binding display: Global (icon only), Providers (icon + count), Groups (icon + count)

### Localization
- Add translations for 5 languages (en, ru, ja, zh-CN, zh-TW)
- Column name changed from "Apply To" to "Apply"

### Bug Fixes
- Fix binding type change validation (use explicit undefined check instead of ??)
- Prevent stale binding data when switching types

### Other Improvements
- Compact last 4 columns in filter table (Priority, Apply, Status, Actions)
- Improved UX: binding configuration at top of dialog

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

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
John Doe 1 miesiąc temu
rodzic
commit
d3a36df656

+ 4 - 0
drizzle/0041_sticky_jackal.sql

@@ -0,0 +1,4 @@
+ALTER TABLE "request_filters" ADD COLUMN "binding_type" varchar(20) DEFAULT 'global' NOT NULL;--> statement-breakpoint
+ALTER TABLE "request_filters" ADD COLUMN "provider_ids" jsonb;--> statement-breakpoint
+ALTER TABLE "request_filters" ADD COLUMN "group_tags" jsonb;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "idx_request_filters_binding" ON "request_filters" USING btree ("is_enabled","binding_type");

+ 1991 - 0
drizzle/meta/0041_snapshot.json

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

@@ -288,6 +288,13 @@
       "when": 1766509746306,
       "tag": "0040_bored_venus",
       "breakpoints": true
+    },
+    {
+      "idx": 41,
+      "version": "7",
+      "when": 1766696732309,
+      "tag": "0041_sticky_jackal",
+      "breakpoints": true
     }
   ]
 }

+ 17 - 1
messages/en/settings.json

@@ -1619,7 +1619,22 @@
       "saving": "Saving...",
       "validation": {
         "fieldRequired": "Name and target are required"
-      }
+      },
+      "bindingType": "Apply To",
+      "bindingGlobal": "All Providers (Global)",
+      "bindingProviders": "Specific Providers",
+      "bindingGroups": "Provider Groups",
+      "selectProviders": "Select providers...",
+      "selectGroups": "Select groups...",
+      "searchProviders": "Search providers...",
+      "searchGroups": "Search groups...",
+      "noProvidersFound": "No providers found",
+      "noGroupsFound": "No groups found",
+      "providersSelected": "{count} provider(s) selected",
+      "groupsSelected": "{count} group(s) selected",
+      "loading": "Loading...",
+      "clear": "Clear",
+      "selectAll": "Select All"
     },
     "table": {
       "name": "Name",
@@ -1628,6 +1643,7 @@
       "target": "Target",
       "replacement": "Replacement",
       "priority": "Priority",
+      "apply": "Apply",
       "status": "Status",
       "createdAt": "Created At",
       "actions": "Actions"

+ 17 - 1
messages/ja/settings.json

@@ -1569,7 +1569,22 @@
       "saving": "保存中...",
       "validation": {
         "fieldRequired": "名称と対象は必須です"
-      }
+      },
+      "bindingType": "適用先",
+      "bindingGlobal": "全プロバイダー(グローバル)",
+      "bindingProviders": "特定のプロバイダー",
+      "bindingGroups": "プロバイダーグループ",
+      "selectProviders": "プロバイダーを選択...",
+      "selectGroups": "グループを選択...",
+      "searchProviders": "プロバイダー検索...",
+      "searchGroups": "グループ検索...",
+      "noProvidersFound": "プロバイダーが見つかりません",
+      "noGroupsFound": "グループが見つかりません",
+      "providersSelected": "{count}件のプロバイダーを選択",
+      "groupsSelected": "{count}件のグループを選択",
+      "loading": "読み込み中...",
+      "clear": "クリア",
+      "selectAll": "すべて選択"
     },
     "table": {
       "name": "名称",
@@ -1578,6 +1593,7 @@
       "target": "対象",
       "replacement": "置換値",
       "priority": "優先度",
+      "apply": "適用",
       "status": "状態",
       "createdAt": "作成日時",
       "actions": "操作"

+ 17 - 1
messages/ru/settings.json

@@ -1569,7 +1569,22 @@
       "saving": "Сохранение...",
       "validation": {
         "fieldRequired": "Название и цель обязательны"
-      }
+      },
+      "bindingType": "Применить к",
+      "bindingGlobal": "Все провайдеры (глобально)",
+      "bindingProviders": "Конкретные провайдеры",
+      "bindingGroups": "Группы провайдеров",
+      "selectProviders": "Выберите провайдеров...",
+      "selectGroups": "Выберите группы...",
+      "searchProviders": "Поиск провайдеров...",
+      "searchGroups": "Поиск групп...",
+      "noProvidersFound": "Провайдеры не найдены",
+      "noGroupsFound": "Группы не найдены",
+      "providersSelected": "Выбрано провайдеров: {count}",
+      "groupsSelected": "Выбрано групп: {count}",
+      "loading": "Загрузка...",
+      "clear": "Очистить",
+      "selectAll": "Выбрать все"
     },
     "table": {
       "name": "Название",
@@ -1578,6 +1593,7 @@
       "target": "Цель",
       "replacement": "Значение",
       "priority": "Приоритет",
+      "apply": "Область",
       "status": "Статус",
       "createdAt": "Создано",
       "actions": "Действия"

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

@@ -1296,7 +1296,22 @@
       "saving": "保存中...",
       "validation": {
         "fieldRequired": "名称和目标为必填项"
-      }
+      },
+      "bindingType": "应用范围",
+      "bindingGlobal": "所有Provider(全局)",
+      "bindingProviders": "指定Provider",
+      "bindingGroups": "Provider分组",
+      "selectProviders": "选择Provider...",
+      "selectGroups": "选择分组...",
+      "searchProviders": "搜索Provider...",
+      "searchGroups": "搜索分组...",
+      "noProvidersFound": "未找到Provider",
+      "noGroupsFound": "未找到分组",
+      "providersSelected": "已选 {count} 个Provider",
+      "groupsSelected": "已选 {count} 个分组",
+      "loading": "加载中...",
+      "clear": "清空",
+      "selectAll": "全选"
     },
     "table": {
       "name": "名称",
@@ -1305,6 +1320,7 @@
       "target": "目标",
       "replacement": "替换值",
       "priority": "优先级",
+      "apply": "范围",
       "status": "状态",
       "createdAt": "创建时间",
       "actions": "操作"

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

@@ -1575,7 +1575,22 @@
       "saving": "保存中...",
       "validation": {
         "fieldRequired": "名稱和目標為必填項"
-      }
+      },
+      "bindingType": "套用範圍",
+      "bindingGlobal": "所有Provider(全域)",
+      "bindingProviders": "指定Provider",
+      "bindingGroups": "Provider分組",
+      "selectProviders": "選擇Provider...",
+      "selectGroups": "選擇分組...",
+      "searchProviders": "搜尋Provider...",
+      "searchGroups": "搜尋分組...",
+      "noProvidersFound": "找不到Provider",
+      "noGroupsFound": "找不到分組",
+      "providersSelected": "已選 {count} 個Provider",
+      "groupsSelected": "已選 {count} 個分組",
+      "loading": "載入中...",
+      "clear": "清空",
+      "selectAll": "全選"
     },
     "table": {
       "name": "名稱",
@@ -1584,6 +1599,7 @@
       "target": "目標",
       "replacement": "替換值",
       "priority": "優先級",
+      "apply": "範圍",
       "status": "狀態",
       "createdAt": "建立時間",
       "actions": "操作"

+ 129 - 0
src/actions/request-filters.ts

@@ -12,6 +12,7 @@ import {
   getRequestFilterById,
   type RequestFilter,
   type RequestFilterAction,
+  type RequestFilterBindingType,
   type RequestFilterMatchType,
   type RequestFilterScope,
   updateRequestFilter,
@@ -31,6 +32,9 @@ function validatePayload(data: {
   target: string;
   matchType?: RequestFilterMatchType;
   replacement?: unknown;
+  bindingType?: RequestFilterBindingType;
+  providerIds?: number[] | null;
+  groupTags?: string[] | null;
 }): string | null {
   if (!data.name?.trim()) return "名称不能为空";
   if (!data.target?.trim()) return "目标字段不能为空";
@@ -40,6 +44,34 @@ function validatePayload(data: {
       return "正则表达式存在 ReDoS 风险";
     }
   }
+
+  // Validate binding type constraints
+  const bindingType = data.bindingType ?? "global";
+  if (bindingType === "providers") {
+    if (!data.providerIds || data.providerIds.length === 0) {
+      return "至少选择一个 Provider";
+    }
+    if (data.groupTags && data.groupTags.length > 0) {
+      return "不能同时选择 Providers 和 Groups";
+    }
+  }
+  if (bindingType === "groups") {
+    if (!data.groupTags || data.groupTags.length === 0) {
+      return "至少选择一个 Group Tag";
+    }
+    if (data.providerIds && data.providerIds.length > 0) {
+      return "不能同时选择 Providers 和 Groups";
+    }
+  }
+  if (bindingType === "global") {
+    if (
+      (data.providerIds && data.providerIds.length > 0) ||
+      (data.groupTags && data.groupTags.length > 0)
+    ) {
+      return "Global 类型不能指定 Providers 或 Groups";
+    }
+  }
+
   return null;
 }
 
@@ -65,6 +97,9 @@ export async function createRequestFilterAction(data: {
   matchType?: RequestFilterMatchType;
   replacement?: unknown;
   priority?: number;
+  bindingType?: RequestFilterBindingType;
+  providerIds?: number[] | null;
+  groupTags?: string[] | null;
 }): Promise<ActionResult<RequestFilter>> {
   const session = await getSession();
   if (!isAdmin(session)) return { ok: false, error: "权限不足" };
@@ -82,6 +117,9 @@ export async function createRequestFilterAction(data: {
       matchType: data.matchType ?? null,
       replacement: data.replacement ?? null,
       priority: data.priority ?? 0,
+      bindingType: data.bindingType ?? "global",
+      providerIds: data.providerIds ?? null,
+      groupTags: data.groupTags ?? null,
     });
 
     revalidatePath(SETTINGS_PATH);
@@ -104,6 +142,9 @@ export async function updateRequestFilterAction(
     replacement: unknown;
     priority: number;
     isEnabled: boolean;
+    bindingType: RequestFilterBindingType;
+    providerIds: number[] | null;
+    groupTags: string[] | null;
   }>
 ): Promise<ActionResult<RequestFilter>> {
   const session = await getSession();
@@ -133,6 +174,39 @@ export async function updateRequestFilterAction(
     }
   }
 
+  // Validate binding type constraints when updating binding-related fields
+  if (
+    updates.bindingType !== undefined ||
+    updates.providerIds !== undefined ||
+    updates.groupTags !== undefined
+  ) {
+    // Need to merge updates with existing data
+    const existing = await getRequestFilterById(id);
+    if (!existing) {
+      return { ok: false, error: "记录不存在" };
+    }
+
+    const effectiveBindingType = updates.bindingType ?? existing.bindingType;
+    const effectiveProviderIds =
+      updates.providerIds !== undefined ? updates.providerIds : existing.providerIds;
+    const effectiveGroupTags =
+      updates.groupTags !== undefined ? updates.groupTags : existing.groupTags;
+
+    const validationError = validatePayload({
+      name: existing.name,
+      scope: existing.scope,
+      action: existing.action,
+      target: existing.target,
+      bindingType: effectiveBindingType,
+      providerIds: effectiveProviderIds,
+      groupTags: effectiveGroupTags,
+    });
+
+    if (validationError) {
+      return { ok: false, error: validationError };
+    }
+  }
+
   try {
     const updated = await updateRequestFilter(id, updates);
     if (!updated) {
@@ -176,3 +250,58 @@ export async function refreshRequestFiltersCache(): Promise<ActionResult<{ count
     return { ok: false, error: "刷新失败" };
   }
 }
+
+/**
+ * Get list of all providers for filter binding selection
+ */
+export async function listProvidersForFilterAction(): Promise<
+  ActionResult<Array<{ id: number; name: string }>>
+> {
+  const session = await getSession();
+  if (!isAdmin(session)) return { ok: false, error: "权限不足" };
+
+  try {
+    const { findAllProviders } = await import("@/repository/provider");
+    const providers = await findAllProviders();
+    const simplified = providers.map((p) => ({ id: p.id, name: p.name }));
+    return { ok: true, data: simplified };
+  } catch (error) {
+    logger.error("[RequestFiltersAction] Failed to list providers", { error });
+    return { ok: false, error: "获取 Provider 列表失败" };
+  }
+}
+
+/**
+ * Get distinct provider group tags for filter binding selection
+ */
+export async function getDistinctProviderGroupsAction(): Promise<ActionResult<string[]>> {
+  const session = await getSession();
+  if (!isAdmin(session)) return { ok: false, error: "权限不足" };
+
+  try {
+    const { db } = await import("@/drizzle/db");
+    const { providers } = await import("@/drizzle/schema");
+    const { isNotNull } = await import("drizzle-orm");
+
+    const result = await db
+      .selectDistinct({ groupTag: providers.groupTag })
+      .from(providers)
+      .where(isNotNull(providers.groupTag));
+
+    // Parse comma-separated tags and flatten into unique array
+    const allTags = new Set<string>();
+    for (const row of result) {
+      if (row.groupTag) {
+        const tags = row.groupTag.split(",").map((tag) => tag.trim());
+        for (const tag of tags) {
+          if (tag) allTags.add(tag);
+        }
+      }
+    }
+
+    return { ok: true, data: Array.from(allTags).sort() };
+  } catch (error) {
+    logger.error("[RequestFiltersAction] Failed to get distinct group tags", { error });
+    return { ok: false, error: "获取 Group Tags 失败" };
+  }
+}

+ 69 - 1
src/app/[locale]/settings/request-filters/_components/filter-dialog.tsx

@@ -27,7 +27,13 @@ import {
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
 import { Textarea } from "@/components/ui/textarea";
-import type { RequestFilter, RequestFilterMatchType } from "@/repository/request-filters";
+import type {
+  RequestFilter,
+  RequestFilterBindingType,
+  RequestFilterMatchType,
+} from "@/repository/request-filters";
+import { GroupMultiSelect } from "./group-multi-select";
+import { ProviderMultiSelect } from "./provider-multi-select";
 
 type Mode = "create" | "edit";
 
@@ -62,6 +68,11 @@ export function FilterDialog({ mode, trigger, filter, open, onOpenChange }: Prop
     filter?.matchType ?? "contains"
   );
   const [isEnabled, setIsEnabled] = useState<boolean>(filter?.isEnabled ?? true);
+  const [bindingType, setBindingType] = useState<RequestFilterBindingType>(
+    filter?.bindingType ?? "global"
+  );
+  const [providerIds, setProviderIds] = useState<number[]>(filter?.providerIds ?? []);
+  const [groupTags, setGroupTags] = useState<string[]>(filter?.groupTags ?? []);
 
   useEffect(() => {
     // Sync controlled open prop to internal state
@@ -87,6 +98,9 @@ export function FilterDialog({ mode, trigger, filter, open, onOpenChange }: Prop
       setPriority(filter.priority);
       setMatchType(filter.matchType ?? "contains");
       setIsEnabled(filter.isEnabled);
+      setBindingType(filter.bindingType ?? "global");
+      setProviderIds(filter.providerIds ?? []);
+      setGroupTags(filter.groupTags ?? []);
     }
   }, [filter]);
 
@@ -105,6 +119,19 @@ export function FilterDialog({ mode, trigger, filter, open, onOpenChange }: Prop
   const showMatchType = scope === "body" && action === "text_replace";
   const showReplacement = action === "set" || action === "json_path" || action === "text_replace";
 
+  const handleBindingTypeChange = (value: RequestFilterBindingType) => {
+    setBindingType(value);
+    // Clear selections when changing binding type
+    if (value === "global") {
+      setProviderIds([]);
+      setGroupTags([]);
+    } else if (value === "providers") {
+      setGroupTags([]);
+    } else if (value === "groups") {
+      setProviderIds([]);
+    }
+  };
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
 
@@ -139,6 +166,9 @@ export function FilterDialog({ mode, trigger, filter, open, onOpenChange }: Prop
         replacement: showReplacement ? parsedReplacement : null,
         priority,
         isEnabled,
+        bindingType,
+        providerIds: bindingType === "providers" ? providerIds : null,
+        groupTags: bindingType === "groups" ? groupTags : null,
       } as const;
 
       const result =
@@ -183,6 +213,44 @@ export function FilterDialog({ mode, trigger, filter, open, onOpenChange }: Prop
             />
           </div>
 
+          <div className="grid gap-4">
+            <div className="grid gap-2">
+              <Label htmlFor="binding-type">{t("dialog.bindingType")}</Label>
+              <Select value={bindingType} onValueChange={handleBindingTypeChange}>
+                <SelectTrigger id="binding-type">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="global">{t("dialog.bindingGlobal")}</SelectItem>
+                  <SelectItem value="providers">{t("dialog.bindingProviders")}</SelectItem>
+                  <SelectItem value="groups">{t("dialog.bindingGroups")}</SelectItem>
+                </SelectContent>
+              </Select>
+            </div>
+
+            {bindingType === "providers" && (
+              <div className="grid gap-2">
+                <Label>{t("dialog.selectProviders")}</Label>
+                <ProviderMultiSelect
+                  selectedProviderIds={providerIds}
+                  onChange={setProviderIds}
+                  disabled={isSubmitting}
+                />
+              </div>
+            )}
+
+            {bindingType === "groups" && (
+              <div className="grid gap-2">
+                <Label>{t("dialog.selectGroups")}</Label>
+                <GroupMultiSelect
+                  selectedGroupTags={groupTags}
+                  onChange={setGroupTags}
+                  disabled={isSubmitting}
+                />
+              </div>
+            )}
+          </div>
+
           <div className="grid gap-2">
             <Label htmlFor="filter-scope">{t("dialog.scope")}</Label>
             <Select value={scope} onValueChange={(val) => setScope(val as RequestFilter["scope"])}>

+ 28 - 8
src/app/[locale]/settings/request-filters/_components/filter-table.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { Pencil, RefreshCw, Trash2 } from "lucide-react";
+import { Globe, Package, Pencil, RefreshCw, Tags, Trash2 } from "lucide-react";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useState } from "react";
@@ -94,9 +94,10 @@ export function FilterTable({ filters }: Props) {
               <th className="px-4 py-3">{t("table.action")}</th>
               <th className="px-4 py-3">{t("table.target")}</th>
               <th className="px-4 py-3">{t("table.replacement")}</th>
-              <th className="px-4 py-3">{t("table.priority")}</th>
-              <th className="px-4 py-3">{t("table.status")}</th>
-              <th className="px-4 py-3 text-right">{t("table.actions")}</th>
+              <th className="px-2 py-3 w-20">{t("table.priority")}</th>
+              <th className="px-2 py-3 w-24">{t("table.apply")}</th>
+              <th className="px-2 py-3 w-20">{t("table.status")}</th>
+              <th className="px-2 py-3 text-right w-24">{t("table.actions")}</th>
             </tr>
           </thead>
           <tbody>
@@ -126,15 +127,34 @@ export function FilterTable({ filters }: Props) {
                       ? filter.replacement
                       : JSON.stringify(filter.replacement)}
                 </td>
-                <td className="px-4 py-3 text-sm">{filter.priority}</td>
-                <td className="px-4 py-3">
+                <td className="px-2 py-3 text-sm text-center">{filter.priority}</td>
+                <td className="px-2 py-3 text-center">
+                  {filter.bindingType === "global" && (
+                    <div className="flex items-center justify-center">
+                      <Globe className="h-4 w-4 text-muted-foreground" />
+                    </div>
+                  )}
+                  {filter.bindingType === "providers" && (
+                    <div className="flex items-center justify-center gap-1 text-sm">
+                      <Package className="h-4 w-4" />
+                      <span>{filter.providerIds?.length ?? 0}</span>
+                    </div>
+                  )}
+                  {filter.bindingType === "groups" && (
+                    <div className="flex items-center justify-center gap-1 text-sm">
+                      <Tags className="h-4 w-4" />
+                      <span>{filter.groupTags?.length ?? 0}</span>
+                    </div>
+                  )}
+                </td>
+                <td className="px-2 py-3 text-center">
                   <Switch
                     checked={filter.isEnabled}
                     onCheckedChange={(checked) => handleToggle(filter, checked)}
                   />
                 </td>
-                <td className="px-4 py-3 text-right">
-                  <div className="flex justify-end gap-2">
+                <td className="px-2 py-3 text-right">
+                  <div className="flex justify-end gap-1">
                     <Button variant="ghost" size="sm" onClick={() => setEditing(filter)}>
                       <Pencil className="h-4 w-4" />
                     </Button>

+ 154 - 0
src/app/[locale]/settings/request-filters/_components/group-multi-select.tsx

@@ -0,0 +1,154 @@
+"use client";
+
+import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { getDistinctProviderGroupsAction } from "@/actions/request-filters";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+
+interface GroupMultiSelectProps {
+  selectedGroupTags: string[];
+  onChange: (groupTags: string[]) => void;
+  disabled?: boolean;
+}
+
+export function GroupMultiSelect({
+  selectedGroupTags,
+  onChange,
+  disabled = false,
+}: GroupMultiSelectProps) {
+  const t = useTranslations("settings.requestFilters.dialog");
+  const [open, setOpen] = useState(false);
+  const [groupTags, setGroupTags] = useState<string[]>([]);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    async function loadGroups() {
+      setLoading(true);
+      const result = await getDistinctProviderGroupsAction();
+      if (result.ok) {
+        setGroupTags(result.data);
+      }
+      setLoading(false);
+    }
+    loadGroups();
+  }, []);
+
+  const toggleGroup = (groupTag: string) => {
+    if (selectedGroupTags.includes(groupTag)) {
+      onChange(selectedGroupTags.filter((tag) => tag !== groupTag));
+    } else {
+      onChange([...selectedGroupTags, groupTag]);
+    }
+  };
+
+  const selectAll = () => onChange(groupTags);
+  const clearAll = () => onChange([]);
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          disabled={disabled}
+          className="w-full justify-between"
+        >
+          {selectedGroupTags.length === 0 ? (
+            <span className="text-muted-foreground">{t("selectGroups")}</span>
+          ) : (
+            <div className="flex gap-2 items-center">
+              <span className="truncate">
+                {t("groupsSelected", { count: selectedGroupTags.length })}
+              </span>
+              <Badge variant="secondary" className="ml-auto">
+                {selectedGroupTags.length}
+              </Badge>
+            </div>
+          )}
+          {loading ? (
+            <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-50" />
+          ) : (
+            <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+          )}
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent
+        className="w-[400px] p-0"
+        align="start"
+        onWheel={(e) => e.stopPropagation()}
+        onTouchMove={(e) => e.stopPropagation()}
+      >
+        <Command shouldFilter={true}>
+          <CommandInput placeholder={t("searchGroups")} />
+          <CommandList className="max-h-[300px] overflow-y-auto">
+            <CommandEmpty>{loading ? t("loading") : t("noGroupsFound")}</CommandEmpty>
+
+            {!loading && (
+              <>
+                <CommandGroup>
+                  <div className="flex gap-2 p-2">
+                    <Button
+                      size="sm"
+                      variant="outline"
+                      onClick={selectAll}
+                      className="flex-1"
+                      type="button"
+                    >
+                      {t("selectAll")}
+                    </Button>
+                    <Button
+                      size="sm"
+                      variant="outline"
+                      onClick={clearAll}
+                      disabled={selectedGroupTags.length === 0}
+                      className="flex-1"
+                      type="button"
+                    >
+                      {t("clear")}
+                    </Button>
+                  </div>
+                </CommandGroup>
+
+                <CommandGroup>
+                  {groupTags.map((groupTag) => (
+                    <CommandItem
+                      key={groupTag}
+                      value={groupTag}
+                      onSelect={() => toggleGroup(groupTag)}
+                      className="cursor-pointer"
+                    >
+                      <Checkbox
+                        checked={selectedGroupTags.includes(groupTag)}
+                        className="mr-2"
+                        onCheckedChange={() => toggleGroup(groupTag)}
+                      />
+                      <div className="flex-1">
+                        <span className="font-mono">{groupTag}</span>
+                      </div>
+                      {selectedGroupTags.includes(groupTag) && (
+                        <Check className="h-4 w-4 text-primary" />
+                      )}
+                    </CommandItem>
+                  ))}
+                </CommandGroup>
+              </>
+            )}
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  );
+}

+ 157 - 0
src/app/[locale]/settings/request-filters/_components/provider-multi-select.tsx

@@ -0,0 +1,157 @@
+"use client";
+
+import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { listProvidersForFilterAction } from "@/actions/request-filters";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+
+interface ProviderMultiSelectProps {
+  selectedProviderIds: number[];
+  onChange: (providerIds: number[]) => void;
+  disabled?: boolean;
+}
+
+export function ProviderMultiSelect({
+  selectedProviderIds,
+  onChange,
+  disabled = false,
+}: ProviderMultiSelectProps) {
+  const t = useTranslations("settings.requestFilters.dialog");
+  const [open, setOpen] = useState(false);
+  const [providers, setProviders] = useState<Array<{ id: number; name: string }>>([]);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    async function loadProviders() {
+      setLoading(true);
+      const result = await listProvidersForFilterAction();
+      if (result.ok) {
+        setProviders(result.data);
+      }
+      setLoading(false);
+    }
+    loadProviders();
+  }, []);
+
+  const toggleProvider = (providerId: number) => {
+    if (selectedProviderIds.includes(providerId)) {
+      onChange(selectedProviderIds.filter((id) => id !== providerId));
+    } else {
+      onChange([...selectedProviderIds, providerId]);
+    }
+  };
+
+  const selectAll = () => onChange(providers.map((p) => p.id));
+  const clearAll = () => onChange([]);
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          disabled={disabled}
+          className="w-full justify-between"
+        >
+          {selectedProviderIds.length === 0 ? (
+            <span className="text-muted-foreground">{t("selectProviders")}</span>
+          ) : (
+            <div className="flex gap-2 items-center">
+              <span className="truncate">
+                {t("providersSelected", { count: selectedProviderIds.length })}
+              </span>
+              <Badge variant="secondary" className="ml-auto">
+                {selectedProviderIds.length}
+              </Badge>
+            </div>
+          )}
+          {loading ? (
+            <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-50" />
+          ) : (
+            <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+          )}
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent
+        className="w-[400px] p-0"
+        align="start"
+        onWheel={(e) => e.stopPropagation()}
+        onTouchMove={(e) => e.stopPropagation()}
+      >
+        <Command shouldFilter={true}>
+          <CommandInput placeholder={t("searchProviders")} />
+          <CommandList className="max-h-[300px] overflow-y-auto">
+            <CommandEmpty>{loading ? t("loading") : t("noProvidersFound")}</CommandEmpty>
+
+            {!loading && (
+              <>
+                <CommandGroup>
+                  <div className="flex gap-2 p-2">
+                    <Button
+                      size="sm"
+                      variant="outline"
+                      onClick={selectAll}
+                      className="flex-1"
+                      type="button"
+                    >
+                      {t("selectAll")}
+                    </Button>
+                    <Button
+                      size="sm"
+                      variant="outline"
+                      onClick={clearAll}
+                      disabled={selectedProviderIds.length === 0}
+                      className="flex-1"
+                      type="button"
+                    >
+                      {t("clear")}
+                    </Button>
+                  </div>
+                </CommandGroup>
+
+                <CommandGroup>
+                  {providers.map((provider) => (
+                    <CommandItem
+                      key={provider.id}
+                      value={`${provider.name} ${provider.id}`}
+                      onSelect={() => toggleProvider(provider.id)}
+                      className="cursor-pointer"
+                    >
+                      <Checkbox
+                        checked={selectedProviderIds.includes(provider.id)}
+                        className="mr-2"
+                        onCheckedChange={() => toggleProvider(provider.id)}
+                      />
+                      <div className="flex-1">
+                        <span className="font-medium">{provider.name}</span>
+                        <span className="ml-2 text-xs text-muted-foreground">
+                          (ID: {provider.id})
+                        </span>
+                      </div>
+                      {selectedProviderIds.includes(provider.id) && (
+                        <Check className="h-4 w-4 text-primary" />
+                      )}
+                    </CommandItem>
+                  ))}
+                </CommandGroup>
+              </>
+            )}
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  );
+}

+ 20 - 1
src/app/v1/_lib/proxy/guard-pipeline.ts

@@ -2,6 +2,7 @@ import { ProxyAuthenticator } from "./auth-guard";
 import { ProxyClientGuard } from "./client-guard";
 import { ProxyMessageService } from "./message-service";
 import { ProxyModelGuard } from "./model-guard";
+import { ProxyProviderRequestFilter } from "./provider-request-filter";
 import { ProxyProviderResolver } from "./provider-selector";
 import { ProxyRateLimitGuard } from "./rate-limit-guard";
 import { ProxyRequestFilter } from "./request-filter";
@@ -34,6 +35,7 @@ export type GuardStepKey =
   | "sensitive"
   | "rateLimit"
   | "provider"
+  | "providerRequestFilter"
   | "messageContext";
 
 export interface GuardConfig {
@@ -115,6 +117,13 @@ const Steps: Record<GuardStepKey, GuardStep> = {
       return ProxyProviderResolver.ensure(session);
     },
   },
+  providerRequestFilter: {
+    name: "providerRequestFilter",
+    async execute(session) {
+      await ProxyProviderRequestFilter.ensure(session);
+      return null;
+    },
+  },
   messageContext: {
     name: "messageContext",
     async execute(session) {
@@ -165,11 +174,21 @@ export const CHAT_PIPELINE: GuardConfig = {
     "sensitive",
     "rateLimit",
     "provider",
+    "providerRequestFilter",
     "messageContext",
   ],
 };
 
 export const COUNT_TOKENS_PIPELINE: GuardConfig = {
   // Minimal chain for count_tokens: no session, no sensitive, no rate limit, no message logging
-  steps: ["auth", "client", "model", "version", "probe", "requestFilter", "provider"],
+  steps: [
+    "auth",
+    "client",
+    "model",
+    "version",
+    "probe",
+    "requestFilter",
+    "provider",
+    "providerRequestFilter",
+  ],
 };

+ 29 - 0
src/app/v1/_lib/proxy/provider-request-filter.ts

@@ -0,0 +1,29 @@
+import { logger } from "@/lib/logger";
+import { requestFilterEngine } from "@/lib/request-filter-engine";
+import type { ProxySession } from "./session";
+
+/**
+ * Provider-specific Request Filter
+ * Применяет фильтры, привязанные к конкретному провайдеру или группе
+ * Выполняется ПОСЛЕ выбора провайдера
+ */
+export class ProxyProviderRequestFilter {
+  static async ensure(session: ProxySession): Promise<void> {
+    if (!session.provider) {
+      logger.warn(
+        "[ProxyProviderRequestFilter] No provider selected, skipping provider-specific filters"
+      );
+      return;
+    }
+
+    try {
+      await requestFilterEngine.applyForProvider(session);
+    } catch (error) {
+      // Fail-open: фильтр не блокирует основной поток
+      logger.error("[ProxyProviderRequestFilter] Failed to apply provider-specific filters", {
+        error,
+        providerId: session.provider.id,
+      });
+    }
+  }
+}

+ 2 - 2
src/app/v1/_lib/proxy/request-filter.ts

@@ -13,10 +13,10 @@ import type { ProxySession } from "./session";
 export class ProxyRequestFilter {
   static async ensure(session: ProxySession): Promise<void> {
     try {
-      await requestFilterEngine.apply(session);
+      await requestFilterEngine.applyGlobal(session);
     } catch (error) {
       // Fail-open: 过滤失败不阻塞主流程
-      logger.error("[ProxyRequestFilter] Failed to apply request filters", { error });
+      logger.error("[ProxyRequestFilter] Failed to apply global request filters", { error });
     }
   }
 }

+ 7 - 0
src/drizzle/schema.ts

@@ -392,12 +392,19 @@ export const requestFilters = pgTable('request_filters', {
   replacement: jsonb('replacement'),
   priority: integer('priority').notNull().default(0),
   isEnabled: boolean('is_enabled').notNull().default(true),
+  bindingType: varchar('binding_type', { length: 20 })
+    .notNull()
+    .default('global')
+    .$type<'global' | 'providers' | 'groups'>(),
+  providerIds: jsonb('provider_ids').$type<number[] | null>(),
+  groupTags: jsonb('group_tags').$type<string[] | null>(),
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
 }, (table) => ({
   requestFiltersEnabledIdx: index('idx_request_filters_enabled').on(table.isEnabled, table.priority),
   requestFiltersScopeIdx: index('idx_request_filters_scope').on(table.scope),
   requestFiltersActionIdx: index('idx_request_filters_action').on(table.action),
+  requestFiltersBindingIdx: index('idx_request_filters_binding').on(table.isEnabled, table.bindingType),
 }));
 
 // Sensitive Words table

+ 77 - 11
src/lib/request-filter-engine.ts

@@ -82,7 +82,8 @@ function replaceText(
 }
 
 export class RequestFilterEngine {
-  private filters: RequestFilter[] = [];
+  private globalFilters: RequestFilter[] = [];
+  private providerFilters: RequestFilter[] = [];
   private lastReloadTime = 0;
   private isLoading = false;
   private isInitialized = false;
@@ -113,12 +114,22 @@ export class RequestFilterEngine {
     try {
       const { getActiveRequestFilters } = await import("@/repository/request-filters");
       const filters = await getActiveRequestFilters();
-      // 按优先级升序、id 升序排序,确保执行顺序稳定
-      filters.sort((a, b) => a.priority - b.priority || a.id - b.id);
-      this.filters = filters;
+
+      // Разделяем фильтры по типу привязки
+      this.globalFilters = filters
+        .filter((f) => f.bindingType === "global" || !f.bindingType)
+        .sort((a, b) => a.priority - b.priority || a.id - b.id);
+
+      this.providerFilters = filters
+        .filter((f) => f.bindingType === "providers" || f.bindingType === "groups")
+        .sort((a, b) => a.priority - b.priority || a.id - b.id);
+
       this.lastReloadTime = Date.now();
       this.isInitialized = true;
-      logger.info("[RequestFilterEngine] Filters loaded", { count: filters.length });
+      logger.info("[RequestFilterEngine] Filters loaded", {
+        globalCount: this.globalFilters.length,
+        providerCount: this.providerFilters.length,
+      });
     } catch (error) {
       logger.error("[RequestFilterEngine] Failed to reload filters", { error });
     } finally {
@@ -136,11 +147,14 @@ export class RequestFilterEngine {
     await this.initializationPromise;
   }
 
-  async apply(session: ProxySession): Promise<void> {
+  /**
+   * Применить глобальные фильтры (вызывается ДО выбора провайдера)
+   */
+  async applyGlobal(session: ProxySession): Promise<void> {
     await this.ensureInitialized();
-    if (this.filters.length === 0) return;
+    if (this.globalFilters.length === 0) return;
 
-    for (const filter of this.filters) {
+    for (const filter of this.globalFilters) {
       try {
         if (filter.scope === "header") {
           this.applyHeaderFilter(session, filter);
@@ -148,7 +162,7 @@ export class RequestFilterEngine {
           this.applyBodyFilter(session, filter);
         }
       } catch (error) {
-        logger.error("[RequestFilterEngine] Failed to apply filter", {
+        logger.error("[RequestFilterEngine] Failed to apply global filter", {
           filterId: filter.id,
           scope: filter.scope,
           action: filter.action,
@@ -158,6 +172,55 @@ export class RequestFilterEngine {
     }
   }
 
+  /**
+   * Применить фильтры для конкретного провайдера (вызывается ПОСЛЕ выбора провайдера)
+   */
+  async applyForProvider(session: ProxySession): Promise<void> {
+    await this.ensureInitialized();
+    if (this.providerFilters.length === 0 || !session.provider) return;
+
+    const providerId = session.provider.id;
+    const providerGroupTag = session.provider.groupTag;
+    const providerTags = providerGroupTag?.split(",").map((t) => t.trim()) ?? [];
+
+    for (const filter of this.providerFilters) {
+      // Проверяем соответствие привязки
+      let matches = false;
+
+      if (filter.bindingType === "providers") {
+        matches = filter.providerIds?.includes(providerId) ?? false;
+      } else if (filter.bindingType === "groups") {
+        matches = filter.groupTags?.some((tag) => providerTags.includes(tag)) ?? false;
+      }
+
+      if (!matches) continue;
+
+      try {
+        if (filter.scope === "header") {
+          this.applyHeaderFilter(session, filter);
+        } else if (filter.scope === "body") {
+          this.applyBodyFilter(session, filter);
+        }
+      } catch (error) {
+        logger.error("[RequestFilterEngine] Failed to apply provider filter", {
+          filterId: filter.id,
+          providerId,
+          scope: filter.scope,
+          action: filter.action,
+          error,
+        });
+      }
+    }
+  }
+
+  /**
+   * @deprecated Используйте applyGlobal() вместо этого метода.
+   * Оставлено для обратной совместимости.
+   */
+  async apply(session: ProxySession): Promise<void> {
+    await this.applyGlobal(session);
+  }
+
   private applyHeaderFilter(session: ProxySession, filter: RequestFilter) {
     const key = filter.target;
     switch (filter.action) {
@@ -229,14 +292,17 @@ export class RequestFilterEngine {
 
   // 测试辅助:直接注入过滤器
   setFiltersForTest(filters: RequestFilter[]): void {
-    this.filters = [...filters];
+    this.globalFilters = filters.filter((f) => f.bindingType === "global" || !f.bindingType);
+    this.providerFilters = filters.filter(
+      (f) => f.bindingType === "providers" || f.bindingType === "groups"
+    );
     this.isInitialized = true;
     this.lastReloadTime = Date.now();
   }
 
   getStats() {
     return {
-      count: this.filters.length,
+      count: this.globalFilters.length + this.providerFilters.length,
       lastReloadTime: this.lastReloadTime,
       isLoading: this.isLoading,
       isInitialized: this.isInitialized,

+ 16 - 0
src/repository/request-filters.ts

@@ -8,6 +8,7 @@ import { emitRequestFiltersUpdated } from "@/lib/emit-event";
 export type RequestFilterScope = "header" | "body";
 export type RequestFilterAction = "remove" | "set" | "json_path" | "text_replace";
 export type RequestFilterMatchType = "regex" | "contains" | "exact" | null;
+export type RequestFilterBindingType = "global" | "providers" | "groups";
 
 export interface RequestFilter {
   id: number;
@@ -20,6 +21,9 @@ export interface RequestFilter {
   replacement: unknown;
   priority: number;
   isEnabled: boolean;
+  bindingType: RequestFilterBindingType;
+  providerIds: number[] | null;
+  groupTags: string[] | null;
   createdAt: Date;
   updatedAt: Date;
 }
@@ -38,6 +42,9 @@ function mapRow(row: Row): RequestFilter {
     replacement: row.replacement ?? null,
     priority: row.priority,
     isEnabled: row.isEnabled,
+    bindingType: (row.bindingType as RequestFilterBindingType) ?? "global",
+    providerIds: (row.providerIds as number[] | null) ?? null,
+    groupTags: (row.groupTags as string[] | null) ?? null,
     createdAt: row.createdAt ?? new Date(),
     updatedAt: row.updatedAt ?? new Date(),
   };
@@ -87,6 +94,9 @@ interface CreateRequestFilterInput {
   replacement?: unknown;
   priority?: number;
   isEnabled?: boolean;
+  bindingType?: RequestFilterBindingType;
+  providerIds?: number[] | null;
+  groupTags?: string[] | null;
 }
 
 export async function createRequestFilter(data: CreateRequestFilterInput): Promise<RequestFilter> {
@@ -102,6 +112,9 @@ export async function createRequestFilter(data: CreateRequestFilterInput): Promi
       replacement: data.replacement ?? null,
       priority: data.priority ?? 0,
       isEnabled: data.isEnabled ?? true,
+      bindingType: data.bindingType ?? "global",
+      providerIds: data.providerIds ?? null,
+      groupTags: data.groupTags ?? null,
     })
     .returning();
 
@@ -119,6 +132,9 @@ interface UpdateRequestFilterInput {
   replacement?: unknown;
   priority?: number;
   isEnabled?: boolean;
+  bindingType?: RequestFilterBindingType;
+  providerIds?: number[] | null;
+  groupTags?: string[] | null;
 }
 
 export async function updateRequestFilter(