ソースを参照

feat: add per-group provider priority and inline editing (#701)

* feat: rebase PR #701 onto latest dev and resolve migration conflict

* fix(lint): sort provider rich item imports

* fix(lint): restore file and sort imports

* fix: address bot review items in provider priority PR
NieiR 4 日 前
コミット
a7bbef3c31
49 ファイル変更4927 行追加257 行削除
  1. 1 1
      .github/workflows/test.yml
  2. 1 0
      .gitignore
  3. 8 8
      Dockerfile
  4. 7 1
      docker-compose.yaml
  5. 1 1
      drizzle/0062_aromatic_taskmaster.sql
  6. 1 0
      drizzle/0064_harsh_dragon_lord.sql
  7. 2975 0
      drizzle/meta/0064_snapshot.json
  8. 8 1
      drizzle/meta/_journal.json
  9. 4 1
      messages/en/settings/providers/filter.json
  10. 6 0
      messages/en/settings/providers/form/sections.json
  11. 15 0
      messages/en/settings/providers/inlineEdit.json
  12. 7 1
      messages/en/settings/providers/list.json
  13. 4 1
      messages/ja/settings/providers/filter.json
  14. 6 0
      messages/ja/settings/providers/form/sections.json
  15. 15 0
      messages/ja/settings/providers/inlineEdit.json
  16. 7 1
      messages/ja/settings/providers/list.json
  17. 4 1
      messages/ru/settings/providers/filter.json
  18. 6 0
      messages/ru/settings/providers/form/sections.json
  19. 15 0
      messages/ru/settings/providers/inlineEdit.json
  20. 7 1
      messages/ru/settings/providers/list.json
  21. 4 1
      messages/zh-CN/settings/providers/filter.json
  22. 6 0
      messages/zh-CN/settings/providers/form/sections.json
  23. 21 6
      messages/zh-CN/settings/providers/inlineEdit.json
  24. 7 1
      messages/zh-CN/settings/providers/list.json
  25. 4 1
      messages/zh-TW/settings/providers/filter.json
  26. 6 0
      messages/zh-TW/settings/providers/form/sections.json
  27. 15 0
      messages/zh-TW/settings/providers/inlineEdit.json
  28. 7 1
      messages/zh-TW/settings/providers/list.json
  29. 1 0
      package.json
  30. 2 0
      src/actions/providers.ts
  31. 4 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx
  32. 3 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx
  33. 2 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts
  34. 40 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
  35. 315 0
      src/app/[locale]/settings/providers/_components/group-edit-combobox.tsx
  36. 129 66
      src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx
  37. 311 0
      src/app/[locale]/settings/providers/_components/priority-edit-popover.tsx
  38. 13 1
      src/app/[locale]/settings/providers/_components/provider-list.tsx
  39. 215 71
      src/app/[locale]/settings/providers/_components/provider-manager.tsx
  40. 298 73
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  41. 55 14
      src/app/v1/_lib/proxy/provider-selector.ts
  42. 124 0
      src/components/ui/drawer.tsx
  43. 1 0
      src/drizzle/schema.ts
  44. 20 0
      src/lib/hooks/use-media-query.ts
  45. 13 4
      src/lib/validation/schemas.ts
  46. 1 0
      src/repository/_shared/transformers.ts
  47. 7 0
      src/repository/provider.ts
  48. 4 0
      src/types/provider.ts
  49. 201 0
      tests/unit/proxy/provider-selector-group-priority.test.ts

+ 1 - 1
.github/workflows/test.yml

@@ -210,7 +210,7 @@ jobs:
           fi
 
       - name: Create summary
-        if: github.event_name == 'pull_request'
+        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
         uses: actions/github-script@v7
         with:
           script: |

+ 1 - 0
.gitignore

@@ -92,3 +92,4 @@ tmp/
 .trae/
 .sisyphus
 .ace-tool/
+.worktrees/

+ 8 - 8
Dockerfile

@@ -10,19 +10,19 @@ COPY --from=deps /app/node_modules ./node_modules
 COPY . .
 ENV NEXT_TELEMETRY_DISABLED=1
 ENV CI=true
-RUN bun run build
+RUN --mount=type=cache,target=/app/.next/cache bun run build
 
 FROM node:20-slim AS runner
 WORKDIR /app
 ENV NODE_ENV=production
-ENV PORT=8080
-EXPOSE 8080
+ENV PORT=3000
+EXPOSE 3000
 
 # 关键:确保复制了所有必要的文件,特别是 drizzle 文件夹
 COPY --from=builder /app/public ./public
-COPY --from=builder /app/.next ./.next
-COPY --from=builder /app/node_modules ./node_modules
-COPY --from=builder /app/package.json ./package.json
-COPY --from=builder /app/drizzle ./drizzle 
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+COPY --from=builder /app/drizzle ./drizzle
+COPY --from=builder /app/VERSION ./VERSION
 
-CMD ["node", "node_modules/.bin/next", "start"]
+CMD ["node", "server.js"]

+ 7 - 1
docker-compose.yaml

@@ -68,7 +68,13 @@ services:
       - "${APP_PORT:-23000}:3000"
     restart: unless-stopped
     healthcheck:
-      test: ["CMD-SHELL", "curl -f http://localhost:3000/api/actions/health || exit 1"]
+      test:
+        [
+          "CMD",
+          "node",
+          "-e",
+          "fetch('http://' + (process.env.HOSTNAME || '127.0.0.1') + ':3000/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
+        ]
       interval: 30s
       timeout: 5s
       retries: 3

+ 1 - 1
drizzle/0062_aromatic_taskmaster.sql

@@ -1 +1 @@
-ALTER TABLE "providers" ADD COLUMN "gemini_google_search_preference" varchar(20);
+ALTER TABLE "providers" ADD COLUMN IF NOT EXISTS "gemini_google_search_preference" varchar(20);

+ 1 - 0
drizzle/0064_harsh_dragon_lord.sql

@@ -0,0 +1 @@
+ALTER TABLE "providers" ADD COLUMN IF NOT EXISTS "group_priorities" jsonb DEFAULT 'null'::jsonb;

+ 2975 - 0
drizzle/meta/0064_snapshot.json

@@ -0,0 +1,2975 @@
+{
+  "id": "9fd69a68-7794-42af-ac5f-83f874adeecf",
+  "prevId": "40d9ed20-d9e3-42a4-9357-3e17e4b06ba1",
+  "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 with time zone",
+          "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(200)",
+          "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": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "bigint",
+          "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": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_5m_input_tokens": {
+          "name": "cache_creation_5m_input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_1h_input_tokens": {
+          "name": "cache_creation_1h_input_tokens",
+          "type": "bigint",
+          "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
+        },
+        "special_settings": {
+          "name": "special_settings",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": 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_id_prefix": {
+          "name": "idx_message_request_session_id_prefix",
+          "columns": [
+            {
+              "expression": "\"session_id\" varchar_pattern_ops",
+              "asc": true,
+              "isExpression": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')",
+          "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_blocked_by": {
+          "name": "idx_message_request_blocked_by",
+          "columns": [
+            {
+              "expression": "blocked_by",
+              "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
+        },
+        "source": {
+          "name": "source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'litellm'"
+        },
+        "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": {}
+        },
+        "idx_model_prices_source": {
+          "name": "idx_model_prices_source",
+          "columns": [
+            {
+              "expression": "source",
+              "isExpression": false,
+              "asc": true,
+              "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
+        },
+        "use_legacy_mode": {
+          "name": "use_legacy_mode",
+          "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.notification_target_bindings": {
+      "name": "notification_target_bindings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "notification_type": {
+          "name": "notification_type",
+          "type": "notification_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "target_id": {
+          "name": "target_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "schedule_cron": {
+          "name": "schedule_cron",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "schedule_timezone": {
+          "name": "schedule_timezone",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "template_override": {
+          "name": "template_override",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "unique_notification_target_binding": {
+          "name": "unique_notification_target_binding",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_type": {
+          "name": "idx_notification_bindings_type",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_target": {
+          "name": "idx_notification_bindings_target",
+          "columns": [
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "notification_target_bindings_target_id_webhook_targets_id_fk": {
+          "name": "notification_target_bindings_target_id_webhook_targets_id_fk",
+          "tableFrom": "notification_target_bindings",
+          "tableTo": "webhook_targets",
+          "columnsFrom": [
+            "target_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.provider_endpoint_probe_logs": {
+      "name": "provider_endpoint_probe_logs",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "endpoint_id": {
+          "name": "endpoint_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "source": {
+          "name": "source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'scheduled'"
+        },
+        "ok": {
+          "name": "ok",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "latency_ms": {
+          "name": "latency_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_type": {
+          "name": "error_type",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_provider_endpoint_probe_logs_endpoint_created_at": {
+          "name": "idx_provider_endpoint_probe_logs_endpoint_created_at",
+          "columns": [
+            {
+              "expression": "endpoint_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoint_probe_logs_created_at": {
+          "name": "idx_provider_endpoint_probe_logs_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": {
+          "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk",
+          "tableFrom": "provider_endpoint_probe_logs",
+          "tableTo": "provider_endpoints",
+          "columnsFrom": [
+            "endpoint_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.provider_endpoints": {
+      "name": "provider_endpoints",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "vendor_id": {
+          "name": "vendor_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "label": {
+          "name": "label",
+          "type": "varchar(200)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "sort_order": {
+          "name": "sort_order",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "last_probed_at": {
+          "name": "last_probed_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_ok": {
+          "name": "last_probe_ok",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_status_code": {
+          "name": "last_probe_status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_latency_ms": {
+          "name": "last_probe_latency_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_error_type": {
+          "name": "last_probe_error_type",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_error_message": {
+          "name": "last_probe_error_message",
+          "type": "text",
+          "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": {
+        "uniq_provider_endpoints_vendor_type_url": {
+          "name": "uniq_provider_endpoints_vendor_type_url",
+          "columns": [
+            {
+              "expression": "vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "url",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_vendor_type": {
+          "name": "idx_provider_endpoints_vendor_type",
+          "columns": [
+            {
+              "expression": "vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"provider_endpoints\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_enabled": {
+          "name": "idx_provider_endpoints_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"provider_endpoints\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_created_at": {
+          "name": "idx_provider_endpoints_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_deleted_at": {
+          "name": "idx_provider_endpoints_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "provider_endpoints_vendor_id_provider_vendors_id_fk": {
+          "name": "provider_endpoints_vendor_id_provider_vendors_id_fk",
+          "tableFrom": "provider_endpoints",
+          "tableTo": "provider_vendors",
+          "columnsFrom": [
+            "vendor_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.provider_vendors": {
+      "name": "provider_vendors",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "website_domain": {
+          "name": "website_domain",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "display_name": {
+          "name": "display_name",
+          "type": "varchar(200)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "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": {
+        "uniq_provider_vendors_website_domain": {
+          "name": "uniq_provider_vendors_website_domain",
+          "columns": [
+            {
+              "expression": "website_domain",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_vendors_created_at": {
+          "name": "idx_provider_vendors_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.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
+        },
+        "provider_vendor_id": {
+          "name": "provider_vendor_id",
+          "type": "integer",
+          "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
+        },
+        "group_priorities": {
+          "name": "group_priorities",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "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_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "total_cost_reset_at": {
+          "name": "total_cost_reset_at",
+          "type": "timestamp with time zone",
+          "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
+        },
+        "codex_reasoning_effort_preference": {
+          "name": "codex_reasoning_effort_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "codex_reasoning_summary_preference": {
+          "name": "codex_reasoning_summary_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "codex_text_verbosity_preference": {
+          "name": "codex_text_verbosity_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "codex_parallel_tool_calls_preference": {
+          "name": "codex_parallel_tool_calls_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "anthropic_max_tokens_preference": {
+          "name": "anthropic_max_tokens_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "anthropic_thinking_budget_preference": {
+          "name": "anthropic_thinking_budget_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "gemini_google_search_preference": {
+          "name": "gemini_google_search_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": {}
+        },
+        "idx_providers_vendor_type": {
+          "name": "idx_providers_vendor_type",
+          "columns": [
+            {
+              "expression": "provider_vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "providers_provider_vendor_id_provider_vendors_id_fk": {
+          "name": "providers_provider_vendor_id_provider_vendors_id_fk",
+          "tableFrom": "providers",
+          "tableTo": "provider_vendors",
+          "columnsFrom": [
+            "provider_vendor_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "restrict",
+          "onUpdate": "no action"
+        }
+      },
+      "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'"
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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
+        },
+        "intercept_anthropic_warmup_requests": {
+          "name": "intercept_anthropic_warmup_requests",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_thinking_signature_rectifier": {
+          "name": "enable_thinking_signature_rectifier",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "enable_thinking_budget_rectifier": {
+          "name": "enable_thinking_budget_rectifier",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "enable_codex_session_id_completion": {
+          "name": "enable_codex_session_id_completion",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "enable_claude_metadata_user_id_injection": {
+          "name": "enable_claude_metadata_user_id_injection",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "enable_response_fixer": {
+          "name": "enable_response_fixer",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "response_fixer_config": {
+          "name": "response_fixer_config",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb"
+        },
+        "quota_db_refresh_interval_seconds": {
+          "name": "quota_db_refresh_interval_seconds",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10
+        },
+        "quota_lease_percent_5h": {
+          "name": "quota_lease_percent_5h",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_percent_daily": {
+          "name": "quota_lease_percent_daily",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_percent_weekly": {
+          "name": "quota_lease_percent_weekly",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_percent_monthly": {
+          "name": "quota_lease_percent_monthly",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_cap_usd": {
+          "name": "quota_lease_cap_usd",
+          "type": "numeric(10, 2)",
+          "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": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(200)",
+          "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
+    },
+    "public.webhook_targets": {
+      "name": "webhook_targets",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "webhook_provider_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "webhook_url": {
+          "name": "webhook_url",
+          "type": "varchar(1024)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_bot_token": {
+          "name": "telegram_bot_token",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_chat_id": {
+          "name": "telegram_chat_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "dingtalk_secret": {
+          "name": "dingtalk_secret",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_template": {
+          "name": "custom_template",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_headers": {
+          "name": "custom_headers",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "last_test_at": {
+          "name": "last_test_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_test_result": {
+          "name": "last_test_result",
+          "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": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    },
+    "public.notification_type": {
+      "name": "notification_type",
+      "schema": "public",
+      "values": [
+        "circuit_breaker",
+        "daily_leaderboard",
+        "cost_alert"
+      ]
+    },
+    "public.webhook_provider_type": {
+      "name": "webhook_provider_type",
+      "schema": "public",
+      "values": [
+        "wechat",
+        "feishu",
+        "dingtalk",
+        "telegram",
+        "custom"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 8 - 1
drizzle/meta/_journal.json

@@ -449,6 +449,13 @@
       "when": 1770476679142,
       "tag": "0063_slippery_sharon_carter",
       "breakpoints": true
+    },
+    {
+      "idx": 64,
+      "version": "7",
+      "when": 1770598056381,
+      "tag": "0064_harsh_dragon_lord",
+      "breakpoints": true
     }
   ]
-}
+}

+ 4 - 1
messages/en/settings/providers/filter.json

@@ -9,5 +9,8 @@
     "active": "Active",
     "all": "Any status",
     "inactive": "Inactive"
-  }
+  },
+  "mobileFilter": "Filter",
+  "mobileFilterCount": "Filter ({count})",
+  "resetFilters": "Reset Filters"
 }

+ 6 - 0
messages/en/settings/providers/form/sections.json

@@ -296,6 +296,12 @@
         "label": "Provider Group",
         "placeholder": "e.g. premium, economy"
       },
+      "groupPriorities": {
+        "label": "Per-Group Priority",
+        "desc": "Override global priority for specific groups. Leave empty to use the global priority above.",
+        "placeholder": "Use global priority",
+        "noGroups": "Set a group tag first to configure per-group priorities"
+      },
       "priority": {
         "desc": "Lower value = higher priority (0 is highest). The system only chooses from the highest priority tier. Suggested: primary=0, standby=1, emergency=2",
         "label": "Priority",

+ 15 - 0
messages/en/settings/providers/inlineEdit.json

@@ -1,12 +1,27 @@
 {
+  "addGroup": "Add group",
   "cancel": "Cancel",
   "costMultiplierInvalid": "Please enter a non-negative number",
   "costMultiplierLabel": "Cost Multiplier",
+  "createGroup": "Create \"{name}\"",
+  "editGroups": "Edit Groups",
+  "globalPriority": "Global Priority",
+  "groupPriorityLabel": "Per-Group Priority",
+  "groupPriorityPlaceholder": "Use global",
+  "groupSaveError": "Failed to save group changes",
+  "groupValidation": {
+    "empty": "Group name cannot be empty",
+    "noComma": "Group name cannot contain comma",
+    "tooLong": "Group name cannot exceed 50 characters"
+  },
+  "noGroupsAvailable": "No groups available",
   "priorityInvalid": "Please enter an integer >= 0",
   "priorityLabel": "Priority",
   "save": "Save",
+  "saving": "Saving...",
   "saveFailed": "Save failed",
   "saveSuccess": "Saved successfully",
+  "searchGroups": "Search groups...",
   "weightInvalid": "Please enter an integer between 1 and 100",
   "weightLabel": "Weight"
 }

+ 7 - 1
messages/en/settings/providers/list.json

@@ -33,5 +33,11 @@
   "unknownError": "Unknown error",
   "viewFullKey": "View Complete API Key",
   "viewFullKeyDesc": "Please keep it safe and don't share it with others",
-  "weight": "Weight"
+  "weight": "Weight",
+  "actions": "Actions",
+  "actionClone": "Clone",
+  "actionResetCircuit": "Reset Circuit",
+  "actionResetUsage": "Reset Usage",
+  "actionDelete": "Delete",
+  "selectProvider": "Select {name}"
 }

+ 4 - 1
messages/ja/settings/providers/filter.json

@@ -9,5 +9,8 @@
     "active": "有効",
     "all": "すべてのステータス",
     "inactive": "無効"
-  }
+  },
+  "mobileFilter": "フィルター",
+  "mobileFilterCount": "フィルター ({count})",
+  "resetFilters": "フィルターをリセット"
 }

+ 6 - 0
messages/ja/settings/providers/form/sections.json

@@ -297,6 +297,12 @@
         "label": "プロバイダーグループ",
         "placeholder": "例: premium, economy"
       },
+      "groupPriorities": {
+        "label": "グループ別優先度",
+        "desc": "特定のグループに個別の優先度を設定します。空欄の場合は上記のグローバル優先度を使用します。",
+        "placeholder": "グローバル優先度を使用",
+        "noGroups": "グループ別優先度を設定するには、先にグループタグを設定してください"
+      },
       "priority": {
         "desc": "値が小さいほど優先度が高くなります(0 が最も高い)。システムは最も高い優先度のプロバイダーのみから選択します。推奨: メイン=0、予備=1、緊急=2",
         "label": "優先度",

+ 15 - 0
messages/ja/settings/providers/inlineEdit.json

@@ -1,12 +1,27 @@
 {
+  "addGroup": "グループを追加",
   "cancel": "キャンセル",
   "costMultiplierInvalid": "0以上の数値を入力してください",
   "costMultiplierLabel": "コスト倍率",
+  "createGroup": "\"{name}\" を作成",
+  "editGroups": "グループを編集",
+  "globalPriority": "グローバル優先度",
+  "groupPriorityLabel": "グループ別優先度",
+  "groupPriorityPlaceholder": "グローバル値を使用",
+  "groupSaveError": "グループの保存に失敗しました",
+  "groupValidation": {
+    "empty": "グループ名を空にすることはできません",
+    "noComma": "グループ名にカンマを含めることはできません",
+    "tooLong": "グループ名は50文字以内にしてください"
+  },
+  "noGroupsAvailable": "利用可能なグループがありません",
   "priorityInvalid": "0 以上の整数を入力してください",
   "priorityLabel": "優先度",
   "save": "保存する",
+  "saving": "保存中...",
   "saveFailed": "保存に失敗しました",
   "saveSuccess": "保存に成功しました",
+  "searchGroups": "グループを検索...",
   "weightInvalid": "1〜100 の整数を入力してください",
   "weightLabel": "重み"
 }

+ 7 - 1
messages/ja/settings/providers/list.json

@@ -33,5 +33,11 @@
   "unknownError": "不明なエラー",
   "viewFullKey": "完全な API キーを表示",
   "viewFullKeyDesc": "安全に保管し、他人と共有しないでください",
-  "weight": "重み"
+  "weight": "重み",
+  "actions": "アクション",
+  "actionClone": "クローン",
+  "actionResetCircuit": "サーキットリセット",
+  "actionResetUsage": "使用量リセット",
+  "actionDelete": "削除",
+  "selectProvider": "{name} を選択"
 }

+ 4 - 1
messages/ru/settings/providers/filter.json

@@ -9,5 +9,8 @@
     "active": "Активные",
     "all": "Все статусы",
     "inactive": "Неактивные"
-  }
+  },
+  "mobileFilter": "Фильтр",
+  "mobileFilterCount": "Фильтр ({count})",
+  "resetFilters": "Сбросить фильтры"
 }

+ 6 - 0
messages/ru/settings/providers/form/sections.json

@@ -297,6 +297,12 @@
         "label": "Группа провайдера",
         "placeholder": "напр. premium, economy"
       },
+      "groupPriorities": {
+        "label": "Приоритет по группам",
+        "desc": "Переопределение глобального приоритета для определённых групп. Оставьте пустым для использования глобального приоритета выше.",
+        "placeholder": "Использовать глобальный приоритет",
+        "noGroups": "Сначала задайте тег группы для настройки приоритетов по группам"
+      },
       "priority": {
         "desc": "Меньше — выше приоритет (0 — наивысший). Система выбирает только из провайдеров с максимальным приоритетом. Рекомендации: основной=0, резерв=1, аварийный=2",
         "label": "Приоритет",

+ 15 - 0
messages/ru/settings/providers/inlineEdit.json

@@ -1,12 +1,27 @@
 {
+  "addGroup": "Добавить группу",
   "cancel": "Отмена",
   "costMultiplierInvalid": "Введите число не меньше 0",
   "costMultiplierLabel": "Коэф цены",
+  "createGroup": "Создать \"{name}\"",
+  "editGroups": "Редактировать группы",
+  "globalPriority": "Глобальный приоритет",
+  "groupPriorityLabel": "Приоритет по группам",
+  "groupPriorityPlaceholder": "Глобальное значение",
+  "groupSaveError": "Не удалось сохранить изменения группы",
+  "groupValidation": {
+    "empty": "Название группы не может быть пустым",
+    "noComma": "Название группы не может содержать запятую",
+    "tooLong": "Название группы не может превышать 50 символов"
+  },
+  "noGroupsAvailable": "Нет доступных групп",
   "priorityInvalid": "Введите целое число >= 0",
   "priorityLabel": "Приоритет",
   "save": "Сохранить",
+  "saving": "Сохранение...",
   "saveFailed": "Не удалось сохранить",
   "saveSuccess": "Успешно сохранено",
+  "searchGroups": "Поиск групп...",
   "weightInvalid": "Введите целое число от 1 до 100",
   "weightLabel": "Вес"
 }

+ 7 - 1
messages/ru/settings/providers/list.json

@@ -33,5 +33,11 @@
   "unknownError": "Неизвестная ошибка",
   "viewFullKey": "Просмотр полного API-ключа",
   "viewFullKeyDesc": "Пожалуйста, храните его в безопасности и не делитесь с другими",
-  "weight": "Вес"
+  "weight": "Вес",
+  "actions": "Действия",
+  "actionClone": "Клонировать",
+  "actionResetCircuit": "Сбросить автоматический выключатель",
+  "actionResetUsage": "Сбросить использование",
+  "actionDelete": "Удалить",
+  "selectProvider": "Выбрать {name}"
 }

+ 4 - 1
messages/zh-CN/settings/providers/filter.json

@@ -9,5 +9,8 @@
     "all": "全部",
     "default": "default"
   },
-  "circuitBroken": "熔断"
+  "circuitBroken": "熔断",
+  "mobileFilter": "筛选",
+  "mobileFilterCount": "筛选 ({count})",
+  "resetFilters": "重置筛选"
 }

+ 6 - 0
messages/zh-CN/settings/providers/form/sections.json

@@ -70,6 +70,12 @@
         "label": "供应商分组",
         "placeholder": "例如 premium, economy",
         "desc": "分组标签。从列表选择或输入新名称后按 Enter 创建(最多50字符)。只有 providerGroup 匹配的用户才能使用此供应商。"
+      },
+      "groupPriorities": {
+        "label": "分组优先级覆盖",
+        "desc": "为特定分组设置独立的优先级。留空则使用上方的全局优先级。",
+        "placeholder": "使用全局优先级",
+        "noGroups": "请先设置分组标签,才能配置分组优先级"
       }
     },
     "cacheTtl": {

+ 21 - 6
messages/zh-CN/settings/providers/inlineEdit.json

@@ -1,12 +1,27 @@
 {
-  "save": "保存",
+  "addGroup": "添加分组",
   "cancel": "取消",
-  "saveSuccess": "保存成功",
-  "saveFailed": "保存失败",
-  "priorityLabel": "优先级",
-  "weightLabel": "权重",
+  "costMultiplierInvalid": "请输入大于等于 0 的数字",
   "costMultiplierLabel": "成本倍数",
+  "createGroup": "创建 \"{name}\"",
+  "editGroups": "编辑分组",
+  "globalPriority": "全局优先级",
+  "groupPriorityLabel": "分组优先级",
+  "groupPriorityPlaceholder": "使用全局值",
+  "groupSaveError": "保存分组失败",
+  "groupValidation": {
+    "empty": "分组名不能为空",
+    "noComma": "分组名不能包含逗号",
+    "tooLong": "分组名不能超过50字符"
+  },
+  "noGroupsAvailable": "无可用分组",
   "priorityInvalid": "请输入大于等于 0 的整数",
+  "priorityLabel": "优先级",
+  "save": "保存",
+  "saving": "保存中...",
+  "saveFailed": "保存失败",
+  "saveSuccess": "保存成功",
+  "searchGroups": "搜索分组...",
   "weightInvalid": "请输入 1-100 之间的整数",
-  "costMultiplierInvalid": "请输入大于等于 0 的数字"
+  "weightLabel": "权重"
 }

+ 7 - 1
messages/zh-CN/settings/providers/list.json

@@ -33,5 +33,11 @@
   "toggleSuccessDesc": "供应商 \"{name}\" 状态已更新",
   "toggleFailed": "状态切换失败",
   "statusEnabled": "启用",
-  "statusDisabled": "禁用"
+  "statusDisabled": "禁用",
+  "actions": "操作",
+  "actionClone": "克隆",
+  "actionResetCircuit": "重置熔断",
+  "actionResetUsage": "重置用量",
+  "actionDelete": "删除",
+  "selectProvider": "选择 {name}"
 }

+ 4 - 1
messages/zh-TW/settings/providers/filter.json

@@ -9,5 +9,8 @@
     "active": "已啟用",
     "all": "所有狀態",
     "inactive": "已停用"
-  }
+  },
+  "mobileFilter": "篩選",
+  "mobileFilterCount": "篩選 ({count})",
+  "resetFilters": "重置篩選"
 }

+ 6 - 0
messages/zh-TW/settings/providers/form/sections.json

@@ -297,6 +297,12 @@
         "label": "供應商分組",
         "placeholder": "例如 premium, economy"
       },
+      "groupPriorities": {
+        "label": "分組優先級覆蓋",
+        "desc": "為特定分組設定獨立的優先級。留空則使用上方的全域優先級。",
+        "placeholder": "使用全域優先級",
+        "noGroups": "請先設定分組標籤,才能設定分組優先級"
+      },
       "priority": {
         "desc": "數值越小,優先級越高(0 最高)。系統只會從最高優先級的供應商中選擇。建議:主力=0,備用=1,緊急備援=2",
         "label": "優先級",

+ 15 - 0
messages/zh-TW/settings/providers/inlineEdit.json

@@ -1,12 +1,27 @@
 {
+  "addGroup": "新增分組",
   "cancel": "放棄",
   "costMultiplierInvalid": "請輸入大於等於 0 的數字",
   "costMultiplierLabel": "成本倍數",
+  "createGroup": "建立 \"{name}\"",
+  "editGroups": "編輯分組",
+  "globalPriority": "全域優先級",
+  "groupPriorityLabel": "分組優先級",
+  "groupPriorityPlaceholder": "使用全域值",
+  "groupSaveError": "儲存分組失敗",
+  "groupValidation": {
+    "empty": "分組名稱不能為空",
+    "noComma": "分組名稱不能包含逗號",
+    "tooLong": "分組名稱不能超過50字元"
+  },
+  "noGroupsAvailable": "無可用分組",
   "priorityInvalid": "請輸入大於等於 0 的整數",
   "priorityLabel": "優先級",
   "save": "儲存",
+  "saving": "儲存中...",
   "saveFailed": "儲存失敗",
   "saveSuccess": "儲存成功",
+  "searchGroups": "搜尋分組...",
   "weightInvalid": "請輸入 1-100 之間的整數",
   "weightLabel": "權重"
 }

+ 7 - 1
messages/zh-TW/settings/providers/list.json

@@ -33,5 +33,11 @@
   "unknownError": "未知錯誤",
   "viewFullKey": "查看完整 API 金鑰",
   "viewFullKeyDesc": "請妥善保管,不要洩露給他人",
-  "weight": "權重"
+  "weight": "權重",
+  "actions": "操作",
+  "actionClone": "複製",
+  "actionResetCircuit": "重置熔斷",
+  "actionResetUsage": "重置用量",
+  "actionDelete": "刪除",
+  "selectProvider": "選擇 {name}"
 }

+ 1 - 0
package.json

@@ -102,6 +102,7 @@
     "timeago.js": "^4",
     "tw-animate-css": "^1",
     "undici": "^7",
+    "vaul": "^1.1.2",
     "zod": "^4"
   },
   "devDependencies": {

+ 2 - 0
src/actions/providers.ts

@@ -244,6 +244,7 @@ export async function getProviders(): Promise<ProviderDisplay[]> {
         isEnabled: provider.isEnabled,
         weight: provider.weight,
         priority: provider.priority,
+        groupPriorities: provider.groupPriorities,
         costMultiplier: provider.costMultiplier,
         groupTag: provider.groupTag,
         providerType: provider.providerType,
@@ -616,6 +617,7 @@ export async function editProvider(
     priority?: number;
     cost_multiplier?: number;
     group_tag?: string | null;
+    group_priorities?: Record<string, number> | null;
     provider_type?: ProviderType;
     preserve_client_ip?: boolean;
     model_redirects?: Record<string, string> | null;

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

@@ -308,6 +308,10 @@ function ProviderFormContent({
           allowed_models:
             state.routing.allowedModels.length > 0 ? state.routing.allowedModels : null,
           priority: state.routing.priority,
+          group_priorities:
+            Object.keys(state.routing.groupPriorities).length > 0
+              ? state.routing.groupPriorities
+              : null,
           weight: state.routing.weight,
           cost_multiplier: state.routing.costMultiplier,
           group_tag: state.routing.groupTag.length > 0 ? state.routing.groupTag.join(",") : null,

+ 3 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx

@@ -47,6 +47,7 @@ export function createInitialState(
       modelRedirects: sourceProvider?.modelRedirects ?? {},
       allowedModels: sourceProvider?.allowedModels ?? [],
       priority: sourceProvider?.priority ?? 0,
+      groupPriorities: sourceProvider?.groupPriorities ?? {},
       weight: sourceProvider?.weight ?? 1,
       costMultiplier: sourceProvider?.costMultiplier ?? 1.0,
       cacheTtlPreference: sourceProvider?.cacheTtlPreference ?? "inherit",
@@ -141,6 +142,8 @@ export function providerFormReducer(
       return { ...state, routing: { ...state.routing, allowedModels: action.payload } };
     case "SET_PRIORITY":
       return { ...state, routing: { ...state.routing, priority: action.payload } };
+    case "SET_GROUP_PRIORITIES":
+      return { ...state, routing: { ...state.routing, groupPriorities: action.payload } };
     case "SET_WEIGHT":
       return { ...state, routing: { ...state.routing, weight: action.payload } };
     case "SET_COST_MULTIPLIER":

+ 2 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts

@@ -40,6 +40,7 @@ export interface RoutingState {
   modelRedirects: Record<string, string>;
   allowedModels: string[];
   priority: number;
+  groupPriorities: Record<string, number>;
   weight: number;
   costMultiplier: number;
   cacheTtlPreference: "inherit" | "5m" | "1h";
@@ -118,6 +119,7 @@ export type ProviderFormAction =
   | { type: "SET_MODEL_REDIRECTS"; payload: Record<string, string> }
   | { type: "SET_ALLOWED_MODELS"; payload: string[] }
   | { type: "SET_PRIORITY"; payload: number }
+  | { type: "SET_GROUP_PRIORITIES"; payload: Record<string, number> }
   | { type: "SET_WEIGHT"; payload: number }
   | { type: "SET_COST_MULTIPLIER"; payload: number }
   | { type: "SET_CACHE_TTL_PREFERENCE"; payload: "inherit" | "5m" | "1h" }

+ 40 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx

@@ -281,6 +281,46 @@ export function RoutingSection() {
               />
             </SmartInputWrapper>
           </div>
+
+          {/* Per-Group Priority Override */}
+          {state.routing.groupTag.length > 0 && (
+            <div className="mt-4 space-y-3">
+              <div className="text-sm font-medium">
+                {t("sections.routing.scheduleParams.groupPriorities.label")}
+              </div>
+              <p className="text-xs text-muted-foreground">
+                {t("sections.routing.scheduleParams.groupPriorities.desc")}
+              </p>
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+                {state.routing.groupTag.map((group) => (
+                  <div key={group} className="flex items-center gap-2">
+                    <Badge variant="outline" className="font-mono text-xs shrink-0">
+                      {group}
+                    </Badge>
+                    <Input
+                      type="number"
+                      value={state.routing.groupPriorities[group] ?? ""}
+                      onChange={(e) => {
+                        const val = e.target.value;
+                        const next = { ...state.routing.groupPriorities };
+                        if (val === "") {
+                          delete next[group];
+                        } else {
+                          next[group] = parseInt(val, 10) || 0;
+                        }
+                        dispatch({ type: "SET_GROUP_PRIORITIES", payload: next });
+                      }}
+                      placeholder={t("sections.routing.scheduleParams.groupPriorities.placeholder")}
+                      disabled={state.ui.isPending}
+                      min="0"
+                      step="1"
+                      className="h-8 text-sm"
+                    />
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
         </SectionCard>
 
         {/* Advanced Settings */}

+ 315 - 0
src/app/[locale]/settings/providers/_components/group-edit-combobox.tsx

@@ -0,0 +1,315 @@
+"use client";
+
+import { Loader2, Plus } from "lucide-react";
+import { useTranslations } from "next-intl";
+import type * as React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from "@/components/ui/command";
+import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useMediaQuery } from "@/lib/hooks/use-media-query";
+import { cn } from "@/lib/utils";
+import { getContrastTextColor, getGroupColor } from "@/lib/utils/color";
+
+const MAX_GROUP_NAME_LENGTH = 50;
+
+export interface GroupEditComboboxProps {
+  currentGroups: string[];
+  allGroups: string[];
+  userGroups: string[];
+  isAdmin: boolean;
+  onSave: (groups: string[]) => Promise<boolean>;
+  disabled?: boolean;
+}
+
+export function GroupEditCombobox({
+  currentGroups,
+  allGroups,
+  userGroups,
+  isAdmin,
+  onSave,
+  disabled = false,
+}: GroupEditComboboxProps) {
+  const t = useTranslations("settings.providers.inlineEdit");
+  const isDesktop = useMediaQuery("(min-width: 768px)");
+  const [open, setOpen] = useState(false);
+  const [searchValue, setSearchValue] = useState("");
+  const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
+  const [saving, setSaving] = useState(false);
+
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  // Sync selectedGroups with currentGroups when opening
+  useEffect(() => {
+    if (open) {
+      setSelectedGroups([...currentGroups]);
+      setSearchValue("");
+    }
+  }, [open, currentGroups]);
+
+  // Auto-focus search input when opening
+  useEffect(() => {
+    if (!open) return;
+    const raf = requestAnimationFrame(() => {
+      inputRef.current?.focus();
+    });
+    return () => cancelAnimationFrame(raf);
+  }, [open]);
+
+  // Available groups: admin sees all groups, non-admin sees only their assigned groups
+  const availableGroups = useMemo(() => {
+    if (isAdmin) {
+      return allGroups.filter((g) => g !== "default");
+    }
+    return userGroups.filter((g) => g !== "default");
+  }, [isAdmin, allGroups, userGroups]);
+
+  // Validation for new group name
+  const validateGroupName = useCallback(
+    (name: string): string | null => {
+      const trimmed = name.trim();
+      if (trimmed.length === 0) {
+        return t("groupValidation.empty");
+      }
+      if (trimmed.includes(",")) {
+        return t("groupValidation.noComma");
+      }
+      if (trimmed.length > MAX_GROUP_NAME_LENGTH) {
+        return t("groupValidation.tooLong");
+      }
+      return null;
+    },
+    [t]
+  );
+
+  // Check if the search value matches an existing group (case-insensitive)
+  const searchMatchesExisting = useMemo(() => {
+    const trimmed = searchValue.trim().toLowerCase();
+    return availableGroups.some((g) => g.toLowerCase() === trimmed);
+  }, [searchValue, availableGroups]);
+
+  // Can create a new group?
+  const canCreateGroup = useMemo(() => {
+    const trimmed = searchValue.trim();
+    if (!isAdmin) return false;
+    if (trimmed.length === 0) return false;
+    if (searchMatchesExisting) return false;
+    return validateGroupName(trimmed) === null;
+  }, [isAdmin, searchValue, searchMatchesExisting, validateGroupName]);
+
+  const stopPropagation = (e: React.SyntheticEvent) => {
+    e.stopPropagation();
+  };
+
+  const handleOpenChange = (nextOpen: boolean) => {
+    if (disabled && nextOpen) return;
+    setOpen(nextOpen);
+  };
+
+  const toggleGroup = async (group: string) => {
+    const previousSelection = [...selectedGroups];
+    const newSelection = previousSelection.includes(group)
+      ? previousSelection.filter((g) => g !== group)
+      : [...previousSelection, group];
+
+    setSelectedGroups(newSelection);
+
+    // Optimistic update: save immediately
+    setSaving(true);
+    try {
+      const ok = await onSave(newSelection);
+      if (!ok) {
+        // Rollback on failure
+        setSelectedGroups(previousSelection);
+      }
+    } catch {
+      // Rollback on exception
+      setSelectedGroups(previousSelection);
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleCreateGroup = async () => {
+    const trimmed = searchValue.trim();
+    if (!canCreateGroup) return;
+
+    const previousSelection = [...selectedGroups];
+    const newSelection = [...previousSelection, trimmed];
+    setSelectedGroups(newSelection);
+    setSearchValue("");
+
+    // Save immediately
+    setSaving(true);
+    try {
+      const ok = await onSave(newSelection);
+      if (!ok) {
+        // Rollback on failure
+        setSelectedGroups(previousSelection);
+      }
+    } catch {
+      // Rollback on exception
+      setSelectedGroups(previousSelection);
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  // Trigger button: show badges if groups exist, otherwise show + button
+  const triggerButton = (
+    <button
+      type="button"
+      disabled={disabled}
+      className={cn(
+        "inline-flex flex-wrap items-center gap-1 rounded-sm",
+        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
+        disabled ? "cursor-default" : "cursor-pointer"
+      )}
+      onPointerDown={stopPropagation}
+      onClick={(e) => {
+        e.stopPropagation();
+        if (!isDesktop) handleOpenChange(true);
+      }}
+    >
+      {currentGroups.length > 0 ? (
+        currentGroups.map((tag, index) => {
+          const bgColor = getGroupColor(tag);
+          return (
+            <Badge
+              key={`${tag}-${index}`}
+              className="text-xs"
+              style={{ backgroundColor: bgColor, color: getContrastTextColor(bgColor) }}
+            >
+              {tag}
+            </Badge>
+          );
+        })
+      ) : (
+        <Badge variant="outline" className="text-xs gap-1">
+          <Plus className="h-3 w-3" />
+          {t("addGroup")}
+        </Badge>
+      )}
+    </button>
+  );
+
+  // Filter groups based on search
+  const filteredGroups = useMemo(() => {
+    const trimmed = searchValue.trim().toLowerCase();
+    if (!trimmed) return availableGroups;
+    return availableGroups.filter((g) => g.toLowerCase().includes(trimmed));
+  }, [availableGroups, searchValue]);
+
+  const commandContent = (
+    <Command shouldFilter={false}>
+      <CommandInput
+        ref={inputRef}
+        placeholder={t("searchGroups")}
+        value={searchValue}
+        onValueChange={setSearchValue}
+        onKeyDown={(e) => {
+          if (e.key === "Escape") {
+            e.preventDefault();
+            setOpen(false);
+          }
+          if (e.key === "Enter" && canCreateGroup) {
+            e.preventDefault();
+            void handleCreateGroup();
+          }
+        }}
+      />
+      <CommandList>
+        <CommandEmpty>{canCreateGroup ? null : t("noGroupsAvailable")}</CommandEmpty>
+
+        {/* Existing groups */}
+        {filteredGroups.length > 0 && (
+          <CommandGroup>
+            <div className="grid grid-cols-3 gap-1 p-1">
+              {filteredGroups.map((group) => {
+                const isSelected = selectedGroups.includes(group);
+                const bgColor = getGroupColor(group);
+                return (
+                  <CommandItem
+                    key={group}
+                    value={group}
+                    onSelect={() => toggleGroup(group)}
+                    className="cursor-pointer data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
+                    disabled={saving}
+                  >
+                    <Checkbox checked={isSelected} className="mr-1.5" disabled={saving} />
+                    <span className="text-xs font-medium truncate" style={{ color: bgColor }}>
+                      {group}
+                    </span>
+                  </CommandItem>
+                );
+              })}
+            </div>
+          </CommandGroup>
+        )}
+
+        {/* Create new group option (admin only) */}
+        {canCreateGroup && (
+          <CommandGroup>
+            <CommandItem
+              value={`create-${searchValue.trim()}`}
+              onSelect={handleCreateGroup}
+              className="cursor-pointer"
+              disabled={saving}
+            >
+              <Plus className="h-4 w-4 mr-2" />
+              <span>{t("createGroup", { name: searchValue.trim() })}</span>
+            </CommandItem>
+          </CommandGroup>
+        )}
+      </CommandList>
+
+      {saving && (
+        <div className="flex items-center justify-center py-2 text-xs text-muted-foreground">
+          <Loader2 className="h-3 w-3 animate-spin mr-1" />
+          {t("saving")}
+        </div>
+      )}
+    </Command>
+  );
+
+  if (!isDesktop) {
+    return (
+      <>
+        {triggerButton}
+        <Drawer open={open} onOpenChange={handleOpenChange}>
+          <DrawerContent>
+            <DrawerHeader>
+              <DrawerTitle>{t("editGroups")}</DrawerTitle>
+            </DrawerHeader>
+            <div className="px-4 pb-6">{commandContent}</div>
+          </DrawerContent>
+        </Drawer>
+      </>
+    );
+  }
+
+  return (
+    <Popover open={open} onOpenChange={handleOpenChange}>
+      <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
+      <PopoverContent
+        align="start"
+        side="bottom"
+        sideOffset={6}
+        className="w-80 p-0"
+        onPointerDown={stopPropagation}
+        onClick={stopPropagation}
+      >
+        {commandContent}
+      </PopoverContent>
+    </Popover>
+  );
+}

+ 129 - 66
src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx

@@ -5,8 +5,10 @@ import { useTranslations } from "next-intl";
 import type * as React from "react";
 import { useEffect, useMemo, useRef, useState } from "react";
 import { Button } from "@/components/ui/button";
+import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
 import { Input } from "@/components/ui/input";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useMediaQuery } from "@/lib/hooks/use-media-query";
 import { cn } from "@/lib/utils";
 
 export interface InlineEditPopoverProps {
@@ -29,6 +31,7 @@ export function InlineEditPopover({
   type = "number",
 }: InlineEditPopoverProps) {
   const t = useTranslations("settings.providers.inlineEdit");
+  const isDesktop = useMediaQuery("(min-width: 768px)");
   const [open, setOpen] = useState(false);
   const [draft, setDraft] = useState(() => value.toString());
   const [saving, setSaving] = useState(false);
@@ -102,24 +105,133 @@ export function InlineEditPopover({
     }
   };
 
-  return (
-    <Popover open={open} onOpenChange={handleOpenChange}>
-      <PopoverTrigger asChild>
-        <button
-          type="button"
-          disabled={disabled}
-          className={cn(
-            "tabular-nums font-medium underline-offset-4 rounded-sm",
-            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
-            disabled ? "cursor-default text-muted-foreground" : "cursor-pointer hover:underline"
-          )}
+  const triggerButton = (
+    <button
+      type="button"
+      disabled={disabled}
+      className={cn(
+        "tabular-nums font-medium underline-offset-4 rounded-sm",
+        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
+        disabled ? "cursor-default text-muted-foreground" : "cursor-pointer hover:underline"
+      )}
+      onPointerDown={stopPropagation}
+      onClick={(e) => {
+        e.stopPropagation();
+        if (!isDesktop) handleOpenChange(true);
+      }}
+    >
+      {value}
+      {suffix}
+    </button>
+  );
+
+  const formContent = (
+    <div className="grid gap-2">
+      <div className="text-xs font-medium md:block hidden">{label}</div>
+      <div className="flex items-center gap-2">
+        <Input
+          ref={inputRef}
+          value={draft}
+          onChange={(e) => setDraft(e.target.value)}
+          disabled={disabled || saving}
+          className="w-full md:w-24 tabular-nums"
+          aria-label={label}
+          aria-invalid={validationError != null}
+          type="number"
+          inputMode="decimal"
+          step={type === "integer" ? "1" : "any"}
           onPointerDown={stopPropagation}
           onClick={stopPropagation}
-        >
-          {value}
-          {suffix}
-        </button>
-      </PopoverTrigger>
+          onKeyDown={(e) => {
+            e.stopPropagation();
+            if (e.key === "Escape") {
+              e.preventDefault();
+              handleCancel();
+            }
+            if (e.key === "Enter") {
+              e.preventDefault();
+              void handleSave();
+            }
+          }}
+        />
+        {suffix && <span className="text-sm text-muted-foreground">{suffix}</span>}
+      </div>
+      {validationError && <div className="text-xs text-destructive">{validationError}</div>}
+      <div className="flex items-center justify-end gap-2 pt-1">
+        <Button type="button" size="sm" variant="outline" onClick={handleCancel} disabled={saving}>
+          {t("cancel")}
+        </Button>
+        <Button type="button" size="sm" onClick={handleSave} disabled={!canSave}>
+          {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+          {t("save")}
+        </Button>
+      </div>
+    </div>
+  );
+
+  if (!isDesktop) {
+    return (
+      <>
+        {triggerButton}
+        <Drawer open={open} onOpenChange={handleOpenChange}>
+          <DrawerContent>
+            <DrawerHeader>
+              <DrawerTitle>{label}</DrawerTitle>
+            </DrawerHeader>
+            <div className="px-4 pb-6">
+              <div className="grid gap-3">
+                <Input
+                  ref={inputRef}
+                  value={draft}
+                  onChange={(e) => setDraft(e.target.value)}
+                  disabled={disabled || saving}
+                  className="tabular-nums text-lg"
+                  aria-label={label}
+                  aria-invalid={validationError != null}
+                  type="number"
+                  inputMode="decimal"
+                  step={type === "integer" ? "1" : "any"}
+                  onKeyDown={(e) => {
+                    if (e.key === "Escape") {
+                      e.preventDefault();
+                      handleCancel();
+                    }
+                    if (e.key === "Enter") {
+                      e.preventDefault();
+                      void handleSave();
+                    }
+                  }}
+                />
+                {suffix && <span className="text-sm text-muted-foreground">{suffix}</span>}
+                {validationError && (
+                  <div className="text-sm text-destructive">{validationError}</div>
+                )}
+                <div className="flex gap-2 pt-2">
+                  <Button
+                    variant="outline"
+                    onClick={handleCancel}
+                    disabled={saving}
+                    className="flex-1"
+                    size="lg"
+                  >
+                    {t("cancel")}
+                  </Button>
+                  <Button onClick={handleSave} disabled={!canSave} className="flex-1" size="lg">
+                    {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                    {t("save")}
+                  </Button>
+                </div>
+              </div>
+            </div>
+          </DrawerContent>
+        </Drawer>
+      </>
+    );
+  }
+
+  return (
+    <Popover open={open} onOpenChange={handleOpenChange}>
+      <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
 
       <PopoverContent
         align="center"
@@ -129,56 +241,7 @@ export function InlineEditPopover({
         onPointerDown={stopPropagation}
         onClick={stopPropagation}
       >
-        <div className="grid gap-2">
-          <div className="text-xs font-medium">{label}</div>
-
-          <div className="flex items-center gap-2">
-            <Input
-              ref={inputRef}
-              value={draft}
-              onChange={(e) => setDraft(e.target.value)}
-              disabled={disabled || saving}
-              className="w-24 tabular-nums"
-              aria-label={label}
-              aria-invalid={validationError != null}
-              type="number"
-              inputMode="decimal"
-              step={type === "integer" ? "1" : "any"}
-              onPointerDown={stopPropagation}
-              onClick={stopPropagation}
-              onKeyDown={(e) => {
-                e.stopPropagation();
-                if (e.key === "Escape") {
-                  e.preventDefault();
-                  handleCancel();
-                }
-                if (e.key === "Enter") {
-                  e.preventDefault();
-                  void handleSave();
-                }
-              }}
-            />
-            {suffix && <span className="text-sm text-muted-foreground">{suffix}</span>}
-          </div>
-
-          {validationError && <div className="text-xs text-destructive">{validationError}</div>}
-
-          <div className="flex items-center justify-end gap-2 pt-1">
-            <Button
-              type="button"
-              size="sm"
-              variant="outline"
-              onClick={handleCancel}
-              disabled={saving}
-            >
-              {t("cancel")}
-            </Button>
-            <Button type="button" size="sm" onClick={handleSave} disabled={!canSave}>
-              {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {t("save")}
-            </Button>
-          </div>
-        </div>
+        {formContent}
       </PopoverContent>
     </Popover>
   );

+ 311 - 0
src/app/[locale]/settings/providers/_components/priority-edit-popover.tsx

@@ -0,0 +1,311 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import type * as React from "react";
+import { useEffect, useRef, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useMediaQuery } from "@/lib/hooks/use-media-query";
+import { cn } from "@/lib/utils";
+
+interface PriorityEditPopoverProps {
+  globalPriority: number;
+  groupPriorities: Record<string, number> | null;
+  groups: string[];
+  activeGroupFilter: string | null;
+  disabled?: boolean;
+  onSave: (
+    globalPriority: number,
+    groupPriorities: Record<string, number> | null
+  ) => Promise<boolean>;
+  validator: (value: string) => string | null;
+}
+
+export function PriorityEditPopover({
+  globalPriority,
+  groupPriorities,
+  groups,
+  activeGroupFilter,
+  disabled = false,
+  onSave,
+  validator,
+}: PriorityEditPopoverProps) {
+  const t = useTranslations("settings.providers.inlineEdit");
+  const isDesktop = useMediaQuery("(min-width: 768px)");
+  const [open, setOpen] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [globalDraft, setGlobalDraft] = useState(() => globalPriority.toString());
+  const [groupDrafts, setGroupDrafts] = useState<Record<string, string>>({});
+
+  const globalInputRef = useRef<HTMLInputElement>(null);
+
+  // Compute display value and whether it's a group override
+  const effectivePriority =
+    activeGroupFilter && groupPriorities?.[activeGroupFilter] != null
+      ? groupPriorities[activeGroupFilter]
+      : globalPriority;
+  const isGroupOverride = activeGroupFilter != null && groupPriorities?.[activeGroupFilter] != null;
+
+  // Validation for global draft
+  const globalError = validator(globalDraft.trim());
+
+  // Validation for group drafts
+  const groupErrors: Record<string, string | null> = {};
+  for (const g of groups) {
+    const draft = groupDrafts[g] ?? "";
+    if (draft.trim() === "") {
+      groupErrors[g] = null; // empty means use global
+    } else {
+      groupErrors[g] = validator(draft.trim());
+    }
+  }
+
+  const hasAnyError = globalError != null || Object.values(groupErrors).some((e) => e != null);
+
+  const canSave = !disabled && !saving && !hasAnyError && globalDraft.trim() !== "";
+
+  useEffect(() => {
+    if (!open) return;
+    const raf = requestAnimationFrame(() => {
+      globalInputRef.current?.focus();
+      globalInputRef.current?.select();
+    });
+    return () => cancelAnimationFrame(raf);
+  }, [open]);
+
+  const stopPropagation = (e: React.SyntheticEvent) => {
+    e.stopPropagation();
+  };
+
+  const resetDrafts = () => {
+    setGlobalDraft(globalPriority.toString());
+    const drafts: Record<string, string> = {};
+    for (const g of groups) {
+      drafts[g] = groupPriorities?.[g] != null ? groupPriorities[g].toString() : "";
+    }
+    setGroupDrafts(drafts);
+  };
+
+  const handleOpenChange = (nextOpen: boolean) => {
+    if (disabled && nextOpen) return;
+    if (nextOpen) {
+      resetDrafts();
+    } else {
+      setSaving(false);
+    }
+    setOpen(nextOpen);
+  };
+
+  const handleCancel = () => {
+    resetDrafts();
+    setOpen(false);
+  };
+
+  const handleSave = async () => {
+    if (!canSave) return;
+
+    const parsedGlobal = Number(globalDraft.trim());
+    if (!Number.isFinite(parsedGlobal) || !Number.isInteger(parsedGlobal) || parsedGlobal < 0)
+      return;
+
+    const mergedGroupPriorities: Record<string, number> = { ...(groupPriorities ?? {}) };
+    for (const g of groups) {
+      const draft = (groupDrafts[g] ?? "").trim();
+      if (draft === "") {
+        delete mergedGroupPriorities[g];
+        continue;
+      }
+      const val = Number(draft);
+      if (Number.isFinite(val) && Number.isInteger(val) && val >= 0) {
+        mergedGroupPriorities[g] = val;
+      }
+    }
+    const hasGroupOverrides = Object.keys(mergedGroupPriorities).length > 0;
+
+    setSaving(true);
+    try {
+      const ok = await onSave(parsedGlobal, hasGroupOverrides ? mergedGroupPriorities : null);
+      if (ok) {
+        setOpen(false);
+      }
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleGroupDraftChange = (group: string, value: string) => {
+    setGroupDrafts((prev) => ({ ...prev, [group]: value }));
+  };
+
+  const triggerButton = (
+    <button
+      type="button"
+      disabled={disabled}
+      className={cn(
+        "inline-flex items-center gap-1 tabular-nums font-medium underline-offset-4 rounded-sm",
+        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
+        disabled ? "cursor-default text-muted-foreground" : "cursor-pointer hover:underline"
+      )}
+      onPointerDown={stopPropagation}
+      onClick={(e) => {
+        e.stopPropagation();
+        if (!isDesktop) handleOpenChange(true);
+      }}
+    >
+      {effectivePriority}
+      {isGroupOverride && activeGroupFilter && (
+        <Badge variant="outline" className="text-[10px] px-1 py-0 leading-tight font-normal">
+          {activeGroupFilter}
+        </Badge>
+      )}
+    </button>
+  );
+
+  const priorityFormFields = (
+    <>
+      {/* Global priority */}
+      <div className="grid gap-1.5">
+        <div className="text-xs font-medium">{t("globalPriority")}</div>
+        <Input
+          ref={globalInputRef}
+          value={globalDraft}
+          onChange={(e) => setGlobalDraft(e.target.value)}
+          disabled={disabled || saving}
+          className="tabular-nums"
+          aria-label={t("globalPriority")}
+          aria-invalid={globalError != null}
+          type="number"
+          inputMode="decimal"
+          step="1"
+          onPointerDown={stopPropagation}
+          onClick={stopPropagation}
+          onKeyDown={(e) => {
+            e.stopPropagation();
+            if (e.key === "Escape") {
+              e.preventDefault();
+              handleCancel();
+            }
+            if (e.key === "Enter") {
+              e.preventDefault();
+              void handleSave();
+            }
+          }}
+        />
+        {globalError && <div className="text-xs text-destructive">{globalError}</div>}
+      </div>
+
+      {/* Per-group priorities */}
+      {groups.length > 0 && (
+        <div className="grid gap-1.5">
+          <div className="text-xs font-medium">{t("groupPriorityLabel")}</div>
+          {groups.map((group) => (
+            <div key={group} className="flex items-center gap-2">
+              <span className="text-xs text-muted-foreground min-w-[60px] truncate" title={group}>
+                {group}
+              </span>
+              <Input
+                value={groupDrafts[group] ?? ""}
+                onChange={(e) => handleGroupDraftChange(group, e.target.value)}
+                disabled={disabled || saving}
+                placeholder={t("groupPriorityPlaceholder")}
+                className="tabular-nums"
+                aria-label={`${t("groupPriorityLabel")} - ${group}`}
+                aria-invalid={groupErrors[group] != null}
+                type="number"
+                inputMode="decimal"
+                step="1"
+                onPointerDown={stopPropagation}
+                onClick={stopPropagation}
+                onKeyDown={(e) => {
+                  e.stopPropagation();
+                  if (e.key === "Escape") {
+                    e.preventDefault();
+                    handleCancel();
+                  }
+                  if (e.key === "Enter") {
+                    e.preventDefault();
+                    void handleSave();
+                  }
+                }}
+              />
+              {groupErrors[group] && (
+                <div className="text-xs text-destructive">{groupErrors[group]}</div>
+              )}
+            </div>
+          ))}
+        </div>
+      )}
+    </>
+  );
+
+  const actionButtons = (
+    <div className="flex items-center justify-end gap-2 pt-1">
+      <Button type="button" size="sm" variant="outline" onClick={handleCancel} disabled={saving}>
+        {t("cancel")}
+      </Button>
+      <Button type="button" size="sm" onClick={handleSave} disabled={!canSave}>
+        {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+        {t("save")}
+      </Button>
+    </div>
+  );
+
+  if (!isDesktop) {
+    return (
+      <>
+        {triggerButton}
+        <Drawer open={open} onOpenChange={handleOpenChange}>
+          <DrawerContent>
+            <DrawerHeader>
+              <DrawerTitle>{t("globalPriority")}</DrawerTitle>
+            </DrawerHeader>
+            <div className="px-4 pb-6">
+              <div className="grid gap-3">
+                {priorityFormFields}
+                <div className="flex gap-2 pt-2">
+                  <Button
+                    variant="outline"
+                    onClick={handleCancel}
+                    disabled={saving}
+                    className="flex-1"
+                    size="lg"
+                  >
+                    {t("cancel")}
+                  </Button>
+                  <Button onClick={handleSave} disabled={!canSave} className="flex-1" size="lg">
+                    {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                    {t("save")}
+                  </Button>
+                </div>
+              </div>
+            </div>
+          </DrawerContent>
+        </Drawer>
+      </>
+    );
+  }
+
+  return (
+    <Popover open={open} onOpenChange={handleOpenChange}>
+      <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
+
+      <PopoverContent
+        align="center"
+        side="bottom"
+        sideOffset={6}
+        className="w-auto p-3"
+        onPointerDown={stopPropagation}
+        onClick={stopPropagation}
+      >
+        <div className="grid gap-3">
+          {priorityFormFields}
+          {actionButtons}
+        </div>
+      </PopoverContent>
+    </Popover>
+  );
+}

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

@@ -23,9 +23,13 @@ interface ProviderListProps {
   statisticsLoading?: boolean;
   currencyCode?: CurrencyCode;
   enableMultiProviderTypes: boolean;
+  activeGroupFilter?: string | null;
   isMultiSelectMode?: boolean;
   selectedProviderIds?: Set<number>;
   onSelectProvider?: (providerId: number, checked: boolean) => void;
+  allGroups?: string[];
+  userGroups?: string[];
+  isAdmin?: boolean;
 }
 
 export function ProviderList({
@@ -36,9 +40,13 @@ export function ProviderList({
   statisticsLoading = false,
   currencyCode = "USD",
   enableMultiProviderTypes,
+  activeGroupFilter = null,
   isMultiSelectMode = false,
   selectedProviderIds = new Set(),
   onSelectProvider,
+  allGroups = [],
+  userGroups = [],
+  isAdmin = false,
 }: ProviderListProps) {
   const t = useTranslations("settings.providers");
 
@@ -55,7 +63,7 @@ export function ProviderList({
   }
 
   return (
-    <div className="border rounded-lg overflow-hidden">
+    <div className="grid gap-3 md:block md:border md:rounded-lg md:overflow-hidden md:gap-0">
       {providers.map((provider) => (
         <ProviderRichListItem
           key={provider.id}
@@ -66,11 +74,15 @@ export function ProviderList({
           statisticsLoading={statisticsLoading}
           currencyCode={currencyCode}
           enableMultiProviderTypes={enableMultiProviderTypes}
+          activeGroupFilter={activeGroupFilter}
           isMultiSelectMode={isMultiSelectMode}
           isSelected={selectedProviderIds.has(provider.id)}
           onSelectChange={
             onSelectProvider ? (checked) => onSelectProvider(provider.id, checked) : undefined
           }
+          allGroups={allGroups}
+          userGroups={userGroups}
+          isAdmin={isAdmin}
         />
       ))}
     </div>

+ 215 - 71
src/app/[locale]/settings/providers/_components/provider-manager.tsx

@@ -1,8 +1,9 @@
 "use client";
-import { AlertTriangle, LayoutGrid, LayoutList, Loader2, Search } from "lucide-react";
+import { AlertTriangle, Filter, LayoutGrid, LayoutList, Loader2, Search } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
 import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import {
@@ -77,6 +78,7 @@ export function ProviderManager({
   const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
   const [groupFilter, setGroupFilter] = useState<string[]>([]);
   const [circuitBrokenFilter, setCircuitBrokenFilter] = useState(false);
+  const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
 
   // Batch edit state
   const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
@@ -89,6 +91,16 @@ export function ProviderManager({
     return providers.filter((p) => healthStatus[p.id]?.circuitState === "open").length;
   }, [providers, healthStatus]);
 
+  const activeFilterCount = useMemo(() => {
+    let count = 0;
+    if (typeFilter !== "all") count++;
+    if (statusFilter !== "all") count++;
+    if (groupFilter.length > 0) count++;
+    if (circuitBrokenFilter) count++;
+    if (sortBy !== "priority") count++;
+    return count;
+  }, [typeFilter, statusFilter, groupFilter, circuitBrokenFilter, sortBy]);
+
   // Auto-reset circuit broken filter when no providers are broken
   useEffect(() => {
     if (circuitBrokenCount === 0 && circuitBrokenFilter) {
@@ -120,6 +132,18 @@ export function ProviderManager({
     return sortedGroups;
   }, [providers]);
 
+  // User's assigned groups (for non-admin users)
+  const userGroups = useMemo(() => {
+    if (!currentUser?.providerGroup) return [];
+    return currentUser.providerGroup
+      .split(",")
+      .map((g) => g.trim())
+      .filter(Boolean);
+  }, [currentUser?.providerGroup]);
+
+  // Check if current user is admin
+  const isAdmin = currentUser?.role === "admin";
+
   // 统一过滤逻辑:搜索 + 类型筛选 + 排序
   const filteredProviders = useMemo(() => {
     let result = providers;
@@ -284,52 +308,10 @@ export function ProviderManager({
         />
         {addDialogSlot ? <div className="ml-auto">{addDialogSlot}</div> : null}
       </div>
-      {/* 筛选条件 */}
+      {/* Filter section */}
       <div className="flex flex-col gap-3">
-        <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
-          {/* View Mode Toggle */}
-          <div className="flex items-center border rounded-md bg-muted/50 p-1">
-            <Button
-              variant={viewMode === "list" ? "secondary" : "ghost"}
-              size="sm"
-              className="h-7 px-2 gap-1.5 text-xs"
-              onClick={() => setViewMode("list")}
-              title={tStrings("viewModeList")}
-            >
-              <LayoutList className="h-3.5 w-3.5" />
-              <span className="hidden sm:inline">{tStrings("viewModeList")}</span>
-            </Button>
-            <Button
-              variant={viewMode === "vendor" ? "secondary" : "ghost"}
-              size="sm"
-              className="h-7 px-2 gap-1.5 text-xs"
-              onClick={() => setViewMode("vendor")}
-              title={tStrings("viewModeVendor")}
-            >
-              <LayoutGrid className="h-3.5 w-3.5" />
-              <span className="hidden sm:inline">{tStrings("viewModeVendor")}</span>
-            </Button>
-          </div>
-
-          <ProviderTypeFilter value={typeFilter} onChange={setTypeFilter} disabled={loading} />
-
-          {/* Status filter */}
-          <Select
-            value={statusFilter}
-            onValueChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
-            disabled={loading}
-          >
-            <SelectTrigger className="w-full sm:w-[140px]">
-              <SelectValue />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value="all">{tFilter("status.all")}</SelectItem>
-              <SelectItem value="active">{tFilter("status.active")}</SelectItem>
-              <SelectItem value="inactive">{tFilter("status.inactive")}</SelectItem>
-            </SelectContent>
-          </Select>
-
-          <ProviderSortDropdown value={sortBy} onChange={setSortBy} disabled={loading} />
+        {/* Mobile: search + filter toggle button */}
+        <div className="flex items-center gap-2 md:hidden">
           <div className="relative flex-1">
             <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
             <Input
@@ -341,40 +323,198 @@ export function ProviderManager({
               disabled={loading}
             />
           </div>
+          <Button
+            variant="outline"
+            size="default"
+            className="flex-shrink-0"
+            onClick={() => setMobileFilterOpen((prev) => !prev)}
+          >
+            <Filter className="mr-1.5 h-4 w-4" />
+            {activeFilterCount > 0
+              ? tFilter("mobileFilterCount", { count: activeFilterCount })
+              : tFilter("mobileFilter")}
+          </Button>
         </div>
 
-        {/* Group filter */}
-        {allGroups.length > 0 && (
-          <div className="flex flex-wrap gap-2 items-center">
-            <span className="text-sm text-muted-foreground">{tFilter("groups.label")}</span>
-            <Button
-              variant={groupFilter.length === 0 ? "default" : "outline"}
-              size="sm"
-              onClick={() => setGroupFilter([])}
-              disabled={loading}
-              className="h-7"
-            >
-              {tFilter("groups.all")}
-            </Button>
-            {allGroups.map((group) => (
+        {/* Mobile: collapsible filter panel */}
+        <Collapsible open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
+          <CollapsibleContent className="md:hidden">
+            <div className="flex flex-col gap-3 p-3 border rounded-lg bg-muted/30">
+              <ProviderTypeFilter value={typeFilter} onChange={setTypeFilter} disabled={loading} />
+              <Select
+                value={statusFilter}
+                onValueChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
+                disabled={loading}
+              >
+                <SelectTrigger>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="all">{tFilter("status.all")}</SelectItem>
+                  <SelectItem value="active">{tFilter("status.active")}</SelectItem>
+                  <SelectItem value="inactive">{tFilter("status.inactive")}</SelectItem>
+                </SelectContent>
+              </Select>
+              <ProviderSortDropdown value={sortBy} onChange={setSortBy} disabled={loading} />
+              {allGroups.length > 0 && (
+                <div className="flex flex-wrap gap-2 items-center">
+                  <span className="text-sm text-muted-foreground">{tFilter("groups.label")}</span>
+                  <Button
+                    variant={groupFilter.length === 0 ? "default" : "outline"}
+                    size="sm"
+                    onClick={() => setGroupFilter([])}
+                    disabled={loading}
+                    className="h-7"
+                  >
+                    {tFilter("groups.all")}
+                  </Button>
+                  {allGroups.map((group) => (
+                    <Button
+                      key={group}
+                      variant={groupFilter.includes(group) ? "default" : "outline"}
+                      size="sm"
+                      onClick={() =>
+                        setGroupFilter((prev) =>
+                          prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
+                        )
+                      }
+                      disabled={loading}
+                      className="h-7"
+                    >
+                      {group}
+                    </Button>
+                  ))}
+                </div>
+              )}
+              {circuitBrokenCount > 0 && (
+                <div className="flex items-center gap-2">
+                  <AlertTriangle
+                    className={`h-4 w-4 ${circuitBrokenFilter ? "text-destructive" : "text-muted-foreground"}`}
+                  />
+                  <Label
+                    htmlFor="circuit-broken-filter-mobile"
+                    className={`text-sm cursor-pointer select-none ${circuitBrokenFilter ? "text-destructive font-medium" : "text-muted-foreground"}`}
+                  >
+                    {tFilter("circuitBroken")} ({circuitBrokenCount})
+                  </Label>
+                  <Switch
+                    id="circuit-broken-filter-mobile"
+                    checked={circuitBrokenFilter}
+                    onCheckedChange={setCircuitBrokenFilter}
+                    disabled={loading}
+                  />
+                </div>
+              )}
               <Button
-                key={group}
-                variant={groupFilter.includes(group) ? "default" : "outline"}
+                variant="ghost"
                 size="sm"
                 onClick={() => {
-                  setGroupFilter((prev) =>
-                    prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
-                  );
+                  setTypeFilter("all");
+                  setStatusFilter("all");
+                  setGroupFilter([]);
+                  setCircuitBrokenFilter(false);
+                  setSortBy("priority");
                 }}
+                className="self-end"
+              >
+                {tFilter("resetFilters")}
+              </Button>
+            </div>
+          </CollapsibleContent>
+        </Collapsible>
+
+        {/* Desktop: original filter layout */}
+        <div className="hidden md:flex flex-col gap-3">
+          <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
+            {/* View Mode Toggle */}
+            <div className="flex items-center border rounded-md bg-muted/50 p-1">
+              <Button
+                variant={viewMode === "list" ? "secondary" : "ghost"}
+                size="sm"
+                className="h-7 px-2 gap-1.5 text-xs"
+                onClick={() => setViewMode("list")}
+                title={tStrings("viewModeList")}
+              >
+                <LayoutList className="h-3.5 w-3.5" />
+                <span className="hidden sm:inline">{tStrings("viewModeList")}</span>
+              </Button>
+              <Button
+                variant={viewMode === "vendor" ? "secondary" : "ghost"}
+                size="sm"
+                className="h-7 px-2 gap-1.5 text-xs"
+                onClick={() => setViewMode("vendor")}
+                title={tStrings("viewModeVendor")}
+              >
+                <LayoutGrid className="h-3.5 w-3.5" />
+                <span className="hidden sm:inline">{tStrings("viewModeVendor")}</span>
+              </Button>
+            </div>
+
+            <ProviderTypeFilter value={typeFilter} onChange={setTypeFilter} disabled={loading} />
+
+            <Select
+              value={statusFilter}
+              onValueChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
+              disabled={loading}
+            >
+              <SelectTrigger className="w-full sm:w-[140px]">
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="all">{tFilter("status.all")}</SelectItem>
+                <SelectItem value="active">{tFilter("status.active")}</SelectItem>
+                <SelectItem value="inactive">{tFilter("status.inactive")}</SelectItem>
+              </SelectContent>
+            </Select>
+
+            <ProviderSortDropdown value={sortBy} onChange={setSortBy} disabled={loading} />
+            <div className="relative flex-1">
+              <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+              <Input
+                type="search"
+                placeholder={t("placeholder")}
+                value={searchTerm}
+                onChange={(e) => setSearchTerm(e.target.value)}
+                className="pl-9"
+                disabled={loading}
+              />
+            </div>
+          </div>
+
+          {/* Group filter */}
+          {allGroups.length > 0 && (
+            <div className="flex flex-wrap gap-2 items-center">
+              <span className="text-sm text-muted-foreground">{tFilter("groups.label")}</span>
+              <Button
+                variant={groupFilter.length === 0 ? "default" : "outline"}
+                size="sm"
+                onClick={() => setGroupFilter([])}
                 disabled={loading}
                 className="h-7"
               >
-                {group}
+                {tFilter("groups.all")}
               </Button>
-            ))}
-          </div>
-        )}
-        {/* 搜索结果提示 + Circuit Breaker filter */}
+              {allGroups.map((group) => (
+                <Button
+                  key={group}
+                  variant={groupFilter.includes(group) ? "default" : "outline"}
+                  size="sm"
+                  onClick={() => {
+                    setGroupFilter((prev) =>
+                      prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
+                    );
+                  }}
+                  disabled={loading}
+                  className="h-7"
+                >
+                  {group}
+                </Button>
+              ))}
+            </div>
+          )}
+        </div>
+
+        {/* Search result count + Circuit Breaker filter (both mobile and desktop) */}
         <div className="flex items-center justify-between">
           {debouncedSearchTerm ? (
             <p className="text-sm text-muted-foreground">
@@ -394,7 +534,7 @@ export function ProviderManager({
 
           {/* Circuit Breaker toggle - only show if there are broken providers */}
           {circuitBrokenCount > 0 && (
-            <div className="flex items-center gap-2">
+            <div className="hidden md:flex items-center gap-2">
               <AlertTriangle
                 className={`h-4 w-4 ${circuitBrokenFilter ? "text-destructive" : "text-muted-foreground"}`}
               />
@@ -436,9 +576,13 @@ export function ProviderManager({
               statisticsLoading={statisticsLoading}
               currencyCode={currencyCode}
               enableMultiProviderTypes={enableMultiProviderTypes}
+              activeGroupFilter={groupFilter.length === 1 ? groupFilter[0] : null}
               isMultiSelectMode={isMultiSelectMode}
               selectedProviderIds={selectedProviderIds}
               onSelectProvider={handleSelectProvider}
+              allGroups={allGroups}
+              userGroups={userGroups}
+              isAdmin={isAdmin}
             />
           ) : (
             <ProviderVendorView

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

@@ -7,6 +7,7 @@ import {
   Edit,
   Globe,
   Key,
+  MoreHorizontal,
   RotateCcw,
   Trash,
   XCircle,
@@ -44,6 +45,13 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
 import { Skeleton } from "@/components/ui/skeleton";
 import { Switch } from "@/components/ui/switch";
 import { PROVIDER_GROUP, PROVIDER_LIMITS } from "@/lib/constants/provider.constants";
@@ -55,7 +63,9 @@ import { formatCurrency } from "@/lib/utils/currency";
 import type { ProviderDisplay, ProviderStatistics } from "@/types/provider";
 import type { User } from "@/types/user";
 import { ProviderForm } from "./forms/provider-form";
+import { GroupEditCombobox } from "./group-edit-combobox";
 import { InlineEditPopover } from "./inline-edit-popover";
+import { PriorityEditPopover } from "./priority-edit-popover";
 import { ProviderEndpointHover } from "./provider-endpoint-hover";
 
 interface ProviderRichListItemProps {
@@ -74,10 +84,14 @@ interface ProviderRichListItemProps {
   enableMultiProviderTypes: boolean;
   isMultiSelectMode?: boolean;
   isSelected?: boolean;
+  activeGroupFilter?: string | null;
   onSelectChange?: (checked: boolean) => void;
   onEdit?: () => void;
   onClone?: () => void;
   onDelete?: () => void;
+  allGroups?: string[];
+  userGroups?: string[];
+  isAdmin?: boolean;
 }
 
 export function ProviderRichListItem({
@@ -90,10 +104,14 @@ export function ProviderRichListItem({
   enableMultiProviderTypes,
   isMultiSelectMode = false,
   isSelected = false,
+  activeGroupFilter = null,
   onSelectChange,
   onEdit: onEditProp,
   onClone: onCloneProp,
   onDelete: onDeleteProp,
+  allGroups = [],
+  userGroups = [],
+  isAdmin = false,
 }: ProviderRichListItemProps) {
   const router = useRouter();
   const queryClient = useQueryClient();
@@ -106,6 +124,7 @@ export function ProviderRichListItem({
   const [openEdit, setOpenEdit] = useState(false);
   const [openClone, setOpenClone] = useState(false);
   const [showKeyDialog, setShowKeyDialog] = useState(false);
+  const [mobileDeleteDialogOpen, setMobileDeleteDialogOpen] = useState(false);
   const [unmaskedKey, setUnmaskedKey] = useState<string | null>(null);
   const [copied, setCopied] = useState(false);
   const [clipboardAvailable, setClipboardAvailable] = useState(false);
@@ -359,48 +378,280 @@ export function ProviderRichListItem({
     };
   };
 
-  const handleSavePriority = createSaveHandler("priority");
   const handleSaveWeight = createSaveHandler("weight");
   const handleSaveCostMultiplier = createSaveHandler("cost_multiplier");
 
+  const providerGroups = provider.groupTag
+    ? provider.groupTag
+        .split(",")
+        .map((t) => t.trim())
+        .filter(Boolean)
+    : [];
+
+  const handleSaveGroups = async (groups: string[]): Promise<boolean> => {
+    try {
+      const groupTag = groups.length > 0 ? groups.join(",") : null;
+      const res = await editProvider(provider.id, { group_tag: groupTag });
+      if (res.ok) {
+        toast.success(tInline("saveSuccess"));
+        queryClient.invalidateQueries({ queryKey: ["providers"] });
+        router.refresh();
+        return true;
+      }
+      toast.error(tInline("groupSaveError"), {
+        description: res.error || tList("unknownError"),
+      });
+      return false;
+    } catch (error) {
+      console.error("Failed to save groups:", error);
+      toast.error(tInline("groupSaveError"), { description: tList("unknownError") });
+      return false;
+    }
+  };
+
+  const handleSavePriorityWithGroups = async (
+    newGlobal: number,
+    newGroupPriorities: Record<string, number> | null
+  ): Promise<boolean> => {
+    try {
+      const res = await editProvider(provider.id, {
+        priority: newGlobal,
+        group_priorities: newGroupPriorities,
+      });
+      if (res.ok) {
+        toast.success(tInline("saveSuccess"));
+        queryClient.invalidateQueries({ queryKey: ["providers"] });
+        router.refresh();
+        return true;
+      }
+      toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") });
+      return false;
+    } catch (error) {
+      console.error("Failed to update priority:", error);
+      toast.error(tInline("saveFailed"), { description: tList("unknownError") });
+      return false;
+    }
+  };
+
   return (
     <>
-      <div className="flex items-center gap-4 py-3 px-4 border-b hover:bg-muted/50 transition-colors">
-        {/* 多选模式下显示 checkbox */}
+      <div className="rounded-lg border bg-card p-4 md:rounded-none md:border-0 md:border-b md:bg-transparent md:p-0 md:py-3 md:px-4 flex flex-col gap-3 md:flex-row md:items-center md:gap-4 hover:bg-muted/50 transition-colors">
+        {/* Checkbox: shared between mobile and desktop */}
         {isMultiSelectMode && (
           <Checkbox
             checked={isSelected}
             onCheckedChange={(checked) => onSelectChange?.(Boolean(checked))}
             onClick={(e) => e.stopPropagation()}
-            aria-label={`Select ${provider.name}`}
+            aria-label={tList("selectProvider", { name: provider.name })}
+            className="flex-shrink-0"
           />
         )}
 
-        {/* 左侧:状态和类型图标 */}
-        <div className="flex items-center gap-2">
-          {/* 启用状态指示器 */}
+        {/* Mobile: top row with name and switch */}
+        <div className="flex items-center justify-between md:hidden">
+          <div className="flex items-center gap-2 min-w-0 flex-1">
+            {provider.isEnabled ? (
+              <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
+            ) : (
+              <XCircle className="h-4 w-4 text-gray-400 flex-shrink-0" />
+            )}
+            <div
+              className={`flex items-center justify-center w-6 h-6 rounded ${typeConfig.bgColor} flex-shrink-0`}
+              title={`${typeLabel} - ${typeDescription}`}
+              aria-label={typeLabel}
+            >
+              <TypeIcon className="h-3.5 w-3.5" aria-hidden />
+            </div>
+            <span className="font-semibold truncate">{provider.name}</span>
+          </div>
+          {canEdit && (
+            <Switch
+              checked={provider.isEnabled}
+              onCheckedChange={handleToggle}
+              disabled={togglePending}
+              className="data-[state=checked]:bg-green-500"
+            />
+          )}
+        </div>
+
+        {/* Mobile: status badges */}
+        <div className="flex flex-wrap items-center gap-1.5 md:hidden">
+          {canEdit ? (
+            <GroupEditCombobox
+              currentGroups={providerGroups}
+              allGroups={allGroups}
+              userGroups={userGroups}
+              isAdmin={isAdmin}
+              onSave={handleSaveGroups}
+            />
+          ) : providerGroups.length > 0 ? (
+            providerGroups.map((tag, index) => {
+              const bgColor = getGroupColor(tag);
+              return (
+                <Badge
+                  key={`${tag}-${index}`}
+                  className="text-xs"
+                  style={{ backgroundColor: bgColor, color: getContrastTextColor(bgColor) }}
+                >
+                  {tag}
+                </Badge>
+              );
+            })
+          ) : (
+            <Badge variant="outline">{PROVIDER_GROUP.DEFAULT}</Badge>
+          )}
+          {healthStatus?.circuitState === "open" && (
+            <Badge variant="destructive" className="flex items-center gap-1">
+              <AlertTriangle className="h-3 w-3" />
+              {tList("circuitBroken")}
+            </Badge>
+          )}
+        </div>
+
+        {/* Mobile: metrics row */}
+        <div className="flex items-center gap-3 text-sm md:hidden">
+          <div className="flex items-center gap-1">
+            <span className="text-xs text-muted-foreground">{tList("priority")}:</span>
+            <span className="font-medium tabular-nums">
+              {canEdit ? (
+                <PriorityEditPopover
+                  globalPriority={provider.priority}
+                  groupPriorities={provider.groupPriorities}
+                  groups={providerGroups}
+                  activeGroupFilter={activeGroupFilter ?? null}
+                  validator={validatePriority}
+                  onSave={handleSavePriorityWithGroups}
+                />
+              ) : (
+                provider.priority
+              )}
+            </span>
+          </div>
+          <div className="flex items-center gap-1">
+            <span className="text-xs text-muted-foreground">{tList("weight")}:</span>
+            <span className="font-medium tabular-nums">
+              {canEdit ? (
+                <InlineEditPopover
+                  value={provider.weight}
+                  label={tInline("weightLabel")}
+                  type="integer"
+                  validator={validateWeight}
+                  onSave={handleSaveWeight}
+                />
+              ) : (
+                provider.weight
+              )}
+            </span>
+          </div>
+          <div className="flex items-center gap-1">
+            <span className="text-xs text-muted-foreground">{tList("costMultiplier")}:</span>
+            <span className="font-medium tabular-nums">
+              {canEdit ? (
+                <InlineEditPopover
+                  value={provider.costMultiplier}
+                  label={tInline("costMultiplierLabel")}
+                  validator={validateCostMultiplier}
+                  onSave={handleSaveCostMultiplier}
+                  suffix="x"
+                  type="number"
+                />
+              ) : (
+                <>{provider.costMultiplier}x</>
+              )}
+            </span>
+          </div>
+        </div>
+
+        {/* Mobile: actions */}
+        <div className="flex items-center justify-end gap-2 md:hidden">
+          {canEdit && (
+            <Button variant="outline" className="min-h-[44px] min-w-[44px]" onClick={handleEdit}>
+              <Edit className="h-4 w-4" />
+            </Button>
+          )}
+          {canEdit && (
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button
+                  variant="outline"
+                  className="min-h-[44px] min-w-[44px]"
+                  aria-label={tList("actions")}
+                >
+                  <MoreHorizontal className="h-4 w-4" />
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent align="end">
+                <DropdownMenuItem onClick={handleClone}>
+                  <Copy className="mr-2 h-4 w-4" />
+                  {tList("actionClone")}
+                </DropdownMenuItem>
+                {healthStatus?.circuitState === "open" && (
+                  <DropdownMenuItem onClick={handleResetCircuit} disabled={resetPending}>
+                    <RotateCcw className="mr-2 h-4 w-4 text-orange-600" />
+                    {tList("actionResetCircuit")}
+                  </DropdownMenuItem>
+                )}
+                {provider.limitTotalUsd !== null && provider.limitTotalUsd > 0 && (
+                  <DropdownMenuItem onClick={handleResetTotalUsage} disabled={resetUsagePending}>
+                    <RotateCcw className="mr-2 h-4 w-4 text-blue-600" />
+                    {tList("actionResetUsage")}
+                  </DropdownMenuItem>
+                )}
+                <DropdownMenuSeparator />
+                <DropdownMenuItem
+                  className="text-destructive"
+                  onSelect={() => setMobileDeleteDialogOpen(true)}
+                >
+                  <Trash className="mr-2 h-4 w-4" />
+                  {tList("actionDelete")}
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
+          )}
+        </div>
+
+        {canEdit && (
+          <AlertDialog open={mobileDeleteDialogOpen} onOpenChange={setMobileDeleteDialogOpen}>
+            <AlertDialogContent>
+              <AlertDialogHeader>
+                <AlertDialogTitle>{tList("confirmDeleteTitle")}</AlertDialogTitle>
+                <AlertDialogDescription>
+                  {tList("confirmDeleteMessage", { name: provider.name })}
+                </AlertDialogDescription>
+              </AlertDialogHeader>
+              <div className="flex justify-end gap-2">
+                <AlertDialogCancel>{tList("cancelButton")}</AlertDialogCancel>
+                <AlertDialogAction
+                  onClick={handleDelete}
+                  className="bg-red-600 hover:bg-red-700"
+                  disabled={deletePending}
+                >
+                  {tList("deleteButton")}
+                </AlertDialogAction>
+              </div>
+            </AlertDialogContent>
+          </AlertDialog>
+        )}
+
+        {/* Desktop: original info section (hidden on mobile) */}
+        <div className="hidden md:flex items-center gap-2 flex-shrink-0">
           {provider.isEnabled ? (
             <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
           ) : (
             <XCircle className="h-4 w-4 text-gray-400 flex-shrink-0" />
           )}
-
-          {/* 类型图标 */}
           <div
             className={`flex items-center justify-center w-6 h-6 rounded ${typeConfig.bgColor} flex-shrink-0`}
-            title={`${typeLabel} · ${typeDescription}`}
+            title={`${typeLabel} - ${typeDescription}`}
             aria-label={typeLabel}
           >
             <TypeIcon className="h-3.5 w-3.5" aria-hidden />
           </div>
         </div>
 
-        {/* 中间:名称、URL、官网、tag、熔断状态 */}
-        <div className="flex-1 min-w-0">
+        <div className="hidden md:block flex-1 min-w-0">
           <div className="flex items-center gap-2 flex-wrap">
-            {/* Favicon */}
             {provider.faviconUrl && (
-              // eslint-disable-next-line @next/next/no-img-element
               <img
                 src={provider.faviconUrl}
                 alt=""
@@ -410,52 +661,40 @@ export function ProviderRichListItem({
                 }}
               />
             )}
-
-            {/* 名称 */}
             <span className="font-semibold truncate">{provider.name}</span>
-
-            {/* Group Tags (supports comma-separated values) */}
-            {(provider.groupTag
-              ? provider.groupTag
-                  .split(",")
-                  .map((t) => t.trim())
-                  .filter(Boolean)
-              : []
-            ).length > 0 ? (
-              provider.groupTag
-                ?.split(",")
-                .map((t) => t.trim())
-                .filter(Boolean)
-                .map((tag, index) => {
-                  const bgColor = getGroupColor(tag);
-                  return (
-                    <Badge
-                      key={`${tag}-${index}`}
-                      className="flex-shrink-0 text-xs"
-                      style={{
-                        backgroundColor: bgColor,
-                        color: getContrastTextColor(bgColor),
-                      }}
-                    >
-                      {tag}
-                    </Badge>
-                  );
-                })
+            {canEdit ? (
+              <GroupEditCombobox
+                currentGroups={providerGroups}
+                allGroups={allGroups}
+                userGroups={userGroups}
+                isAdmin={isAdmin}
+                onSave={handleSaveGroups}
+              />
+            ) : providerGroups.length > 0 ? (
+              providerGroups.map((tag, index) => {
+                const bgColor = getGroupColor(tag);
+                return (
+                  <Badge
+                    key={`${tag}-${index}`}
+                    className="flex-shrink-0 text-xs"
+                    style={{ backgroundColor: bgColor, color: getContrastTextColor(bgColor) }}
+                  >
+                    {tag}
+                  </Badge>
+                );
+              })
             ) : (
               <Badge variant="outline" className="flex-shrink-0">
                 {PROVIDER_GROUP.DEFAULT}
               </Badge>
             )}
-
-            {/* 熔断器警告 */}
-            {healthStatus && healthStatus.circuitState === "open" && (
+            {healthStatus?.circuitState === "open" && (
               <Badge variant="destructive" className="flex items-center gap-1 flex-shrink-0">
                 <AlertTriangle className="h-3 w-3" />
                 {tList("circuitBroken")}
               </Badge>
             )}
           </div>
-
           <div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground flex-wrap">
             {/* Vendor & Endpoints OR Legacy URL */}
             {vendor ? (
@@ -482,8 +721,6 @@ export function ProviderRichListItem({
                 {tList("officialWebsite")}
               </a>
             )}
-
-            {/* API Key 展示(仅管理员) */}
             {canEdit && (
               <button
                 onClick={(e) => {
@@ -496,8 +733,6 @@ export function ProviderRichListItem({
                 {provider.maskedKey}
               </button>
             )}
-
-            {/* 超时配置可视化(紧凑格式) */}
             <span className="text-xs text-muted-foreground flex-shrink-0">
               {tTimeout("summary", {
                 streaming:
@@ -517,18 +752,19 @@ export function ProviderRichListItem({
           </div>
         </div>
 
-        {/* 右侧:指标(仅桌面端) */}
+        {/* Desktop: metrics */}
         <div className="hidden md:grid grid-cols-3 gap-4 text-center flex-shrink-0">
           <div>
             <div className="text-xs text-muted-foreground">{tList("priority")}</div>
             <div className="font-medium">
               {canEdit ? (
-                <InlineEditPopover
-                  value={provider.priority}
-                  label={tInline("priorityLabel")}
-                  type="integer"
+                <PriorityEditPopover
+                  globalPriority={provider.priority}
+                  groupPriorities={provider.groupPriorities}
+                  groups={providerGroups}
+                  activeGroupFilter={activeGroupFilter ?? null}
                   validator={validatePriority}
-                  onSave={handleSavePriority}
+                  onSave={handleSavePriorityWithGroups}
                 />
               ) : (
                 <span>{provider.priority}</span>
@@ -570,7 +806,7 @@ export function ProviderRichListItem({
           </div>
         </div>
 
-        {/* 今日用量(仅大屏) */}
+        {/* Desktop: today usage */}
         <div className="hidden lg:block text-center flex-shrink-0 min-w-[100px]">
           <div className="text-xs text-muted-foreground">{tList("todayUsageLabel")}</div>
           {statisticsLoading ? (
@@ -595,9 +831,8 @@ export function ProviderRichListItem({
           )}
         </div>
 
-        {/* 操作按钮 */}
-        <div className="flex items-center gap-1 flex-shrink-0">
-          {/* 启用/禁用切换 */}
+        {/* Desktop: action buttons */}
+        <div className="hidden md:flex items-center gap-1 flex-shrink-0">
           {canEdit && (
             <Switch
               checked={provider.isEnabled}
@@ -606,8 +841,6 @@ export function ProviderRichListItem({
               className="data-[state=checked]:bg-green-500"
             />
           )}
-
-          {/* 编辑按钮 */}
           {canEdit && (
             <Button
               size="icon"
@@ -621,8 +854,6 @@ export function ProviderRichListItem({
               <Edit className="h-4 w-4" />
             </Button>
           )}
-
-          {/* 克隆按钮 */}
           {canEdit && (
             <Button
               size="icon"
@@ -636,9 +867,7 @@ export function ProviderRichListItem({
               <Copy className="h-4 w-4" />
             </Button>
           )}
-
-          {/* 熔断重置按钮(仅熔断时显示) */}
-          {canEdit && healthStatus && healthStatus.circuitState === "open" && (
+          {canEdit && healthStatus?.circuitState === "open" && (
             <Button
               size="icon"
               variant="ghost"
@@ -651,8 +880,6 @@ export function ProviderRichListItem({
               <RotateCcw className="h-4 w-4 text-orange-600" />
             </Button>
           )}
-
-          {/* 总用量重置按钮(仅配置了总限额时显示) */}
           {canEdit && provider.limitTotalUsd !== null && provider.limitTotalUsd > 0 && (
             <Button
               size="icon"
@@ -667,8 +894,6 @@ export function ProviderRichListItem({
               <RotateCcw className="h-4 w-4 text-blue-600" />
             </Button>
           )}
-
-          {/* 删除按钮 */}
           {canEdit && (
             <AlertDialog>
               <AlertDialogTrigger asChild>

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

@@ -903,12 +903,23 @@ export class ProxyProviderResolver {
     }
 
     // Step 5: 优先级分层(只选择最高优先级的供应商)
-    const topPriorityProviders = ProxyProviderResolver.selectTopPriority(healthyProviders);
-    const priorities = [...new Set(healthyProviders.map((p) => p.priority || 0))].sort(
-      (a, b) => a - b
+    const topPriorityProviders = ProxyProviderResolver.selectTopPriority(
+      healthyProviders,
+      effectiveGroupPick
     );
+    const priorities = [
+      ...new Set(
+        healthyProviders.map((p) =>
+          ProxyProviderResolver.resolveEffectivePriority(p, effectiveGroupPick ?? null)
+        )
+      ),
+    ].sort((a, b) => a - b);
     context.priorityLevels = priorities;
-    context.selectedPriority = Math.min(...healthyProviders.map((p) => p.priority || 0));
+    context.selectedPriority = Math.min(
+      ...healthyProviders.map((p) =>
+        ProxyProviderResolver.resolveEffectivePriority(p, effectiveGroupPick ?? null)
+      )
+    );
 
     // Step 6: 成本排序 + 加权选择 + 计算概率
     const totalWeight = topPriorityProviders.reduce((sum, p) => sum + p.weight, 0);
@@ -1024,18 +1035,38 @@ export class ProxyProviderResolver {
   }
 
   /**
-   * 优先级分层:只选择最高优先级的供应商
+   * 解析供应商的有效优先级:优先使用分组覆盖值,回退到全局默认值
+   * 支持逗号分隔的多分组(如 "cli,admin"),取匹配到的最小优先级
+   */
+  static resolveEffectivePriority(provider: Provider, userGroup: string | null): number {
+    if (userGroup && provider.groupPriorities) {
+      const groups = parseGroupString(userGroup);
+      const overrides = groups
+        .map((g) => provider.groupPriorities?.[g])
+        .filter((v): v is number => v !== undefined);
+      if (overrides.length > 0) {
+        return Math.min(...overrides);
+      }
+    }
+    return provider.priority ?? 0;
+  }
+
+  /**
+   * 优先级分层:只选择最高优先级的供应商(支持分组优先级覆盖)
    */
-  private static selectTopPriority(providers: Provider[]): Provider[] {
+  private static selectTopPriority(providers: Provider[], userGroup?: string | null): Provider[] {
     if (providers.length === 0) {
       return [];
     }
 
-    // 找到最小的优先级值(最高优先级)
-    const minPriority = Math.min(...providers.map((p) => p.priority || 0));
+    const group = userGroup ?? null;
+    const minPriority = Math.min(
+      ...providers.map((p) => ProxyProviderResolver.resolveEffectivePriority(p, group))
+    );
 
-    // 只返回该优先级的供应商
-    return providers.filter((p) => (p.priority || 0) === minPriority);
+    return providers.filter(
+      (p) => ProxyProviderResolver.resolveEffectivePriority(p, group) === minPriority
+    );
   }
 
   /**
@@ -1174,7 +1205,10 @@ export class ProxyProviderResolver {
     }
 
     // 优先级分层
-    const topPriorityProviders = ProxyProviderResolver.selectTopPriority(healthyProviders);
+    const topPriorityProviders = ProxyProviderResolver.selectTopPriority(
+      healthyProviders,
+      effectiveGroupPick
+    );
 
     // 成本排序 + 加权随机选择
     const selected = ProxyProviderResolver.selectOptimal(topPriorityProviders);
@@ -1201,10 +1235,17 @@ export class ProxyProviderResolver {
         beforeHealthCheck: typeFiltered.length,
         afterHealthCheck: healthyProviders.length,
         filteredProviders: [],
-        priorityLevels: [...new Set(healthyProviders.map((p) => p.priority || 0))].sort(
-          (a, b) => a - b
+        priorityLevels: [
+          ...new Set(
+            healthyProviders.map((p) =>
+              ProxyProviderResolver.resolveEffectivePriority(p, effectiveGroupPick ?? null)
+            )
+          ),
+        ].sort((a, b) => a - b),
+        selectedPriority: ProxyProviderResolver.resolveEffectivePriority(
+          selected,
+          effectiveGroupPick ?? null
         ),
-        selectedPriority: selected.priority || 0,
         candidatesAtPriority: candidates,
       },
     };

+ 124 - 0
src/components/ui/drawer.tsx

@@ -0,0 +1,124 @@
+"use client";
+
+import type * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+import { cn } from "@/lib/utils/index";
+
+function Drawer({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
+  return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
+}
+
+function DrawerTrigger({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
+  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
+}
+
+function DrawerPortal({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
+  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
+}
+
+function DrawerClose({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
+  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
+}
+
+function DrawerOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
+  return (
+    <DrawerPrimitive.Overlay
+      data-slot="drawer-overlay"
+      className={cn(
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+function DrawerContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
+  return (
+    <DrawerPortal data-slot="drawer-portal">
+      <DrawerOverlay />
+      <DrawerPrimitive.Content
+        data-slot="drawer-content"
+        className={cn(
+          "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
+          "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
+          "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
+          "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
+          "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
+          className
+        )}
+        {...props}
+      >
+        <div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
+        {children}
+      </DrawerPrimitive.Content>
+    </DrawerPortal>
+  );
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-header"
+      className={cn(
+        "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
+        className
+      )}
+      {...props}
+    />
+  );
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-footer"
+      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+      {...props}
+    />
+  );
+}
+
+function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
+  return (
+    <DrawerPrimitive.Title
+      data-slot="drawer-title"
+      className={cn("text-foreground font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function DrawerDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
+  return (
+    <DrawerPrimitive.Description
+      data-slot="drawer-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Drawer,
+  DrawerPortal,
+  DrawerOverlay,
+  DrawerTrigger,
+  DrawerClose,
+  DrawerContent,
+  DrawerHeader,
+  DrawerFooter,
+  DrawerTitle,
+  DrawerDescription,
+};

+ 1 - 0
src/drizzle/schema.ts

@@ -162,6 +162,7 @@ export const providers = pgTable('providers', {
 
   // 优先级和分组配置
   priority: integer('priority').notNull().default(0),
+  groupPriorities: jsonb('group_priorities').$type<Record<string, number> | null>().default(null),
   costMultiplier: numeric('cost_multiplier', { precision: 10, scale: 4 }).default('1.0'),
   groupTag: varchar('group_tag', { length: 50 }),
 

+ 20 - 0
src/lib/hooks/use-media-query.ts

@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export function useMediaQuery(query: string): boolean {
+  const [matches, setMatches] = useState(false);
+
+  useEffect(() => {
+    if (typeof window === "undefined" || !("matchMedia" in window)) {
+      return;
+    }
+    const mql = window.matchMedia(query);
+    setMatches(mql.matches);
+    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
+    mql.addEventListener("change", handler);
+    return () => mql.removeEventListener("change", handler);
+  }, [query]);
+
+  return matches;
+}

+ 13 - 4
src/lib/validation/schemas.ts

@@ -31,13 +31,13 @@ const ANTHROPIC_MAX_TOKENS_PREFERENCE = z.union([
   z.literal("inherit"),
   z
     .string()
-    .regex(/^\d+$/, "max_tokens must be 'inherit' or a numeric string")
+    .regex(/^\d+$/, 'max_tokens 必须为 "inherit" 或数字字符串')
     .refine(
       (val) => {
         const num = Number.parseInt(val, 10);
         return num >= 1 && num <= 64000;
       },
-      { message: "max_tokens must be between 1 and 64000" }
+      { message: "max_tokens 必须在 1 到 64000 之间" }
     ),
 ]);
 
@@ -45,13 +45,13 @@ const ANTHROPIC_THINKING_BUDGET_PREFERENCE = z.union([
   z.literal("inherit"),
   z
     .string()
-    .regex(/^\d+$/, "thinking.budget_tokens must be 'inherit' or a numeric string")
+    .regex(/^\d+$/, 'thinking.budget_tokens 必须为 "inherit" 或数字字符串')
     .refine(
       (val) => {
         const num = Number.parseInt(val, 10);
         return num >= 1024 && num <= 32000;
       },
-      { message: "thinking.budget_tokens must be between 1024 and 32000" }
+      { message: "thinking.budget_tokens 必须在 1024 到 32000 之间" }
     ),
 ]);
 
@@ -409,6 +409,11 @@ export const CreateProviderSchema = z
       .max(2147483647, "优先级超出整数范围")
       .optional()
       .default(0),
+    group_priorities: z
+      .record(z.string(), z.number().int().min(0).max(2147483647))
+      .nullable()
+      .optional()
+      .default(null),
     cost_multiplier: z.coerce.number().min(0, "成本倍率不能为负数").optional().default(1.0),
     group_tag: z.string().max(50, "分组标签不能超过50个字符").nullable().optional(),
     // Codex 支持:供应商类型和模型重定向
@@ -610,6 +615,10 @@ export const UpdateProviderSchema = z
       .min(0, "优先级不能为负数")
       .max(2147483647, "优先级超出整数范围")
       .optional(),
+    group_priorities: z
+      .record(z.string(), z.number().int().min(0).max(2147483647))
+      .nullable()
+      .optional(),
     cost_multiplier: z.coerce.number().min(0, "成本倍率不能为负数").optional(),
     group_tag: z.string().max(50, "分组标签不能超过50个字符").nullable().optional(),
     // Codex 支持:供应商类型和模型重定向

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

@@ -85,6 +85,7 @@ export function toProvider(dbProvider: any): Provider {
     isEnabled: dbProvider?.isEnabled ?? true,
     weight: dbProvider?.weight ?? 1,
     priority: dbProvider?.priority ?? 0,
+    groupPriorities: dbProvider?.groupPriorities ?? null,
     costMultiplier: dbProvider?.costMultiplier ? parseFloat(dbProvider.costMultiplier) : 1.0,
     groupTag: dbProvider?.groupTag ?? null,
     providerType: dbProvider?.providerType ?? "claude",

+ 7 - 0
src/repository/provider.ts

@@ -24,6 +24,7 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     isEnabled: providerData.is_enabled,
     weight: providerData.weight,
     priority: providerData.priority,
+    groupPriorities: providerData.group_priorities ?? null,
     costMultiplier:
       providerData.cost_multiplier != null ? providerData.cost_multiplier.toString() : "1.0",
     groupTag: providerData.group_tag,
@@ -175,6 +176,7 @@ export async function findProviderList(
       isEnabled: providers.isEnabled,
       weight: providers.weight,
       priority: providers.priority,
+      groupPriorities: providers.groupPriorities,
       costMultiplier: providers.costMultiplier,
       groupTag: providers.groupTag,
       providerType: providers.providerType,
@@ -252,6 +254,7 @@ export async function findAllProvidersFresh(): Promise<Provider[]> {
       isEnabled: providers.isEnabled,
       weight: providers.weight,
       priority: providers.priority,
+      groupPriorities: providers.groupPriorities,
       costMultiplier: providers.costMultiplier,
       groupTag: providers.groupTag,
       providerType: providers.providerType,
@@ -333,6 +336,7 @@ export async function findProviderById(id: number): Promise<Provider | null> {
       isEnabled: providers.isEnabled,
       weight: providers.weight,
       priority: providers.priority,
+      groupPriorities: providers.groupPriorities,
       costMultiplier: providers.costMultiplier,
       groupTag: providers.groupTag,
       providerType: providers.providerType,
@@ -403,6 +407,8 @@ export async function updateProvider(
   if (providerData.is_enabled !== undefined) dbData.isEnabled = providerData.is_enabled;
   if (providerData.weight !== undefined) dbData.weight = providerData.weight;
   if (providerData.priority !== undefined) dbData.priority = providerData.priority;
+  if (providerData.group_priorities !== undefined)
+    dbData.groupPriorities = providerData.group_priorities ?? null;
   if (providerData.cost_multiplier !== undefined)
     dbData.costMultiplier =
       providerData.cost_multiplier != null ? providerData.cost_multiplier.toString() : "1.0";
@@ -541,6 +547,7 @@ export async function updateProvider(
         isEnabled: providers.isEnabled,
         weight: providers.weight,
         priority: providers.priority,
+        groupPriorities: providers.groupPriorities,
         costMultiplier: providers.costMultiplier,
         groupTag: providers.groupTag,
         providerType: providers.providerType,

+ 4 - 0
src/types/provider.ts

@@ -59,6 +59,7 @@ export interface Provider {
 
   // 优先级和分组配置
   priority: number;
+  groupPriorities: Record<string, number> | null;
   costMultiplier: number;
   groupTag: string | null;
 
@@ -162,6 +163,7 @@ export interface ProviderDisplay {
   weight: number;
   // 优先级和分组配置
   priority: number;
+  groupPriorities: Record<string, number> | null;
   costMultiplier: number;
   groupTag: string | null;
   // 供应商类型
@@ -251,6 +253,7 @@ export interface CreateProviderData {
 
   // 优先级和分组配置
   priority?: number;
+  group_priorities?: Record<string, number> | null;
   cost_multiplier?: number;
   group_tag?: string | null;
 
@@ -322,6 +325,7 @@ export interface UpdateProviderData {
 
   // 优先级和分组配置
   priority?: number;
+  group_priorities?: Record<string, number> | null;
   cost_multiplier?: number;
   group_tag?: string | null;
 

+ 201 - 0
tests/unit/proxy/provider-selector-group-priority.test.ts

@@ -0,0 +1,201 @@
+import { describe, expect, it } from "vitest";
+import type { Provider } from "@/types/provider";
+import { ProxyProviderResolver } from "@/app/v1/_lib/proxy/provider-selector";
+
+function makeProvider(overrides: Partial<Provider>): Provider {
+  return {
+    id: 1,
+    name: "test",
+    url: "https://api.example.com",
+    key: "sk-test",
+    providerVendorId: null,
+    isEnabled: true,
+    weight: 1,
+    priority: 0,
+    groupPriorities: null,
+    costMultiplier: 1,
+    groupTag: null,
+    providerType: "claude",
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    totalCostResetAt: null,
+    limitConcurrentSessions: 0,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 5,
+    circuitBreakerOpenDuration: 1800000,
+    circuitBreakerHalfOpenSuccessThreshold: 2,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 30000,
+    streamingIdleTimeoutMs: 10000,
+    requestTimeoutNonStreamingMs: 600000,
+    websiteUrl: null,
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    geminiGoogleSearchPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    ...overrides,
+  };
+}
+
+describe("resolveEffectivePriority", () => {
+  it("returns global priority when no groupPriorities", () => {
+    const provider = makeProvider({ priority: 5, groupPriorities: null });
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli")).toBe(5);
+  });
+
+  it("returns group-specific priority when override exists", () => {
+    const provider = makeProvider({
+      priority: 5,
+      groupPriorities: { cli: 0, chat: 2 },
+    });
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli")).toBe(0);
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "chat")).toBe(2);
+  });
+
+  it("falls back to global when group not in overrides", () => {
+    const provider = makeProvider({
+      priority: 5,
+      groupPriorities: { cli: 0 },
+    });
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "chat")).toBe(5);
+  });
+
+  it("returns global priority when userGroup is null", () => {
+    const provider = makeProvider({
+      priority: 5,
+      groupPriorities: { cli: 0 },
+    });
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, null)).toBe(5);
+  });
+
+  it("handles group priority of 0 correctly (not falsy)", () => {
+    const provider = makeProvider({
+      priority: 5,
+      groupPriorities: { cli: 0 },
+    });
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli")).toBe(0);
+  });
+
+  it("handles comma-separated user groups (multi-group)", () => {
+    const provider = makeProvider({
+      priority: 10,
+      groupPriorities: { cli: 2, admin: 5, chat: 8 },
+    });
+    // Multi-group "cli,admin" should match both and take minimum (2)
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli,admin")).toBe(2);
+    // Multi-group "admin,chat" should take minimum (5)
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "admin,chat")).toBe(5);
+  });
+
+  it("falls back to global when no group in multi-group matches", () => {
+    const provider = makeProvider({
+      priority: 10,
+      groupPriorities: { cli: 2 },
+    });
+    // "admin,chat" has no matching overrides, should fall back to global (10)
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "admin,chat")).toBe(10);
+  });
+
+  it("handles partial match in multi-group", () => {
+    const provider = makeProvider({
+      priority: 10,
+      groupPriorities: { cli: 3 },
+    });
+    // "cli,admin" - only "cli" matches, should return 3
+    expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli,admin")).toBe(3);
+  });
+});
+
+describe("selectTopPriority with group context", () => {
+  // Access private method via bracket notation for testing
+  const selectTopPriority = (providers: Provider[], userGroup?: string | null) =>
+    (ProxyProviderResolver as any).selectTopPriority(providers, userGroup);
+
+  it("selects providers by group-aware priority", () => {
+    const providerA = makeProvider({
+      id: 1,
+      name: "A",
+      priority: 5,
+      groupPriorities: { cli: 0 },
+    });
+    const providerB = makeProvider({
+      id: 2,
+      name: "B",
+      priority: 0,
+      groupPriorities: null,
+    });
+
+    // cli group: A has effective priority 0, B has effective priority 0
+    const result = selectTopPriority([providerA, providerB], "cli");
+    expect(result).toHaveLength(2);
+    expect(result.map((p: Provider) => p.id).sort()).toEqual([1, 2]);
+  });
+
+  it("without group context, uses global priority", () => {
+    const providerA = makeProvider({
+      id: 1,
+      name: "A",
+      priority: 5,
+      groupPriorities: { cli: 0 },
+    });
+    const providerB = makeProvider({
+      id: 2,
+      name: "B",
+      priority: 0,
+      groupPriorities: null,
+    });
+
+    // no group: A has priority 5, B has priority 0 -> only B selected
+    const result = selectTopPriority([providerA, providerB], null);
+    expect(result).toHaveLength(1);
+    expect(result[0].id).toBe(2);
+  });
+
+  it("group override changes which providers are top priority", () => {
+    const providerA = makeProvider({
+      id: 1,
+      name: "A",
+      priority: 5,
+      groupPriorities: { chat: 1 },
+    });
+    const providerB = makeProvider({
+      id: 2,
+      name: "B",
+      priority: 3,
+      groupPriorities: null,
+    });
+
+    // chat group: A=1, B=3 -> only A
+    const chatResult = selectTopPriority([providerA, providerB], "chat");
+    expect(chatResult).toHaveLength(1);
+    expect(chatResult[0].id).toBe(1);
+
+    // no group: A=5, B=3 -> only B
+    const noGroupResult = selectTopPriority([providerA, providerB], null);
+    expect(noGroupResult).toHaveLength(1);
+    expect(noGroupResult[0].id).toBe(2);
+  });
+});