Explorar o código

feat(notification): add cache hit rate alert with review fixes (#834)

* feat(notification): add cache hit rate alert (#824)

* feat(notification): 增加缓存命中率异常告警 (#823)

- 支持 5m/30m/1h/1.5h/auto 滚动窗口,按 provider×model 统计并与历史/今日/上一个窗口对比

- 以同一 sessionId + TTL 约束定义 eligible 命中口径,避免跨会话误判

- 新增 webhook 类型/模板/占位符与通知设置页配置,并加入去重冷却

- 增加单测覆盖 decision 与模板渲染

* fix(cache-hit-rate-alert): 收窄 windowMode 校验并补齐边界用例

* refactor(provider): 批量更新使用 RETURNING 计数

* refactor: 清理告警冗余计算并补齐异常用例

* fix(cache-hit-rate-alert): Map 输入过滤无效指标

* fix(i18n): zh-TW 调整 Tokens 文案

* fix(cache-hit-rate-alert): 修复 cooldown=0 与类型断言

* refactor(cache-hit-rate-alert): 优化去重读写与类型细节

* fix(cache-hit-rate-alert): 修复 fan-out 重试漏发并补强 abs_min

* fix(cache-hit-rate-alert): targets 模式按 binding 提交 cooldown

* fix(cache-hit-rate-alert): 补强 cooldown 边界与 payload 校验

* fix(cache-hit-rate-alert): 加强 payload 校验与冷却提交容错

* test/cache-hit-rate-alert: 补强 dedup 断言与 guard 校验

* fix(cache-hit-rate-alert): payload guard 校验 generatedAt

* refactor(cache-hit-rate-alert): cooldown keys 去重

* fix(cache-hit-rate-alert): 严格校验 window.mode

* fix(notification): 强化缓存命中率告警健壮性

- 校验 window.startTime/endTime 可解析且 end>=start

- fan-out 主作业 job.update 失败改为 best-effort

- 补充调度注释:共享 repeat 作业会忽略 per-binding cron/timezone

* fix(cache-hit-rate-alert): 强化 TTL 推断与告警稳定性

* fix(notification): cache-hit-rate-alert 调度兼容大间隔

* fix(notification): 加固缓存命中率告警边界与校验

- dropAbs/严重度字段统一 clamp(避免负跌幅)

- swapCacheTtlApplied 下 eligible TTL 口径还原

- cooldown EX 秒数强制整数 + UI/API 数值输入校验

* fix(settings): 通知设置数值解析正确处理 null/空串

避免 Number(null)==0 导致阈值/间隔被错误 clamp

* fix(settings): 测试 Webhook 类型选择移除不安全断言

---------

Co-authored-by: tesgth032 <[email protected]>

* fix: address code review findings for cache hit rate alert

- Fix costAlertCheckInterval null producing invalid cron in legacy mode
- Cap historicalLookbackDays to 90 to prevent unbounded DB queries
- Strengthen CacheHitRateAlertWindow.mode type from string to union
- Complete default webhook template with all 12 documented placeholders
- Add trailing newline to migration file (POSIX compliance)
- Replace safeNumberOnChange with NumberInput component to fix UX issue
  where clearing a number input via backspace caused value snap-back

* fix: address valid bugbot findings

- Add max=2147483647 to minEligibleTokens input (match PG integer limit)
- Replace relative import with @/ alias in cache-hit-rate-alert repository
- Add Invalid Date guard before SQL query to prevent NaN propagation

---------

Co-authored-by: tesgth032 <[email protected]>
Co-authored-by: tesgth032 <[email protected]>
Ding hai 1 mes
pai
achega
8dc3e703be
Modificáronse 39 ficheiros con 7801 adicións e 107 borrados
  1. 1 1
      biome.json
  2. 13 0
      drizzle/0076_mighty_lionheart.sql
  3. 3903 0
      drizzle/meta/0076_snapshot.json
  4. 7 0
      drizzle/meta/_journal.json
  5. 16 0
      messages/en/settings/notifications.json
  6. 16 0
      messages/ja/settings/notifications.json
  7. 16 0
      messages/ru/settings/notifications.json
  8. 16 0
      messages/zh-CN/settings/notifications.json
  9. 16 0
      messages/zh-TW/settings/notifications.json
  10. 6 1
      src/actions/notification-bindings.ts
  11. 56 1
      src/actions/webhook-targets.ts
  12. 343 17
      src/app/[locale]/settings/notifications/_components/notification-type-card.tsx
  13. 1 1
      src/app/[locale]/settings/notifications/_components/notifications-skeleton.tsx
  14. 31 7
      src/app/[locale]/settings/notifications/_components/template-editor.tsx
  15. 16 1
      src/app/[locale]/settings/notifications/_components/test-webhook-button.tsx
  16. 172 8
      src/app/[locale]/settings/notifications/_lib/hooks.ts
  17. 1 0
      src/app/[locale]/settings/notifications/_lib/schemas.ts
  18. 86 6
      src/app/api/actions/[...route]/route.ts
  19. 15 0
      src/drizzle/schema.ts
  20. 321 0
      src/lib/cache-hit-rate-alert/decision.ts
  21. 1 0
      src/lib/constants/notification.constants.ts
  22. 441 8
      src/lib/notification/notification-queue.ts
  23. 477 0
      src/lib/notification/tasks/cache-hit-rate-alert.ts
  24. 7 0
      src/lib/webhook/index.ts
  25. 130 0
      src/lib/webhook/templates/cache-hit-rate-alert.ts
  26. 17 0
      src/lib/webhook/templates/defaults.ts
  27. 1 0
      src/lib/webhook/templates/index.ts
  28. 34 0
      src/lib/webhook/templates/placeholders.ts
  29. 53 0
      src/lib/webhook/templates/test-messages.ts
  30. 79 1
      src/lib/webhook/types.ts
  31. 360 0
      src/repository/cache-hit-rate-alert.ts
  32. 5 1
      src/repository/notification-bindings.ts
  33. 119 0
      src/repository/notifications.ts
  34. 2 3
      src/repository/provider.ts
  35. 37 47
      tests/unit/components/form/client-restrictions-editor.test.tsx
  36. 312 0
      tests/unit/lib/cache-hit-rate-alert/cooldown-dedup.test.ts
  37. 496 0
      tests/unit/lib/cache-hit-rate-alert/decision.test.ts
  38. 26 4
      tests/unit/repository/provider.test.ts
  39. 152 0
      tests/unit/webhook/templates/templates.test.ts

+ 1 - 1
biome.json

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

+ 13 - 0
drizzle/0076_mighty_lionheart.sql

@@ -0,0 +1,13 @@
+ALTER TYPE "public"."notification_type" ADD VALUE 'cache_hit_rate_alert';--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_webhook" varchar(512);--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_window_mode" varchar(10) DEFAULT 'auto';--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_check_interval" integer DEFAULT 5;--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_historical_lookback_days" integer DEFAULT 7;--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_min_eligible_requests" integer DEFAULT 20;--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_min_eligible_tokens" integer DEFAULT 0;--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_abs_min" numeric(5, 4) DEFAULT '0.05';--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_drop_rel" numeric(5, 4) DEFAULT '0.3';--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_drop_abs" numeric(5, 4) DEFAULT '0.1';--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_cooldown_minutes" integer DEFAULT 30;--> statement-breakpoint
+ALTER TABLE "notification_settings" ADD COLUMN "cache_hit_rate_alert_top_n" integer DEFAULT 10;

+ 3903 - 0
drizzle/meta/0076_snapshot.json

@@ -0,0 +1,3903 @@
+{
+  "id": "75cee3f5-1789-413b-a9ed-5aabc52fa0a0",
+  "prevId": "61c4da35-57cf-4629-88de-a1af77c8ae3b",
+  "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_key": {
+          "name": "idx_keys_key",
+          "columns": [
+            {
+              "expression": "key",
+              "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
+        },
+        "swap_cache_ttl_applied": {
+          "name": "swap_cache_ttl_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_created_at_cost_stats": {
+          "name": "idx_message_request_user_created_at_cost_stats",
+          "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 AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')",
+          "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_provider_created_at_active": {
+          "name": "idx_message_request_provider_created_at_active",
+          "columns": [
+            {
+              "expression": "provider_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 AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')",
+          "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_key_created_at_id": {
+          "name": "idx_message_request_key_created_at_id",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key_model_active": {
+          "name": "idx_message_request_key_model_active",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "model",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key_endpoint_active": {
+          "name": "idx_message_request_key_endpoint_active",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "endpoint",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at_id_active": {
+          "name": "idx_message_request_created_at_id_active",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_model_active": {
+          "name": "idx_message_request_model_active",
+          "columns": [
+            {
+              "expression": "model",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_status_code_active": {
+          "name": "idx_message_request_status_code_active",
+          "columns": [
+            {
+              "expression": "status_code",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL",
+          "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": {}
+        },
+        "idx_message_request_key_last_active": {
+          "name": "idx_message_request_key_last_active",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "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_key_cost_active": {
+          "name": "idx_message_request_key_cost_active",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": 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_user_info": {
+          "name": "idx_message_request_session_user_info",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "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
+        },
+        "cache_hit_rate_alert_enabled": {
+          "name": "cache_hit_rate_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cache_hit_rate_alert_webhook": {
+          "name": "cache_hit_rate_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_hit_rate_alert_window_mode": {
+          "name": "cache_hit_rate_alert_window_mode",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "cache_hit_rate_alert_check_interval": {
+          "name": "cache_hit_rate_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cache_hit_rate_alert_historical_lookback_days": {
+          "name": "cache_hit_rate_alert_historical_lookback_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 7
+        },
+        "cache_hit_rate_alert_min_eligible_requests": {
+          "name": "cache_hit_rate_alert_min_eligible_requests",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 20
+        },
+        "cache_hit_rate_alert_min_eligible_tokens": {
+          "name": "cache_hit_rate_alert_min_eligible_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cache_hit_rate_alert_abs_min": {
+          "name": "cache_hit_rate_alert_abs_min",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "cache_hit_rate_alert_drop_rel": {
+          "name": "cache_hit_rate_alert_drop_rel",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.3'"
+        },
+        "cache_hit_rate_alert_drop_abs": {
+          "name": "cache_hit_rate_alert_drop_abs",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.1'"
+        },
+        "cache_hit_rate_alert_cooldown_minutes": {
+          "name": "cache_hit_rate_alert_cooldown_minutes",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cache_hit_rate_alert_top_n": {
+          "name": "cache_hit_rate_alert_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10
+        },
+        "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,
+          "where": "\"provider_endpoints\".\"deleted_at\" IS NULL",
+          "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_pick_enabled": {
+          "name": "idx_provider_endpoints_pick_enabled",
+          "columns": [
+            {
+              "expression": "vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "sort_order",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "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"
+        },
+        "allowed_clients": {
+          "name": "allowed_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'[]'::jsonb"
+        },
+        "blocked_clients": {
+          "name": "blocked_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'[]'::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
+        },
+        "swap_cache_ttl_billing": {
+          "name": "swap_cache_ttl_billing",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 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
+        },
+        "anthropic_adaptive_thinking": {
+          "name": "anthropic_adaptive_thinking",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "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_vendor_type_url_active": {
+          "name": "idx_providers_vendor_type_url_active",
+          "columns": [
+            {
+              "expression": "provider_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": 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": {}
+        },
+        "idx_providers_enabled_vendor_type": {
+          "name": "idx_providers_enabled_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 AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0",
+          "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_billing_header_rectifier": {
+          "name": "enable_billing_header_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.usage_ledger": {
+      "name": "usage_ledger",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "request_id": {
+          "name": "request_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
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "final_provider_id": {
+          "name": "final_provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "endpoint": {
+          "name": "endpoint",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_success": {
+          "name": "is_success",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "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
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "bigint",
+          "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
+        },
+        "swap_cache_ttl_applied": {
+          "name": "swap_cache_ttl_applied",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "ttfb_ms": {
+          "name": "ttfb_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "idx_usage_ledger_request_id": {
+          "name": "idx_usage_ledger_request_id",
+          "columns": [
+            {
+              "expression": "request_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_user_created_at": {
+          "name": "idx_usage_ledger_user_created_at",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"usage_ledger\".\"blocked_by\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_key_created_at": {
+          "name": "idx_usage_ledger_key_created_at",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"usage_ledger\".\"blocked_by\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_provider_created_at": {
+          "name": "idx_usage_ledger_provider_created_at",
+          "columns": [
+            {
+              "expression": "final_provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"usage_ledger\".\"blocked_by\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_created_at_minute": {
+          "name": "idx_usage_ledger_created_at_minute",
+          "columns": [
+            {
+              "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')",
+              "asc": true,
+              "isExpression": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_created_at_desc_id": {
+          "name": "idx_usage_ledger_created_at_desc_id",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_session_id": {
+          "name": "idx_usage_ledger_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"usage_ledger\".\"session_id\" IS NOT NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_model": {
+          "name": "idx_usage_ledger_model",
+          "columns": [
+            {
+              "expression": "model",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"usage_ledger\".\"model\" IS NOT NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_key_cost": {
+          "name": "idx_usage_ledger_key_cost",
+          "columns": [
+            {
+              "expression": "key",
+              "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": "\"usage_ledger\".\"blocked_by\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_user_cost_cover": {
+          "name": "idx_usage_ledger_user_cost_cover",
+          "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": "\"usage_ledger\".\"blocked_by\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_usage_ledger_provider_cost_cover": {
+          "name": "idx_usage_ledger_provider_cost_cover",
+          "columns": [
+            {
+              "expression": "final_provider_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": "\"usage_ledger\".\"blocked_by\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "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"
+        },
+        "blocked_clients": {
+          "name": "blocked_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true,
+          "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_tags_gin": {
+          "name": "idx_users_tags_gin",
+          "columns": [
+            {
+              "expression": "tags",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "gin",
+          "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",
+        "cache_hit_rate_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": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -533,6 +533,13 @@
       "when": 1771688588623,
       "tag": "0075_faithful_speed_demon",
       "breakpoints": true
+    },
+    {
+      "idx": 76,
+      "version": "7",
+      "when": 1771954926766,
+      "tag": "0076_mighty_lionheart",
+      "breakpoints": true
     }
   ]
 }

+ 16 - 0
messages/en/settings/notifications.json

@@ -38,6 +38,22 @@
     "webhookTypeUnknown": "Unknown platform. Please use WeCom or Feishu webhook URL",
     "webhookTypeWeCom": "WeCom"
   },
+  "cacheHitRateAlert": {
+    "absMin": "Absolute Minimum (absMin)",
+    "checkInterval": "Check Interval (minutes)",
+    "cooldownMinutes": "Cooldown (minutes)",
+    "description": "Alert when cache hit rate drops abnormally (provider × model)",
+    "dropAbs": "Absolute Drop (dropAbs)",
+    "dropRel": "Relative Drop (dropRel)",
+    "enable": "Enable Cache Hit Rate Alert",
+    "historicalLookbackDays": "Historical Lookback (days)",
+    "minEligibleRequests": "Min Eligible Requests",
+    "minEligibleTokens": "Min Eligible Tokens",
+    "title": "Cache Hit Rate Alert",
+    "topN": "Top N",
+    "windowMode": "Window",
+    "windowModeAuto": "Auto"
+  },
   "dailyLeaderboard": {
     "description": "Send daily scheduled user consumption Top N leaderboard",
     "enable": "Enable Daily Leaderboard",

+ 16 - 0
messages/ja/settings/notifications.json

@@ -38,6 +38,22 @@
     "webhookTypeUnknown": "不明なプラットフォームです。WeComまたはFeishuのWebhook URLを使用してください",
     "webhookTypeWeCom": "WeCom"
   },
+  "cacheHitRateAlert": {
+    "absMin": "絶対下限(absMin)",
+    "checkInterval": "チェック間隔(分)",
+    "cooldownMinutes": "クールダウン(分)",
+    "description": "キャッシュヒット率(provider × model)が異常に低下した場合に通知します",
+    "dropAbs": "絶対低下幅(dropAbs)",
+    "dropRel": "相対低下幅(dropRel)",
+    "enable": "キャッシュヒット率アラートを有効にする",
+    "historicalLookbackDays": "履歴参照(日)",
+    "minEligibleRequests": "最小 eligible リクエスト数",
+    "minEligibleTokens": "最小 eligible トークン数",
+    "title": "キャッシュヒット率アラート",
+    "topN": "Top N",
+    "windowMode": "ウィンドウ",
+    "windowModeAuto": "自動"
+  },
   "dailyLeaderboard": {
     "description": "毎日定時でユーザー消費トップNランキングを送信",
     "enable": "日次ランキングを有効にする",

+ 16 - 0
messages/ru/settings/notifications.json

@@ -38,6 +38,22 @@
     "webhookTypeUnknown": "Неизвестная платформа. Используйте URL вебхука WeCom или Feishu",
     "webhookTypeWeCom": "WeCom"
   },
+  "cacheHitRateAlert": {
+    "absMin": "Абсолютный минимум (absMin)",
+    "checkInterval": "Интервал проверки (мин)",
+    "cooldownMinutes": "Охлаждение (мин)",
+    "description": "Уведомлять при аномальном падении доли кеш-хитов (provider × model)",
+    "dropAbs": "Абсолютное падение (dropAbs)",
+    "dropRel": "Относительное падение (dropRel)",
+    "enable": "Включить оповещение о кеш-хите",
+    "historicalLookbackDays": "Исторический период (дни)",
+    "minEligibleRequests": "Мин. eligible запросов",
+    "minEligibleTokens": "Мин. eligible токенов",
+    "title": "Оповещение о кеш-хите",
+    "topN": "Top N",
+    "windowMode": "Окно",
+    "windowModeAuto": "Авто"
+  },
   "dailyLeaderboard": {
     "description": "Ежедневная отправка рейтинга топ N пользователей по потреблению",
     "enable": "Включить ежедневный рейтинг",

+ 16 - 0
messages/zh-CN/settings/notifications.json

@@ -130,6 +130,22 @@
     "interval": "检查间隔(分钟)",
     "test": "测试连接"
   },
+  "cacheHitRateAlert": {
+    "title": "缓存命中率异常告警",
+    "description": "当缓存命中率(provider × model)异常下降时发送告警",
+    "enable": "启用缓存命中率告警",
+    "windowMode": "窗口",
+    "windowModeAuto": "自动",
+    "checkInterval": "检查间隔(分钟)",
+    "historicalLookbackDays": "历史回看天数",
+    "cooldownMinutes": "冷却(分钟)",
+    "absMin": "绝对下限(absMin)",
+    "dropAbs": "绝对跌幅阈值(dropAbs)",
+    "dropRel": "相对跌幅阈值(dropRel)",
+    "minEligibleRequests": "最小可命中请求数",
+    "minEligibleTokens": "最小可命中 Tokens",
+    "topN": "Top N"
+  },
   "form": {
     "save": "保存设置",
     "saving": "保存中...",

+ 16 - 0
messages/zh-TW/settings/notifications.json

@@ -38,6 +38,22 @@
     "webhookTypeUnknown": "未知平台,請使用企業微信或飛書的 Webhook URL",
     "webhookTypeWeCom": "企業微信"
   },
+  "cacheHitRateAlert": {
+    "absMin": "絕對下限(absMin)",
+    "checkInterval": "檢查間隔(分鐘)",
+    "cooldownMinutes": "冷卻(分鐘)",
+    "description": "當快取命中率(provider × model)異常下降時發送告警",
+    "dropAbs": "絕對跌幅閾值(dropAbs)",
+    "dropRel": "相對跌幅閾值(dropRel)",
+    "enable": "啟用快取命中率告警",
+    "historicalLookbackDays": "歷史回看天數",
+    "minEligibleRequests": "最小可命中請求數",
+    "minEligibleTokens": "最小可命中 Token 數",
+    "title": "快取命中率異常告警",
+    "topN": "Top N",
+    "windowMode": "視窗",
+    "windowModeAuto": "自動"
+  },
   "dailyLeaderboard": {
     "description": "每天定時發送使用者消費 Top N 排行榜",
     "enable": "啟用每日排行榜",

+ 6 - 1
src/actions/notification-bindings.ts

@@ -13,7 +13,12 @@ import {
 } from "@/repository/notification-bindings";
 import type { ActionResult } from "./types";
 
-const NotificationTypeSchema = z.enum(["circuit_breaker", "daily_leaderboard", "cost_alert"]);
+const NotificationTypeSchema = z.enum([
+  "circuit_breaker",
+  "daily_leaderboard",
+  "cost_alert",
+  "cache_hit_rate_alert",
+]);
 
 const BindingInputSchema: z.ZodType<BindingInput> = z.object({
   targetId: z.number().int().positive(),

+ 56 - 1
src/actions/webhook-targets.ts

@@ -75,7 +75,12 @@ function validateProviderConfig(params: {
 }
 
 const ProviderTypeSchema = z.enum(["wechat", "feishu", "dingtalk", "telegram", "custom"]);
-const NotificationTypeSchema = z.enum(["circuit_breaker", "daily_leaderboard", "cost_alert"]);
+const NotificationTypeSchema = z.enum([
+  "circuit_breaker",
+  "daily_leaderboard",
+  "cost_alert",
+  "cache_hit_rate_alert",
+]);
 
 export type NotificationType = z.infer<typeof NotificationTypeSchema>;
 
@@ -241,6 +246,8 @@ function toJobType(type: NotificationType): NotificationJobType {
       return "daily-leaderboard";
     case "cost_alert":
       return "cost-alert";
+    case "cache_hit_rate_alert":
+      return "cache-hit-rate-alert";
   }
 }
 
@@ -274,6 +281,54 @@ function buildTestData(type: NotificationType): unknown {
         threshold: 0.8,
         period: "本月",
       };
+    case "cache_hit_rate_alert":
+      return {
+        window: {
+          mode: "5m",
+          startTime: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
+          endTime: new Date().toISOString(),
+          durationMinutes: 5,
+        },
+        anomalies: [
+          {
+            providerId: 1,
+            providerName: "测试供应商",
+            providerType: "claude",
+            model: "test-model",
+            baselineSource: "historical",
+            current: {
+              kind: "eligible",
+              requests: 100,
+              denominatorTokens: 10000,
+              hitRateTokens: 0.12,
+            },
+            baseline: {
+              kind: "eligible",
+              requests: 100,
+              denominatorTokens: 10000,
+              hitRateTokens: 0.45,
+            },
+            deltaAbs: -0.33,
+            deltaRel: -0.7333,
+            dropAbs: 0.33,
+            reasonCodes: ["abs_min", "drop_abs_rel"],
+          },
+        ],
+        suppressedCount: 0,
+        settings: {
+          windowMode: "auto",
+          checkIntervalMinutes: 5,
+          historicalLookbackDays: 7,
+          minEligibleRequests: 20,
+          minEligibleTokens: 0,
+          absMin: 0.05,
+          dropRel: 0.3,
+          dropAbs: 0.1,
+          cooldownMinutes: 30,
+          topN: 10,
+        },
+        generatedAt: new Date().toISOString(),
+      };
   }
 }
 

+ 343 - 17
src/app/[locale]/settings/notifications/_components/notification-type-card.tsx

@@ -1,12 +1,13 @@
 "use client";
 
-import { AlertTriangle, DollarSign, Settings2, TrendingUp } from "lucide-react";
+import { AlertTriangle, Database, DollarSign, Settings2, TrendingUp } from "lucide-react";
 import { useTranslations } from "next-intl";
-import type { ComponentProps } from "react";
-import { useMemo } from "react";
+import type { ComponentProps, ReactNode } from "react";
+import { useEffect, useMemo, useState } from "react";
 import { Badge } from "@/components/ui/badge";
 import { Switch } from "@/components/ui/switch";
 import { cn } from "@/lib/utils";
+import { isCacheHitRateAlertSettingsWindowMode } from "@/lib/webhook/types";
 import type {
   ClientActionResult,
   NotificationBindingState,
@@ -33,7 +34,7 @@ interface TypeConfig {
   iconColor: string;
   iconBgColor: string;
   borderColor: string;
-  IconComponent: typeof AlertTriangle | typeof TrendingUp | typeof DollarSign;
+  IconComponent: typeof AlertTriangle | typeof TrendingUp | typeof DollarSign | typeof Database;
 }
 
 function getTypeConfig(type: NotificationType): TypeConfig {
@@ -59,9 +60,117 @@ function getTypeConfig(type: NotificationType): TypeConfig {
         borderColor: "border-border/50 hover:border-border",
         IconComponent: DollarSign,
       };
+    case "cache_hit_rate_alert":
+      return {
+        iconColor: "text-blue-400",
+        iconBgColor: "bg-blue-500/10",
+        borderColor: "border-blue-500/20 hover:border-blue-500/30",
+        IconComponent: Database,
+      };
   }
 }
 
+const settingsControlClassName = cn(
+  "w-full bg-muted/50 border border-border rounded-lg py-2 px-3 text-sm text-foreground",
+  "focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all",
+  "disabled:opacity-50 disabled:cursor-not-allowed"
+);
+
+function LabeledControl({
+  id,
+  label,
+  children,
+}: {
+  id: string;
+  label: ReactNode;
+  children: ReactNode;
+}) {
+  return (
+    <div className="space-y-1.5">
+      <label htmlFor={id} className="text-xs font-medium text-muted-foreground">
+        {label}
+      </label>
+      {children}
+    </div>
+  );
+}
+
+type SafeNumberOnChange = NonNullable<ComponentProps<"input">["onChange"]>;
+
+type NumberInputConstraints = {
+  min?: number;
+  max?: number;
+  integer?: boolean;
+};
+
+function safeNumberOnChange(
+  onValidNumber: (value: number) => void,
+  constraints: NumberInputConstraints = {}
+): SafeNumberOnChange {
+  return (e) => {
+    const nextValue = e.currentTarget.valueAsNumber;
+    if (!Number.isFinite(nextValue)) return;
+    if (constraints.integer && !Number.isInteger(nextValue)) return;
+    if (constraints.min !== undefined && nextValue < constraints.min) return;
+    if (constraints.max !== undefined && nextValue > constraints.max) return;
+    onValidNumber(nextValue);
+  };
+}
+
+function createSettingsPatch<K extends keyof NotificationSettingsState>(
+  key: K,
+  value: NotificationSettingsState[K]
+): Pick<NotificationSettingsState, K> {
+  return { [key]: value } as Pick<NotificationSettingsState, K>;
+}
+
+/**
+ * Controlled number input that allows temporary empty state while editing.
+ *
+ * The standard pattern of `value={state}` + `onChange={guard}` on `<input type="number">`
+ * causes the input to "snap back" when the user clears it (backspace), because `valueAsNumber`
+ * is `NaN` and the guard rejects the update. This component uses local string state to allow
+ * the field to be cleared, then reverts to the last valid value on blur.
+ */
+function NumberInput({
+  value,
+  onValueChange,
+  constraints,
+  ...inputProps
+}: Omit<ComponentProps<"input">, "value" | "onChange" | "type"> & {
+  value: number;
+  onValueChange: (value: number) => void;
+  constraints?: NumberInputConstraints;
+}) {
+  const [localValue, setLocalValue] = useState(String(value));
+
+  useEffect(() => {
+    setLocalValue(String(value));
+  }, [value]);
+
+  return (
+    <input
+      {...inputProps}
+      type="number"
+      value={localValue}
+      onChange={(e) => {
+        const raw = e.currentTarget.value;
+        setLocalValue(raw);
+
+        const num = e.currentTarget.valueAsNumber;
+        if (!Number.isFinite(num)) return;
+        if (constraints?.integer && !Number.isInteger(num)) return;
+        if (constraints?.min !== undefined && num < constraints.min) return;
+        if (constraints?.max !== undefined && num > constraints.max) return;
+        onValueChange(num);
+      }}
+      onBlur={() => {
+        setLocalValue(String(value));
+      }}
+    />
+  );
+}
+
 export function NotificationTypeCard({
   type,
   settings,
@@ -73,7 +182,21 @@ export function NotificationTypeCard({
   const t = useTranslations("settings");
   const typeConfig = getTypeConfig(type);
 
-  const meta = useMemo(() => {
+  type EnabledKey =
+    | "circuitBreakerEnabled"
+    | "dailyLeaderboardEnabled"
+    | "costAlertEnabled"
+    | "cacheHitRateAlertEnabled";
+
+  type TypeMeta = {
+    title: string;
+    description: string;
+    enabled: boolean;
+    enabledKey: EnabledKey;
+    enableLabel: string;
+  };
+
+  const meta = useMemo<TypeMeta>(() => {
     switch (type) {
       case "circuit_breaker":
         return {
@@ -99,6 +222,14 @@ export function NotificationTypeCard({
           enabledKey: "costAlertEnabled" as const,
           enableLabel: t("notifications.costAlert.enable"),
         };
+      case "cache_hit_rate_alert":
+        return {
+          title: t("notifications.cacheHitRateAlert.title"),
+          description: t("notifications.cacheHitRateAlert.description"),
+          enabled: settings.cacheHitRateAlertEnabled,
+          enabledKey: "cacheHitRateAlertEnabled" as const,
+          enableLabel: t("notifications.cacheHitRateAlert.enable"),
+        };
     }
   }, [settings, t, type]);
 
@@ -156,7 +287,9 @@ export function NotificationTypeCard({
             id={`${type}-enabled`}
             checked={enabled}
             disabled={!settings.enabled}
-            onCheckedChange={(checked) => onUpdateSettings({ [meta.enabledKey]: checked } as any)}
+            onCheckedChange={(checked) =>
+              onUpdateSettings(createSettingsPatch(meta.enabledKey, checked))
+            }
           />
         </div>
       </div>
@@ -194,16 +327,14 @@ export function NotificationTypeCard({
                 >
                   {t("notifications.dailyLeaderboard.topN")}
                 </label>
-                <input
+                <NumberInput
                   id="dailyLeaderboardTopN"
-                  type="number"
                   min={1}
                   max={20}
                   value={settings.dailyLeaderboardTopN}
                   disabled={!settings.enabled}
-                  onChange={(e) =>
-                    onUpdateSettings({ dailyLeaderboardTopN: Number(e.target.value) })
-                  }
+                  onValueChange={(v) => onUpdateSettings({ dailyLeaderboardTopN: v })}
+                  constraints={{ integer: true, min: 1, max: 20 }}
                   className={cn(
                     "w-full bg-muted/50 border border-border rounded-lg py-2 px-3 text-sm text-foreground",
                     "focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all",
@@ -232,7 +363,10 @@ export function NotificationTypeCard({
                   step={0.05}
                   value={settings.costAlertThreshold}
                   disabled={!settings.enabled}
-                  onChange={(e) => onUpdateSettings({ costAlertThreshold: Number(e.target.value) })}
+                  onChange={safeNumberOnChange(
+                    (nextValue) => onUpdateSettings({ costAlertThreshold: nextValue }),
+                    { min: 0.5, max: 1.0 }
+                  )}
                   className="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed accent-primary"
                 />
               </div>
@@ -243,16 +377,14 @@ export function NotificationTypeCard({
                 >
                   {t("notifications.costAlert.interval")}
                 </label>
-                <input
+                <NumberInput
                   id="costAlertCheckInterval"
-                  type="number"
                   min={10}
                   max={1440}
                   value={settings.costAlertCheckInterval}
                   disabled={!settings.enabled}
-                  onChange={(e) =>
-                    onUpdateSettings({ costAlertCheckInterval: Number(e.target.value) })
-                  }
+                  onValueChange={(v) => onUpdateSettings({ costAlertCheckInterval: v })}
+                  constraints={{ integer: true, min: 10, max: 1440 }}
                   className={cn(
                     "w-full bg-muted/50 border border-border rounded-lg py-2 px-3 text-sm text-foreground",
                     "focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all",
@@ -263,6 +395,200 @@ export function NotificationTypeCard({
             </div>
           )}
 
+          {type === "cache_hit_rate_alert" && (
+            <div className="space-y-3">
+              <div className="grid gap-3 md:grid-cols-2">
+                <LabeledControl
+                  id="cacheHitRateAlertWindowMode"
+                  label={t("notifications.cacheHitRateAlert.windowMode")}
+                >
+                  <select
+                    id="cacheHitRateAlertWindowMode"
+                    value={settings.cacheHitRateAlertWindowMode}
+                    disabled={!settings.enabled}
+                    onChange={(e) => {
+                      const nextValue = e.target.value;
+                      if (!isCacheHitRateAlertSettingsWindowMode(nextValue)) return;
+                      onUpdateSettings({ cacheHitRateAlertWindowMode: nextValue });
+                    }}
+                    className={settingsControlClassName}
+                  >
+                    <option value="auto">
+                      {t("notifications.cacheHitRateAlert.windowModeAuto")}
+                    </option>
+                    <option value="5m">5m</option>
+                    <option value="30m">30m</option>
+                    <option value="1h">1h</option>
+                    <option value="1.5h">1.5h</option>
+                  </select>
+                </LabeledControl>
+
+                <LabeledControl
+                  id="cacheHitRateAlertCheckInterval"
+                  label={t("notifications.cacheHitRateAlert.checkInterval")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertCheckInterval"
+                    min={1}
+                    max={1440}
+                    value={settings.cacheHitRateAlertCheckInterval}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) => onUpdateSettings({ cacheHitRateAlertCheckInterval: v })}
+                    constraints={{ integer: true, min: 1, max: 1440 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+
+                <LabeledControl
+                  id="cacheHitRateAlertHistoricalLookbackDays"
+                  label={t("notifications.cacheHitRateAlert.historicalLookbackDays")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertHistoricalLookbackDays"
+                    min={1}
+                    max={90}
+                    value={settings.cacheHitRateAlertHistoricalLookbackDays}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) =>
+                      onUpdateSettings({
+                        cacheHitRateAlertHistoricalLookbackDays: v,
+                      })
+                    }
+                    constraints={{ integer: true, min: 1, max: 90 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+
+                <LabeledControl
+                  id="cacheHitRateAlertCooldownMinutes"
+                  label={t("notifications.cacheHitRateAlert.cooldownMinutes")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertCooldownMinutes"
+                    min={0}
+                    max={1440}
+                    value={settings.cacheHitRateAlertCooldownMinutes}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) => onUpdateSettings({ cacheHitRateAlertCooldownMinutes: v })}
+                    constraints={{ integer: true, min: 0, max: 1440 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+              </div>
+
+              <div className="grid gap-3 md:grid-cols-3">
+                <LabeledControl
+                  id="cacheHitRateAlertAbsMin"
+                  label={t("notifications.cacheHitRateAlert.absMin")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertAbsMin"
+                    min={0}
+                    max={1}
+                    step={0.01}
+                    value={settings.cacheHitRateAlertAbsMin}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) => onUpdateSettings({ cacheHitRateAlertAbsMin: v })}
+                    constraints={{ min: 0, max: 1 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+
+                <LabeledControl
+                  id="cacheHitRateAlertDropAbs"
+                  label={t("notifications.cacheHitRateAlert.dropAbs")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertDropAbs"
+                    min={0}
+                    max={1}
+                    step={0.01}
+                    value={settings.cacheHitRateAlertDropAbs}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) => onUpdateSettings({ cacheHitRateAlertDropAbs: v })}
+                    constraints={{ min: 0, max: 1 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+
+                <LabeledControl
+                  id="cacheHitRateAlertDropRel"
+                  label={t("notifications.cacheHitRateAlert.dropRel")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertDropRel"
+                    min={0}
+                    max={1}
+                    step={0.01}
+                    value={settings.cacheHitRateAlertDropRel}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) => onUpdateSettings({ cacheHitRateAlertDropRel: v })}
+                    constraints={{ min: 0, max: 1 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+              </div>
+
+              <div className="grid gap-3 md:grid-cols-3">
+                <LabeledControl
+                  id="cacheHitRateAlertMinEligibleRequests"
+                  label={t("notifications.cacheHitRateAlert.minEligibleRequests")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertMinEligibleRequests"
+                    min={1}
+                    max={100000}
+                    value={settings.cacheHitRateAlertMinEligibleRequests}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) =>
+                      onUpdateSettings({
+                        cacheHitRateAlertMinEligibleRequests: v,
+                      })
+                    }
+                    constraints={{ integer: true, min: 1, max: 100000 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+
+                <LabeledControl
+                  id="cacheHitRateAlertMinEligibleTokens"
+                  label={t("notifications.cacheHitRateAlert.minEligibleTokens")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertMinEligibleTokens"
+                    min={0}
+                    max={2147483647}
+                    value={settings.cacheHitRateAlertMinEligibleTokens}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) =>
+                      onUpdateSettings({
+                        cacheHitRateAlertMinEligibleTokens: v,
+                      })
+                    }
+                    constraints={{ integer: true, min: 0, max: 2147483647 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+
+                <LabeledControl
+                  id="cacheHitRateAlertTopN"
+                  label={t("notifications.cacheHitRateAlert.topN")}
+                >
+                  <NumberInput
+                    id="cacheHitRateAlertTopN"
+                    min={1}
+                    max={100}
+                    value={settings.cacheHitRateAlertTopN}
+                    disabled={!settings.enabled}
+                    onValueChange={(v) => onUpdateSettings({ cacheHitRateAlertTopN: v })}
+                    constraints={{ integer: true, min: 1, max: 100 }}
+                    className={settingsControlClassName}
+                  />
+                </LabeledControl>
+              </div>
+            </div>
+          )}
+
           {/* Bindings */}
           <div className="space-y-2">
             <div className="flex items-center gap-2">

+ 1 - 1
src/app/[locale]/settings/notifications/_components/notifications-skeleton.tsx

@@ -61,7 +61,7 @@ export function NotificationsSkeleton() {
 
       {/* Notification type cards skeleton */}
       <div className="grid gap-6">
-        {[1, 2, 3].map((i) => (
+        {[1, 2, 3, 4].map((i) => (
           <div
             key={i}
             className="rounded-xl border border-white/5 bg-card/30 p-5 md:p-6 backdrop-blur-sm"

+ 31 - 7
src/app/[locale]/settings/notifications/_components/template-editor.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { Braces, Info } from "lucide-react";
+import { Braces, Info, RotateCcw } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useMemo, useRef } from "react";
 import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -8,13 +8,17 @@ import { Button } from "@/components/ui/button";
 import { Label } from "@/components/ui/label";
 import { Textarea } from "@/components/ui/textarea";
 import { cn } from "@/lib/utils";
+import {
+  DEFAULT_TEMPLATE_BY_NOTIFICATION_TYPE,
+  DEFAULT_TEMPLATES,
+} from "@/lib/webhook/templates/defaults";
 import { getTemplatePlaceholders } from "@/lib/webhook/templates/placeholders";
-import type { NotificationType } from "../_lib/schemas";
+import type { WebhookNotificationType } from "@/lib/webhook/types";
 
 interface TemplateEditorProps {
   value: string;
   onChange: (value: string) => void;
-  notificationType?: NotificationType;
+  notificationType?: WebhookNotificationType;
   className?: string;
 }
 
@@ -27,6 +31,15 @@ export function TemplateEditor({
   const t = useTranslations("settings");
   const textareaRef = useRef<HTMLTextAreaElement | null>(null);
 
+  const defaultTemplate = useMemo(() => {
+    if (!notificationType) {
+      return DEFAULT_TEMPLATES.custom_generic;
+    }
+    return (
+      DEFAULT_TEMPLATE_BY_NOTIFICATION_TYPE[notificationType] ?? DEFAULT_TEMPLATES.custom_generic
+    );
+  }, [notificationType]);
+
   const placeholders = useMemo(() => {
     return getTemplatePlaceholders(notificationType);
   }, [notificationType]);
@@ -65,10 +78,21 @@ export function TemplateEditor({
   return (
     <div className={cn("grid gap-4 md:grid-cols-2", className)}>
       <div className="space-y-2">
-        <Label className="flex items-center gap-2">
-          <Braces className="h-4 w-4" />
-          {t("notifications.templateEditor.title")}
-        </Label>
+        <div className="flex items-center justify-between gap-2">
+          <Label className="flex items-center gap-2">
+            <Braces className="h-4 w-4" />
+            {t("notifications.templateEditor.title")}
+          </Label>
+          <Button
+            type="button"
+            variant="secondary"
+            size="sm"
+            onClick={() => onChange(JSON.stringify(defaultTemplate, null, 2))}
+          >
+            <RotateCcw className="mr-2 h-4 w-4" />
+            {t("common.reset")}
+          </Button>
+        </div>
         <Textarea
           ref={textareaRef}
           value={value}

+ 16 - 1
src/app/[locale]/settings/notifications/_components/test-webhook-button.tsx

@@ -14,6 +14,17 @@ import {
 import { cn } from "@/lib/utils";
 import type { NotificationType } from "../_lib/schemas";
 
+const TEST_NOTIFICATION_TYPES = [
+  "circuit_breaker",
+  "daily_leaderboard",
+  "cost_alert",
+  "cache_hit_rate_alert",
+] as const satisfies readonly NotificationType[];
+
+function isNotificationType(value: string): value is NotificationType {
+  return (TEST_NOTIFICATION_TYPES as readonly string[]).includes(value);
+}
+
 interface TestWebhookButtonProps {
   targetId: number;
   disabled?: boolean;
@@ -30,6 +41,7 @@ export function TestWebhookButton({ targetId, disabled, onTest }: TestWebhookBut
       { value: "circuit_breaker" as const, label: t("notifications.circuitBreaker.title") },
       { value: "daily_leaderboard" as const, label: t("notifications.dailyLeaderboard.title") },
       { value: "cost_alert" as const, label: t("notifications.costAlert.title") },
+      { value: "cache_hit_rate_alert" as const, label: t("notifications.cacheHitRateAlert.title") },
     ],
     [t]
   );
@@ -47,7 +59,10 @@ export function TestWebhookButton({ targetId, disabled, onTest }: TestWebhookBut
     <div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
       <Select
         value={type}
-        onValueChange={(v) => setType(v as NotificationType)}
+        onValueChange={(v) => {
+          if (!isNotificationType(v)) return;
+          setType(v);
+        }}
         disabled={disabled || isTesting}
       >
         <SelectTrigger

+ 172 - 8
src/app/[locale]/settings/notifications/_lib/hooks.ts

@@ -13,6 +13,10 @@ import {
   testWebhookTargetAction,
   updateWebhookTargetAction,
 } from "@/actions/webhook-targets";
+import {
+  type CacheHitRateAlertSettingsWindowMode,
+  isCacheHitRateAlertSettingsWindowMode,
+} from "@/lib/webhook/types";
 import type { NotificationType, WebhookProviderType } from "./schemas";
 
 export interface ClientActionResult<T> {
@@ -35,6 +39,18 @@ export interface NotificationSettingsState {
   costAlertWebhook: string;
   costAlertThreshold: number;
   costAlertCheckInterval: number;
+
+  cacheHitRateAlertEnabled: boolean;
+  cacheHitRateAlertWindowMode: CacheHitRateAlertSettingsWindowMode;
+  cacheHitRateAlertCheckInterval: number;
+  cacheHitRateAlertHistoricalLookbackDays: number;
+  cacheHitRateAlertMinEligibleRequests: number;
+  cacheHitRateAlertMinEligibleTokens: number;
+  cacheHitRateAlertAbsMin: number;
+  cacheHitRateAlertDropRel: number;
+  cacheHitRateAlertDropAbs: number;
+  cacheHitRateAlertCooldownMinutes: number;
+  cacheHitRateAlertTopN: number;
 }
 
 export interface WebhookTestResult {
@@ -94,9 +110,55 @@ export const NOTIFICATION_TYPES: NotificationType[] = [
   "circuit_breaker",
   "daily_leaderboard",
   "cost_alert",
+  "cache_hit_rate_alert",
 ];
 
+const INT32_MAX = 2_147_483_647;
+
+function toFiniteNumber(value: unknown): number | null {
+  if (value === null || value === undefined) return null;
+  if (typeof value === "string" && value.trim() === "") return null;
+  const n = typeof value === "number" ? value : Number(value);
+  return Number.isFinite(n) ? n : null;
+}
+
+function clampNumber(value: number, min: number, max: number): number {
+  return Math.min(max, Math.max(min, value));
+}
+
+function toBoundedInt(value: unknown, fallback: number, min: number, max: number): number {
+  const n = toFiniteNumber(value);
+  if (n == null) return fallback;
+  const intValue = Math.trunc(n);
+  return clampNumber(intValue, min, max);
+}
+
+function toBoundedFloat(value: unknown, fallback: number, min: number, max: number): number {
+  const n = toFiniteNumber(value);
+  if (n == null) return fallback;
+  return clampNumber(n, min, max);
+}
+
+function normalizeIntPatch(value: number, min: number, max: number): number | undefined {
+  if (!Number.isFinite(value)) return undefined;
+  if (!Number.isInteger(value)) return undefined;
+  if (value < min || value > max) return undefined;
+  return value;
+}
+
+function normalizeFloatPatch(value: number, min: number, max: number): number | undefined {
+  if (!Number.isFinite(value)) return undefined;
+  if (value < min || value > max) return undefined;
+  return value;
+}
+
 function toClientSettings(raw: any): NotificationSettingsState {
+  const cacheHitRateAlertWindowMode = isCacheHitRateAlertSettingsWindowMode(
+    raw?.cacheHitRateAlertWindowMode
+  )
+    ? raw.cacheHitRateAlertWindowMode
+    : "auto";
+
   return {
     enabled: Boolean(raw?.enabled),
     circuitBreakerEnabled: Boolean(raw?.circuitBreakerEnabled),
@@ -104,11 +166,42 @@ function toClientSettings(raw: any): NotificationSettingsState {
     dailyLeaderboardEnabled: Boolean(raw?.dailyLeaderboardEnabled),
     dailyLeaderboardWebhook: raw?.dailyLeaderboardWebhook || "",
     dailyLeaderboardTime: raw?.dailyLeaderboardTime || "09:00",
-    dailyLeaderboardTopN: Number(raw?.dailyLeaderboardTopN || 5),
+    dailyLeaderboardTopN: toBoundedInt(raw?.dailyLeaderboardTopN, 5, 1, 20),
     costAlertEnabled: Boolean(raw?.costAlertEnabled),
     costAlertWebhook: raw?.costAlertWebhook || "",
-    costAlertThreshold: parseFloat(raw?.costAlertThreshold || "0.80"),
-    costAlertCheckInterval: Number(raw?.costAlertCheckInterval || 60),
+    costAlertThreshold: toBoundedFloat(raw?.costAlertThreshold, 0.8, 0.5, 1.0),
+    costAlertCheckInterval: toBoundedInt(raw?.costAlertCheckInterval, 60, 10, 1440),
+    cacheHitRateAlertEnabled: Boolean(raw?.cacheHitRateAlertEnabled),
+    cacheHitRateAlertWindowMode,
+    cacheHitRateAlertCheckInterval: toBoundedInt(raw?.cacheHitRateAlertCheckInterval, 5, 1, 1440),
+    cacheHitRateAlertHistoricalLookbackDays: toBoundedInt(
+      raw?.cacheHitRateAlertHistoricalLookbackDays,
+      7,
+      1,
+      90
+    ),
+    cacheHitRateAlertMinEligibleRequests: toBoundedInt(
+      raw?.cacheHitRateAlertMinEligibleRequests,
+      20,
+      1,
+      100000
+    ),
+    cacheHitRateAlertMinEligibleTokens: toBoundedInt(
+      raw?.cacheHitRateAlertMinEligibleTokens,
+      0,
+      0,
+      INT32_MAX
+    ),
+    cacheHitRateAlertAbsMin: toBoundedFloat(raw?.cacheHitRateAlertAbsMin, 0.05, 0, 1),
+    cacheHitRateAlertDropRel: toBoundedFloat(raw?.cacheHitRateAlertDropRel, 0.3, 0, 1),
+    cacheHitRateAlertDropAbs: toBoundedFloat(raw?.cacheHitRateAlertDropAbs, 0.1, 0, 1),
+    cacheHitRateAlertCooldownMinutes: toBoundedInt(
+      raw?.cacheHitRateAlertCooldownMinutes,
+      30,
+      0,
+      1440
+    ),
+    cacheHitRateAlertTopN: toBoundedInt(raw?.cacheHitRateAlertTopN, 10, 1, 100),
   };
 }
 
@@ -121,6 +214,7 @@ export function useNotificationsPageData() {
     circuit_breaker: [],
     daily_leaderboard: [],
     cost_alert: [],
+    cache_hit_rate_alert: [],
   }));
 
   const [isLoading, setIsLoading] = useState(true);
@@ -136,7 +230,7 @@ export function useNotificationsPageData() {
     if (!result.ok) {
       throw new Error(result.error || "LOAD_TARGETS_FAILED");
     }
-    setTargets(result.data as WebhookTargetState[]);
+    setTargets(result.data);
   }, []);
 
   const refreshBindingsForType = useCallback(async (type: NotificationType) => {
@@ -144,7 +238,7 @@ export function useNotificationsPageData() {
     if (!result.ok) {
       throw new Error(result.error || "LOAD_BINDINGS_FAILED");
     }
-    setBindingsByType((prev) => ({ ...prev, [type]: result.data as NotificationBindingState[] }));
+    setBindingsByType((prev) => ({ ...prev, [type]: result.data }));
   }, []);
 
   const refreshAll = useCallback(async () => {
@@ -198,7 +292,10 @@ export function useNotificationsPageData() {
         payload.dailyLeaderboardTime = patch.dailyLeaderboardTime;
       }
       if (patch.dailyLeaderboardTopN !== undefined) {
-        payload.dailyLeaderboardTopN = patch.dailyLeaderboardTopN;
+        const nextValue = normalizeIntPatch(patch.dailyLeaderboardTopN, 1, 20);
+        if (nextValue !== undefined) {
+          payload.dailyLeaderboardTopN = nextValue;
+        }
       }
 
       if (patch.costAlertEnabled !== undefined) {
@@ -210,10 +307,77 @@ export function useNotificationsPageData() {
           : null;
       }
       if (patch.costAlertThreshold !== undefined) {
-        payload.costAlertThreshold = patch.costAlertThreshold.toString();
+        const nextValue = normalizeFloatPatch(patch.costAlertThreshold, 0.5, 1.0);
+        if (nextValue !== undefined) {
+          payload.costAlertThreshold = nextValue.toFixed(2);
+        }
       }
       if (patch.costAlertCheckInterval !== undefined) {
-        payload.costAlertCheckInterval = patch.costAlertCheckInterval;
+        const nextValue = normalizeIntPatch(patch.costAlertCheckInterval, 10, 1440);
+        if (nextValue !== undefined) {
+          payload.costAlertCheckInterval = nextValue;
+        }
+      }
+
+      if (patch.cacheHitRateAlertEnabled !== undefined) {
+        payload.cacheHitRateAlertEnabled = patch.cacheHitRateAlertEnabled;
+      }
+      if (patch.cacheHitRateAlertWindowMode !== undefined) {
+        payload.cacheHitRateAlertWindowMode = patch.cacheHitRateAlertWindowMode;
+      }
+      if (patch.cacheHitRateAlertCheckInterval !== undefined) {
+        const nextValue = normalizeIntPatch(patch.cacheHitRateAlertCheckInterval, 1, 1440);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertCheckInterval = nextValue;
+        }
+      }
+      if (patch.cacheHitRateAlertHistoricalLookbackDays !== undefined) {
+        const nextValue = normalizeIntPatch(patch.cacheHitRateAlertHistoricalLookbackDays, 1, 90);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertHistoricalLookbackDays = nextValue;
+        }
+      }
+      if (patch.cacheHitRateAlertMinEligibleRequests !== undefined) {
+        const nextValue = normalizeIntPatch(patch.cacheHitRateAlertMinEligibleRequests, 1, 100000);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertMinEligibleRequests = nextValue;
+        }
+      }
+      if (patch.cacheHitRateAlertMinEligibleTokens !== undefined) {
+        const nextValue = normalizeIntPatch(patch.cacheHitRateAlertMinEligibleTokens, 0, INT32_MAX);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertMinEligibleTokens = nextValue;
+        }
+      }
+      if (patch.cacheHitRateAlertAbsMin !== undefined) {
+        const nextValue = normalizeFloatPatch(patch.cacheHitRateAlertAbsMin, 0, 1);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertAbsMin = nextValue.toFixed(4);
+        }
+      }
+      if (patch.cacheHitRateAlertDropRel !== undefined) {
+        const nextValue = normalizeFloatPatch(patch.cacheHitRateAlertDropRel, 0, 1);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertDropRel = nextValue.toFixed(4);
+        }
+      }
+      if (patch.cacheHitRateAlertDropAbs !== undefined) {
+        const nextValue = normalizeFloatPatch(patch.cacheHitRateAlertDropAbs, 0, 1);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertDropAbs = nextValue.toFixed(4);
+        }
+      }
+      if (patch.cacheHitRateAlertCooldownMinutes !== undefined) {
+        const nextValue = normalizeIntPatch(patch.cacheHitRateAlertCooldownMinutes, 0, 1440);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertCooldownMinutes = nextValue;
+        }
+      }
+      if (patch.cacheHitRateAlertTopN !== undefined) {
+        const nextValue = normalizeIntPatch(patch.cacheHitRateAlertTopN, 1, 100);
+        if (nextValue !== undefined) {
+          payload.cacheHitRateAlertTopN = nextValue;
+        }
       }
 
       const result = await updateNotificationSettingsAction(payload);

+ 1 - 0
src/app/[locale]/settings/notifications/_lib/schemas.ts

@@ -6,6 +6,7 @@ export const NotificationTypeSchema = z.enum([
   "circuit_breaker",
   "daily_leaderboard",
   "cost_alert",
+  "cache_hit_rate_alert",
 ]);
 export type NotificationType = z.infer<typeof NotificationTypeSchema>;
 

+ 86 - 6
src/app/api/actions/[...route]/route.ts

@@ -1308,6 +1308,16 @@ const { route: getNotificationSettingsRoute, handler: getNotificationSettingsHan
   );
 app.openapi(getNotificationSettingsRoute, getNotificationSettingsHandler);
 
+const RatioStringSchema = z
+  .string()
+  .trim()
+  .min(1)
+  .regex(/^\d+(?:\.\d+)?$/)
+  .refine((value) => {
+    const n = Number(value);
+    return Number.isFinite(n) && n >= 0 && n <= 1;
+  });
+
 const { route: updateNotificationSettingsRoute, handler: updateNotificationSettingsHandler } =
   createActionRoute(
     "notifications",
@@ -1334,7 +1344,13 @@ const { route: updateNotificationSettingsRoute, handler: updateNotificationSetti
           .optional()
           .describe("每日排行榜 Webhook URL(旧版模式)"),
         dailyLeaderboardTime: z.string().optional().describe("每日排行榜发送时间(HH:mm)"),
-        dailyLeaderboardTopN: z.number().int().positive().optional().describe("每日排行榜 TopN"),
+        dailyLeaderboardTopN: z
+          .number()
+          .int()
+          .min(1)
+          .max(20)
+          .optional()
+          .describe("每日排行榜 TopN"),
 
         costAlertEnabled: z.boolean().optional().describe("是否启用成本预警"),
         costAlertWebhook: z
@@ -1343,16 +1359,79 @@ const { route: updateNotificationSettingsRoute, handler: updateNotificationSetti
           .nullable()
           .optional()
           .describe("成本预警 Webhook URL(旧版模式)"),
-        costAlertThreshold: z
-          .string()
-          .optional()
-          .describe("成本预警阈值(numeric 字段以 string 表示)"),
+        costAlertThreshold: RatioStringSchema.optional().describe(
+          "成本预警阈值(numeric 字段以 string 表示)"
+        ),
         costAlertCheckInterval: z
           .number()
           .int()
-          .positive()
+          .min(10)
+          .max(1440)
           .optional()
           .describe("成本预警检查间隔(分钟)"),
+
+        cacheHitRateAlertEnabled: z.boolean().optional().describe("是否启用缓存命中率异常告警"),
+        cacheHitRateAlertWebhook: z
+          .string()
+          .url()
+          .nullable()
+          .optional()
+          .describe("缓存命中率异常告警 Webhook URL(旧版模式)"),
+        cacheHitRateAlertWindowMode: z
+          .enum(["auto", "5m", "30m", "1h", "1.5h"])
+          .optional()
+          .describe("检测窗口模式(auto/5m/30m/1h/1.5h)"),
+        cacheHitRateAlertCheckInterval: z
+          .number()
+          .int()
+          .min(1)
+          .max(1440)
+          .optional()
+          .describe("缓存命中率告警检查间隔(分钟)"),
+        cacheHitRateAlertHistoricalLookbackDays: z
+          .number()
+          .int()
+          .min(1)
+          .max(90)
+          .optional()
+          .describe("历史基线回看天数"),
+        cacheHitRateAlertMinEligibleRequests: z
+          .number()
+          .int()
+          .min(1)
+          .max(100000)
+          .optional()
+          .describe("最小 eligible 请求数门槛"),
+        cacheHitRateAlertMinEligibleTokens: z
+          .number()
+          .int()
+          .min(0)
+          .max(2_147_483_647)
+          .optional()
+          .describe("最小 eligible tokens 门槛"),
+        cacheHitRateAlertAbsMin: RatioStringSchema.optional().describe(
+          "absMin(numeric 字段以 string 表示)"
+        ),
+        cacheHitRateAlertDropRel: RatioStringSchema.optional().describe(
+          "dropRel(numeric 字段以 string 表示)"
+        ),
+        cacheHitRateAlertDropAbs: RatioStringSchema.optional().describe(
+          "dropAbs(numeric 字段以 string 表示)"
+        ),
+        cacheHitRateAlertCooldownMinutes: z
+          .number()
+          .int()
+          .min(0)
+          .max(1440)
+          .optional()
+          .describe("冷却时间(分钟)"),
+        cacheHitRateAlertTopN: z
+          .number()
+          .int()
+          .min(1)
+          .max(100)
+          .optional()
+          .describe("TopN(最多返回/推送条数)"),
       }),
       summary: "更新通知设置",
       description: "更新通知开关与各类型通知配置(生产环境会触发重新调度定时任务)",
@@ -1390,6 +1469,7 @@ const WebhookNotificationTypeSchema = z.enum([
   "circuit_breaker",
   "daily_leaderboard",
   "cost_alert",
+  "cache_hit_rate_alert",
 ]);
 
 const WebhookTargetSchema = z.object({

+ 15 - 0
src/drizzle/schema.ts

@@ -31,6 +31,7 @@ export const notificationTypeEnum = pgEnum('notification_type', [
   'circuit_breaker',
   'daily_leaderboard',
   'cost_alert',
+  'cache_hit_rate_alert',
 ]);
 
 // Users table
@@ -773,6 +774,20 @@ export const notificationSettings = pgTable('notification_settings', {
   costAlertThreshold: numeric('cost_alert_threshold', { precision: 5, scale: 2 }).default('0.80'), // 阈值 0-1 (80% = 0.80)
   costAlertCheckInterval: integer('cost_alert_check_interval').default(60), // 检查间隔(分钟)
 
+  // 缓存命中率异常告警配置(provider × model)
+  cacheHitRateAlertEnabled: boolean('cache_hit_rate_alert_enabled').notNull().default(false),
+  cacheHitRateAlertWebhook: varchar('cache_hit_rate_alert_webhook', { length: 512 }),
+  cacheHitRateAlertWindowMode: varchar('cache_hit_rate_alert_window_mode', { length: 10 }).default('auto'),
+  cacheHitRateAlertCheckInterval: integer('cache_hit_rate_alert_check_interval').default(5), // 检查间隔(分钟)
+  cacheHitRateAlertHistoricalLookbackDays: integer('cache_hit_rate_alert_historical_lookback_days').default(7),
+  cacheHitRateAlertMinEligibleRequests: integer('cache_hit_rate_alert_min_eligible_requests').default(20),
+  cacheHitRateAlertMinEligibleTokens: integer('cache_hit_rate_alert_min_eligible_tokens').default(0),
+  cacheHitRateAlertAbsMin: numeric('cache_hit_rate_alert_abs_min', { precision: 5, scale: 4 }).default('0.05'),
+  cacheHitRateAlertDropRel: numeric('cache_hit_rate_alert_drop_rel', { precision: 5, scale: 4 }).default('0.3'),
+  cacheHitRateAlertDropAbs: numeric('cache_hit_rate_alert_drop_abs', { precision: 5, scale: 4 }).default('0.1'),
+  cacheHitRateAlertCooldownMinutes: integer('cache_hit_rate_alert_cooldown_minutes').default(30),
+  cacheHitRateAlertTopN: integer('cache_hit_rate_alert_top_n').default(10),
+
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
 });

+ 321 - 0
src/lib/cache-hit-rate-alert/decision.ts

@@ -0,0 +1,321 @@
+export interface CacheHitRateAlertMetric {
+  providerId: number;
+  model: string;
+
+  totalRequests: number;
+  denominatorTokens: number;
+  hitRateTokens: number;
+
+  eligibleRequests: number;
+  eligibleDenominatorTokens: number;
+  hitRateTokensEligible: number;
+}
+
+export type CacheHitRateAlertMetricKey = string;
+
+export type CacheHitRateAlertMetricCollection =
+  | ReadonlyArray<CacheHitRateAlertMetric>
+  | ReadonlyMap<CacheHitRateAlertMetricKey, CacheHitRateAlertMetric>;
+
+export interface CacheHitRateAlertDecisionSettings {
+  /** 当前 hitRate 绝对值低于该阈值直接告警 */
+  absMin: number;
+  /** 相对跌幅阈值:dropAbs / baseline >= dropRel */
+  dropRel: number;
+  /** 绝对跌幅阈值:baseline - current >= dropAbs */
+  dropAbs: number;
+  /** eligible 口径不足时可 fallback overall;不足则不参与告警 */
+  minEligibleRequests: number;
+  minEligibleTokens: number;
+  /** 返回的告警条数上限 */
+  topN: number;
+}
+
+export type CacheHitRateAlertBaselineSource = "historical" | "today" | "prev" | null;
+export type CacheHitRateAlertMetricKind = "eligible" | "overall";
+
+export interface CacheHitRateAlertSample {
+  kind: CacheHitRateAlertMetricKind;
+  requests: number;
+  denominatorTokens: number;
+  hitRateTokens: number;
+}
+
+export interface CacheHitRateAlertAnomaly {
+  key: CacheHitRateAlertMetricKey;
+  providerId: number;
+  model: string;
+
+  baselineSource: CacheHitRateAlertBaselineSource;
+  current: CacheHitRateAlertSample;
+  baseline: CacheHitRateAlertSample | null;
+
+  deltaAbs: number | null;
+  deltaRel: number | null;
+  dropAbs: number | null;
+
+  reasonCodes: string[];
+}
+
+export interface CacheHitRateAlertDecisionInput {
+  current: CacheHitRateAlertMetricCollection;
+  prev: CacheHitRateAlertMetricCollection;
+  today: CacheHitRateAlertMetricCollection;
+  historical: CacheHitRateAlertMetricCollection;
+  settings: CacheHitRateAlertDecisionSettings;
+}
+
+function clampRate01(value: number): number {
+  if (!Number.isFinite(value)) return 0;
+  return Math.min(Math.max(value, 0), 1);
+}
+
+export function toCacheHitRateAlertMetricKey(providerId: number, model: string): string {
+  return `${providerId}:${model}`;
+}
+
+function isMetricArray(
+  input: CacheHitRateAlertMetricCollection
+): input is ReadonlyArray<CacheHitRateAlertMetric> {
+  return Array.isArray(input);
+}
+
+function toMetricMap(
+  input: CacheHitRateAlertMetricCollection
+): Map<string, CacheHitRateAlertMetric> {
+  if (isMetricArray(input)) {
+    const map = new Map<string, CacheHitRateAlertMetric>();
+    for (const item of input) {
+      if (!item) continue;
+      if (!item.model || item.model.trim() === "") continue;
+      map.set(toCacheHitRateAlertMetricKey(item.providerId, item.model), item);
+    }
+    return map;
+  }
+
+  const map = new Map<string, CacheHitRateAlertMetric>();
+  for (const [, item] of input) {
+    if (!item) continue;
+    if (!item.model || item.model.trim() === "") continue;
+    map.set(toCacheHitRateAlertMetricKey(item.providerId, item.model), item);
+  }
+  return map;
+}
+
+function pickSample(
+  metric: CacheHitRateAlertMetric,
+  settings: CacheHitRateAlertDecisionSettings
+): { sample: CacheHitRateAlertSample; reasonCodes: string[] } | null {
+  const eligibleOk =
+    metric.eligibleRequests >= settings.minEligibleRequests &&
+    metric.eligibleDenominatorTokens >= settings.minEligibleTokens;
+  if (eligibleOk) {
+    return {
+      sample: {
+        kind: "eligible",
+        requests: metric.eligibleRequests,
+        denominatorTokens: metric.eligibleDenominatorTokens,
+        hitRateTokens: clampRate01(metric.hitRateTokensEligible),
+      },
+      reasonCodes: ["use_eligible"],
+    };
+  }
+
+  const overallOk =
+    metric.totalRequests >= settings.minEligibleRequests &&
+    metric.denominatorTokens >= settings.minEligibleTokens;
+  if (!overallOk) {
+    return null;
+  }
+
+  return {
+    sample: {
+      kind: "overall",
+      requests: metric.totalRequests,
+      denominatorTokens: metric.denominatorTokens,
+      hitRateTokens: clampRate01(metric.hitRateTokens),
+    },
+    reasonCodes: ["use_overall", "eligible_insufficient"],
+  };
+}
+
+function pickBaseline(
+  kind: CacheHitRateAlertMetricKind,
+  key: string,
+  maps: Array<{
+    source: Exclude<CacheHitRateAlertBaselineSource, null>;
+    map: Map<string, CacheHitRateAlertMetric>;
+  }>,
+  settings: CacheHitRateAlertDecisionSettings
+): {
+  source: CacheHitRateAlertBaselineSource;
+  sample: CacheHitRateAlertSample;
+  reasonCodes: string[];
+} | null {
+  const baselineOk =
+    kind === "eligible"
+      ? (metric: CacheHitRateAlertMetric) =>
+          metric.eligibleRequests >= settings.minEligibleRequests &&
+          metric.eligibleDenominatorTokens >= settings.minEligibleTokens
+      : (metric: CacheHitRateAlertMetric) =>
+          metric.totalRequests >= settings.minEligibleRequests &&
+          metric.denominatorTokens >= settings.minEligibleTokens;
+
+  for (const { source, map } of maps) {
+    const metric = map.get(key);
+    if (!metric) continue;
+    if (!baselineOk(metric)) continue;
+
+    const sample: CacheHitRateAlertSample =
+      kind === "eligible"
+        ? {
+            kind: "eligible",
+            requests: metric.eligibleRequests,
+            denominatorTokens: metric.eligibleDenominatorTokens,
+            hitRateTokens: clampRate01(metric.hitRateTokensEligible),
+          }
+        : {
+            kind: "overall",
+            requests: metric.totalRequests,
+            denominatorTokens: metric.denominatorTokens,
+            hitRateTokens: clampRate01(metric.hitRateTokens),
+          };
+
+    const baselineKindCode = kind === "eligible" ? "baseline_eligible" : "baseline_overall";
+    return {
+      source,
+      sample,
+      reasonCodes: [baselineKindCode, `baseline_${source}`],
+    };
+  }
+  return null;
+}
+
+export function decideCacheHitRateAnomalies(
+  input: CacheHitRateAlertDecisionInput
+): CacheHitRateAlertAnomaly[] {
+  const settings = input.settings;
+  if (settings.topN <= 0) return [];
+
+  const currentMap = toMetricMap(input.current);
+  const prevMap = toMetricMap(input.prev);
+  const todayMap = toMetricMap(input.today);
+  const historicalMap = toMetricMap(input.historical);
+
+  const baselineCandidates: Array<{
+    source: Exclude<CacheHitRateAlertBaselineSource, null>;
+    map: Map<string, CacheHitRateAlertMetric>;
+  }> = [
+    { source: "historical", map: historicalMap },
+    { source: "today", map: todayMap },
+    { source: "prev", map: prevMap },
+  ];
+
+  const anomaliesWithSeverity: Array<{ anomaly: CacheHitRateAlertAnomaly; severity: number }> = [];
+
+  for (const [key, currentMetric] of currentMap) {
+    const currentPicked = pickSample(currentMetric, settings);
+    if (!currentPicked) {
+      continue;
+    }
+
+    const currentValue = currentPicked.sample.hitRateTokens;
+    const absMinTriggered = currentValue < settings.absMin;
+
+    const baselinePicked = pickBaseline(
+      currentPicked.sample.kind,
+      key,
+      baselineCandidates,
+      settings
+    );
+    if (!baselinePicked) {
+      if (!absMinTriggered) {
+        continue;
+      }
+
+      const reasonCodes: string[] = [...currentPicked.reasonCodes];
+      reasonCodes.push("baseline_missing", "abs_min");
+
+      anomaliesWithSeverity.push({
+        severity: Math.max(settings.absMin - currentValue, 0),
+        anomaly: {
+          key,
+          providerId: currentMetric.providerId,
+          model: currentMetric.model,
+          baselineSource: null,
+          current: currentPicked.sample,
+          baseline: null,
+          deltaAbs: null,
+          deltaRel: null,
+          dropAbs: null,
+          reasonCodes,
+        },
+      });
+
+      continue;
+    }
+    const baselineValue = baselinePicked.sample.hitRateTokens;
+
+    const deltaAbs = currentValue - baselineValue;
+    const dropAbs = Math.max(baselineValue - currentValue, 0);
+    const deltaRel = baselineValue <= 0 ? null : deltaAbs / baselineValue;
+
+    const reasonCodes: string[] = [...currentPicked.reasonCodes];
+
+    reasonCodes.push(...baselinePicked.reasonCodes);
+
+    const triggered: string[] = [];
+
+    if (absMinTriggered) {
+      triggered.push("abs_min");
+    }
+
+    if (baselineValue > 0) {
+      if (dropAbs >= settings.dropAbs && dropAbs / baselineValue >= settings.dropRel) {
+        triggered.push("drop_abs_rel");
+      }
+    }
+
+    if (triggered.length === 0) {
+      continue;
+    }
+
+    reasonCodes.push(...triggered);
+
+    const severity = Math.max(dropAbs, settings.absMin - currentValue, 0);
+
+    anomaliesWithSeverity.push({
+      severity,
+      anomaly: {
+        key,
+        providerId: currentMetric.providerId,
+        model: currentMetric.model,
+        baselineSource: baselinePicked.source,
+        current: currentPicked.sample,
+        baseline: baselinePicked.sample,
+        deltaAbs,
+        deltaRel,
+        dropAbs,
+        reasonCodes,
+      },
+    });
+  }
+
+  return anomaliesWithSeverity
+    .sort((a, b) => {
+      const severityDiff = b.severity - a.severity;
+      if (severityDiff !== 0) return severityDiff;
+
+      const providerDiff = a.anomaly.providerId - b.anomaly.providerId;
+      if (providerDiff !== 0) return providerDiff;
+
+      if (a.anomaly.model < b.anomaly.model) return -1;
+      if (a.anomaly.model > b.anomaly.model) return 1;
+
+      if (a.anomaly.key < b.anomaly.key) return -1;
+      if (a.anomaly.key > b.anomaly.key) return 1;
+      return 0;
+    })
+    .slice(0, settings.topN)
+    .map((x) => x.anomaly);
+}

+ 1 - 0
src/lib/constants/notification.constants.ts

@@ -3,6 +3,7 @@
  */
 export const NOTIFICATION_JOB_TYPES = [
   "circuit-breaker",
+  "cache-hit-rate-alert",
   "cost-alert",
   "daily-leaderboard",
 ] as const;

+ 441 - 8
src/lib/notification/notification-queue.ts

@@ -2,11 +2,21 @@ import type { Job } from "bull";
 import Queue from "bull";
 import type { NotificationJobType } from "@/lib/constants/notification.constants";
 import { logger } from "@/lib/logger";
+import {
+  applyCacheHitRateAlertCooldownToPayload,
+  buildCacheHitRateAlertCooldownKey,
+  commitCacheHitRateAlertCooldown,
+  generateCacheHitRateAlertPayload,
+} from "@/lib/notification/tasks/cache-hit-rate-alert";
+import { generateCostAlerts } from "@/lib/notification/tasks/cost-alert";
+import { generateDailyLeaderboard } from "@/lib/notification/tasks/daily-leaderboard";
 import { resolveSystemTimezone } from "@/lib/utils/timezone";
 import {
+  buildCacheHitRateAlertMessage,
   buildCircuitBreakerMessage,
   buildCostAlertMessage,
   buildDailyLeaderboardMessage,
+  type CacheHitRateAlertData,
   type CircuitBreakerAlertData,
   type CostAlertData,
   type DailyLeaderboardData,
@@ -14,8 +24,7 @@ import {
   sendWebhookMessage,
   type WebhookNotificationType,
 } from "@/lib/webhook";
-import { generateCostAlerts } from "./tasks/cost-alert";
-import { generateDailyLeaderboard } from "./tasks/daily-leaderboard";
+import { isCacheHitRateAlertSettingsWindowMode } from "@/lib/webhook/types";
 
 /**
  * 通知任务数据
@@ -27,7 +36,7 @@ export interface NotificationJobData {
   // 新模式使用(多目标)
   targetId?: number;
   bindingId?: number;
-  data?: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData; // 可选:定时任务会在执行时动态生成
+  data?: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData | CacheHitRateAlertData; // 可选:定时任务会在执行时动态生成
 }
 
 function toWebhookNotificationType(type: NotificationJobType): WebhookNotificationType {
@@ -38,7 +47,107 @@ function toWebhookNotificationType(type: NotificationJobType): WebhookNotificati
       return "daily_leaderboard";
     case "cost-alert":
       return "cost_alert";
+    case "cache-hit-rate-alert":
+      return "cache_hit_rate_alert";
+  }
+}
+
+function isPlainObject(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function isFiniteNumber(value: unknown): value is number {
+  return typeof value === "number" && Number.isFinite(value);
+}
+
+function uniqueStrings(values: string[]): string[] {
+  return Array.from(new Set(values));
+}
+
+function isCacheHitRateAlertSamplePayload(value: unknown): boolean {
+  if (!isPlainObject(value)) return false;
+  const sample = value as Record<string, unknown>;
+  return (
+    (sample.kind === "eligible" || sample.kind === "overall") &&
+    isFiniteNumber(sample.requests) &&
+    isFiniteNumber(sample.denominatorTokens) &&
+    isFiniteNumber(sample.hitRateTokens)
+  );
+}
+
+function isCacheHitRateAlertAnomalyPayload(value: unknown): boolean {
+  if (!isPlainObject(value)) return false;
+  const anomaly = value as Record<string, unknown>;
+
+  if (!isFiniteNumber(anomaly.providerId)) return false;
+  if (typeof anomaly.model !== "string") return false;
+  if (!isCacheHitRateAlertSamplePayload(anomaly.current)) return false;
+
+  const baseline = anomaly.baseline;
+  if (baseline !== null && baseline !== undefined && !isCacheHitRateAlertSamplePayload(baseline)) {
+    return false;
+  }
+
+  const baselineSource = anomaly.baselineSource;
+  if (
+    baselineSource !== null &&
+    baselineSource !== undefined &&
+    typeof baselineSource !== "string"
+  ) {
+    return false;
+  }
+
+  const dropAbs = anomaly.dropAbs;
+  if (dropAbs !== null && dropAbs !== undefined && !isFiniteNumber(dropAbs)) return false;
+
+  const deltaAbs = anomaly.deltaAbs;
+  if (deltaAbs !== null && deltaAbs !== undefined && !isFiniteNumber(deltaAbs)) return false;
+
+  const deltaRel = anomaly.deltaRel;
+  if (deltaRel !== null && deltaRel !== undefined && !isFiniteNumber(deltaRel)) return false;
+
+  const reasonCodes = anomaly.reasonCodes;
+  if (!Array.isArray(reasonCodes) || !reasonCodes.every((code) => typeof code === "string")) {
+    return false;
   }
+
+  return true;
+}
+
+function isCacheHitRateAlertDataPayload(value: unknown): value is CacheHitRateAlertData {
+  if (!isPlainObject(value)) return false;
+  const payload = value as Record<string, unknown>;
+
+  if (!isPlainObject(payload.window)) return false;
+  const window = payload.window as Record<string, unknown>;
+  if (!isCacheHitRateAlertSettingsWindowMode(window.mode)) return false;
+  if (typeof window.startTime !== "string") return false;
+  if (typeof window.endTime !== "string") return false;
+  const windowStartMs = Date.parse(window.startTime);
+  if (Number.isNaN(windowStartMs)) return false;
+  const windowEndMs = Date.parse(window.endTime);
+  if (Number.isNaN(windowEndMs)) return false;
+  if (windowEndMs < windowStartMs) return false;
+  if (!isFiniteNumber(window.durationMinutes)) return false;
+
+  if (!Array.isArray(payload.anomalies)) return false;
+  if (!payload.anomalies.every(isCacheHitRateAlertAnomalyPayload)) return false;
+
+  if (!isPlainObject(payload.settings)) return false;
+  const settings = payload.settings as Record<string, unknown>;
+  if (!isFiniteNumber(settings.cooldownMinutes)) return false;
+  if (!isFiniteNumber(settings.absMin)) return false;
+  if (!isFiniteNumber(settings.dropAbs)) return false;
+  if (!isFiniteNumber(settings.dropRel)) return false;
+  if (!isFiniteNumber(settings.minEligibleRequests)) return false;
+  if (!isFiniteNumber(settings.minEligibleTokens)) return false;
+
+  if (!isFiniteNumber(payload.suppressedCount)) return false;
+
+  if (typeof payload.generatedAt !== "string") return false;
+  if (Number.isNaN(Date.parse(payload.generatedAt))) return false;
+
+  return true;
 }
 
 /**
@@ -150,10 +259,170 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
         timezone = await resolveSystemTimezone();
       }
 
+      // 特殊:targets 模式下,缓存命中率告警使用 fan-out 作业避免重复计算
+      if (type === "cache-hit-rate-alert" && !webhookUrl && !targetId) {
+        const { getNotificationSettings } = await import("@/repository/notifications");
+        const settings = await getNotificationSettings();
+
+        if (!settings.enabled || !settings.cacheHitRateAlertEnabled) {
+          logger.info({
+            action: "cache_hit_rate_alert_disabled",
+            jobId: job.id,
+          });
+          return { success: true, skipped: true };
+        }
+
+        let payload: CacheHitRateAlertData;
+
+        // 注意:targets 模式的 fan-out 主作业可能会因为 enqueue 失败而触发重试。
+        // 因此这里将生成结果写回 job.data,确保重试时使用同一份 payload,避免边界丢失。
+        if (data) {
+          if (!isCacheHitRateAlertDataPayload(data)) {
+            logger.error({
+              action: "cache_hit_rate_alert_invalid_payload",
+              jobId: job.id,
+              reason: "fanout_data_invalid",
+            });
+            return { success: true, skipped: true };
+          }
+          payload = data;
+        } else {
+          const dedupMode = settings.useLegacyMode ? "global" : "none";
+          const generated = await generateCacheHitRateAlertPayload({ dedupMode });
+          if (!generated) {
+            logger.info({
+              action: "cache_hit_rate_alert_no_data",
+              jobId: job.id,
+            });
+            return { success: true, skipped: true };
+          }
+
+          payload = generated.payload;
+
+          try {
+            await job.update({
+              ...job.data,
+              data: payload,
+            });
+          } catch (error) {
+            logger.error({
+              action: "cache_hit_rate_alert_update_failed",
+              jobId: job.id,
+              error: error instanceof Error ? error.message : String(error),
+            });
+          }
+        }
+
+        if (payload.anomalies.length === 0) {
+          logger.info({
+            action: "cache_hit_rate_alert_no_data",
+            jobId: job.id,
+          });
+          return { success: true, skipped: true };
+        }
+
+        if (settings.useLegacyMode) {
+          const url = settings.cacheHitRateAlertWebhook?.trim();
+          if (!url) {
+            logger.info({
+              action: "cache_hit_rate_alert_disabled",
+              jobId: job.id,
+              reason: "legacy_webhook_missing",
+            });
+            return { success: true, skipped: true };
+          }
+
+          const message = buildCacheHitRateAlertMessage(payload, timezone);
+          const sendResult = await sendWebhookMessage(url, message, { timezone });
+
+          if (!sendResult.success) {
+            throw new Error(sendResult.error || "Failed to send cache hit rate alert");
+          }
+
+          const keys = uniqueStrings(
+            payload.anomalies.map((a) =>
+              buildCacheHitRateAlertCooldownKey({
+                providerId: a.providerId,
+                model: a.model,
+                windowMode: payload.window.mode,
+              })
+            )
+          );
+
+          try {
+            await commitCacheHitRateAlertCooldown(keys, payload.settings.cooldownMinutes);
+          } catch (error) {
+            logger.warn({
+              action: "cache_hit_rate_alert_dedup_commit_failed",
+              jobId: job.id,
+              mode: "legacy",
+              keysCount: keys.length,
+              cooldownMinutes: payload.settings.cooldownMinutes,
+              error: error instanceof Error ? error.message : String(error),
+            });
+          }
+
+          logger.info({
+            action: "cache_hit_rate_alert_sent",
+            jobId: job.id,
+            mode: "legacy",
+            anomalies: payload.anomalies.length,
+            suppressedCount: payload.suppressedCount,
+          });
+
+          return { success: true };
+        }
+
+        const { getEnabledBindingsByType } = await import("@/repository/notification-bindings");
+        const bindings = await getEnabledBindingsByType("cache_hit_rate_alert");
+
+        if (bindings.length === 0) {
+          logger.info({
+            action: "cache_hit_rate_alert_skipped",
+            jobId: job.id,
+            reason: "no_bindings",
+          });
+          return { success: true, skipped: true };
+        }
+
+        const fanoutRunId = String(job.id ?? job.timestamp ?? Date.now());
+
+        for (const binding of bindings) {
+          // 使用稳定的 jobId 避免 fan-out 主作业重试时重复 enqueue,造成重复发送
+          const childJobId = `cache-hit-rate-alert:${fanoutRunId}:${binding.id}`;
+
+          await queue.add(
+            {
+              type: "cache-hit-rate-alert",
+              targetId: binding.targetId,
+              bindingId: binding.id,
+              data: payload,
+            },
+            { jobId: childJobId }
+          );
+        }
+
+        logger.info({
+          action: "cache_hit_rate_alert_fanout_enqueued",
+          jobId: job.id,
+          mode: "targets",
+          targets: bindings.length,
+          anomalies: payload.anomalies.length,
+          suppressedCount: payload.suppressedCount,
+        });
+
+        return { success: true, enqueued: bindings.length };
+      }
+
       // 构建结构化消息
       let message: StructuredMessage;
-      let templateData: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData | undefined =
-        data;
+      let templateData:
+        | CircuitBreakerAlertData
+        | DailyLeaderboardData
+        | CostAlertData
+        | CacheHitRateAlertData
+        | undefined = data;
+      let cooldownCommit: { keys: string[]; cooldownMinutes: number } | undefined;
       switch (type) {
         case "circuit-breaker":
           message = buildCircuitBreakerMessage(data as CircuitBreakerAlertData, timezone);
@@ -199,6 +468,84 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
           message = buildCostAlertMessage(alerts[0]);
           break;
         }
+        case "cache-hit-rate-alert": {
+          let payload: CacheHitRateAlertData;
+          let dedupKeysToSet: string[] | undefined;
+          let cooldownMinutes: number | undefined;
+
+          if (data) {
+            if (!isCacheHitRateAlertDataPayload(data)) {
+              logger.error({
+                action: "cache_hit_rate_alert_invalid_payload",
+                jobId: job.id,
+                targetId,
+                bindingId,
+              });
+              return { success: true, skipped: true };
+            }
+            payload = data;
+          } else {
+            // legacy webhook:全局 cooldown 去重;targets:每个 binding 单独去重,避免“一个 target 发送成功后把全局 cooldown 写死”导致其他 target 永久漏发
+            // 仅在“生成 payload”路径下使用:当 payload 由 fan-out 预填充时(data 存在),本分支不会执行。
+            const generationDedupMode = webhookUrl ? "global" : "none";
+            const result = await generateCacheHitRateAlertPayload({
+              dedupMode: generationDedupMode,
+            });
+
+            if (!result) {
+              logger.info({
+                action: "cache_hit_rate_alert_no_data",
+                jobId: job.id,
+              });
+              return { success: true, skipped: true };
+            }
+
+            payload = result.payload;
+            if (generationDedupMode === "global") {
+              dedupKeysToSet = result.dedupKeysToSet;
+              cooldownMinutes = result.cooldownMinutes;
+            }
+          }
+
+          if (targetId && bindingId) {
+            const applied = await applyCacheHitRateAlertCooldownToPayload({
+              payload,
+              bindingId,
+            });
+            payload = applied.payload;
+            if (payload.anomalies.length === 0) {
+              logger.info({
+                action: "cache_hit_rate_alert_all_suppressed",
+                jobId: job.id,
+                bindingId,
+                targetId,
+              });
+              return { success: true, skipped: true };
+            }
+
+            dedupKeysToSet = applied.dedupKeysToSet;
+            cooldownMinutes = payload.settings.cooldownMinutes;
+          } else {
+            dedupKeysToSet ??= uniqueStrings(
+              payload.anomalies.map((a) =>
+                buildCacheHitRateAlertCooldownKey({
+                  providerId: a.providerId,
+                  model: a.model,
+                  windowMode: payload.window.mode,
+                })
+              )
+            );
+            cooldownMinutes ??= payload.settings.cooldownMinutes;
+          }
+
+          templateData = payload;
+          message = buildCacheHitRateAlertMessage(payload, timezone);
+          cooldownCommit = {
+            keys: dedupKeysToSet ? uniqueStrings(dedupKeysToSet) : [],
+            cooldownMinutes: cooldownMinutes ?? payload.settings.cooldownMinutes,
+          };
+          break;
+        }
         default:
           throw new Error(`Unknown notification type: ${type}`);
       }
@@ -244,6 +591,23 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
         throw new Error(result.error || "Failed to send notification");
       }
 
+      if (cooldownCommit) {
+        try {
+          await commitCacheHitRateAlertCooldown(
+            cooldownCommit.keys,
+            cooldownCommit.cooldownMinutes
+          );
+        } catch (error) {
+          logger.warn({
+            action: "cache_hit_rate_alert_dedup_commit_failed",
+            jobId: job.id,
+            keysCount: cooldownCommit.keys.length,
+            cooldownMinutes: cooldownCommit.cooldownMinutes,
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }
+      }
+
       logger.info({
         action: "notification_job_complete",
         jobId: job.id,
@@ -285,7 +649,7 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
 export async function addNotificationJob(
   type: NotificationJobType,
   webhookUrl: string,
-  data: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData
+  data: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData | CacheHitRateAlertData
 ): Promise<void> {
   try {
     const queue = getNotificationQueue();
@@ -315,7 +679,7 @@ export async function addNotificationJobForTarget(
   type: NotificationJobType,
   targetId: number,
   bindingId: number | null,
-  data: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData
+  data: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData | CacheHitRateAlertData
 ): Promise<void> {
   try {
     const queue = getNotificationQueue();
@@ -400,7 +764,7 @@ export async function scheduleNotifications() {
       }
 
       if (settings.costAlertEnabled && settings.costAlertWebhook) {
-        const interval = settings.costAlertCheckInterval; // 分钟
+        const interval = settings.costAlertCheckInterval ?? 60; // 分钟
         const cron = `*/${interval} * * * *`; // 每 N 分钟
 
         await queue.add(
@@ -422,6 +786,31 @@ export async function scheduleNotifications() {
           mode: "legacy",
         });
       }
+
+      if (settings.cacheHitRateAlertEnabled && settings.cacheHitRateAlertWebhook) {
+        const intervalMinutesRaw = settings.cacheHitRateAlertCheckInterval ?? 5;
+        const intervalMinutes = Math.max(1, Math.trunc(intervalMinutesRaw));
+        const clampedIntervalMinutes = Math.min(intervalMinutes, 24 * 60);
+        const repeat =
+          clampedIntervalMinutes <= 59
+            ? { cron: `*/${clampedIntervalMinutes} * * * *` }
+            : { every: clampedIntervalMinutes * 60 * 1000 };
+
+        await queue.add(
+          {
+            type: "cache-hit-rate-alert",
+            webhookUrl: settings.cacheHitRateAlertWebhook,
+          },
+          { repeat, jobId: "cache-hit-rate-alert-scheduled" }
+        );
+
+        logger.info({
+          action: "cache_hit_rate_alert_scheduled",
+          schedule: "cron" in repeat ? repeat.cron : `every:${clampedIntervalMinutes}m`,
+          intervalMinutes: clampedIntervalMinutes,
+          mode: "legacy",
+        });
+      }
     } else {
       // 新模式:按绑定调度(支持 cron 覆盖)
       const { getEnabledBindingsByType } = await import("@/repository/notification-bindings");
@@ -487,6 +876,50 @@ export async function scheduleNotifications() {
           mode: "targets",
         });
       }
+
+      if (settings.cacheHitRateAlertEnabled) {
+        const bindings = await getEnabledBindingsByType("cache_hit_rate_alert");
+        const intervalMinutesRaw = settings.cacheHitRateAlertCheckInterval ?? 5;
+        const intervalMinutes = Math.max(1, Math.trunc(intervalMinutesRaw));
+        const clampedIntervalMinutes = Math.min(intervalMinutes, 24 * 60);
+        const defaultCron = `*/${clampedIntervalMinutes} * * * *`;
+        const repeat =
+          clampedIntervalMinutes <= 59
+            ? { cron: defaultCron, tz: systemTimezone }
+            : { every: clampedIntervalMinutes * 60 * 1000 };
+
+        if (bindings.length > 0) {
+          // 注意:这里刻意只调度一个共享的 repeat 作业,然后在处理器内 fan-out 到所有 bindings。
+          // 这样可以避免对每个 binding 重复计算同一份 payload;代价是 binding 的 scheduleCron/scheduleTimezone 将被忽略。
+          // 另外:interval > 59 分钟会使用 repeat.every(固定间隔,不对齐整点,也不支持 tz),这是 Bull cron 分钟字段的限制。
+          // 若未来需要支持 per-binding 的 cron/timezone,需要改为“每个 binding 一个 repeat 作业”或引入更细粒度的调度层。
+          await queue.add(
+            {
+              type: "cache-hit-rate-alert",
+            },
+            {
+              repeat,
+              jobId: "cache-hit-rate-alert-targets-scheduled",
+            }
+          );
+          logger.info({
+            action: "cache_hit_rate_alert_scheduled",
+            schedule: "cron" in repeat ? repeat.cron : `every:${clampedIntervalMinutes}m`,
+            intervalMinutes: clampedIntervalMinutes,
+            targets: bindings.length,
+            mode: "targets",
+          });
+        } else {
+          logger.info({
+            action: "cache_hit_rate_alert_schedule_skipped",
+            schedule:
+              clampedIntervalMinutes <= 59 ? defaultCron : `every:${clampedIntervalMinutes}m`,
+            intervalMinutes: clampedIntervalMinutes,
+            reason: "no_bindings",
+            mode: "targets",
+          });
+        }
+      }
     }
 
     logger.info({ action: "notifications_scheduled" });

+ 477 - 0
src/lib/notification/tasks/cache-hit-rate-alert.ts

@@ -0,0 +1,477 @@
+import { addDays, startOfDay } from "date-fns";
+import { fromZonedTime, toZonedTime } from "date-fns-tz";
+import {
+  type CacheHitRateAlertDecisionSettings,
+  type CacheHitRateAlertMetric,
+  decideCacheHitRateAnomalies,
+} from "@/lib/cache-hit-rate-alert/decision";
+import { logger } from "@/lib/logger";
+import { getRedisClient } from "@/lib/redis/client";
+import { resolveSystemTimezone } from "@/lib/utils/timezone";
+import type {
+  CacheHitRateAlertData,
+  CacheHitRateAlertSettingsSnapshot,
+  CacheHitRateAlertWindow,
+} from "@/lib/webhook";
+import type { CacheHitRateAlertSettingsWindowMode } from "@/lib/webhook/types";
+import { findProviderModelCacheHitRateMetricsForAlert } from "@/repository/cache-hit-rate-alert";
+import { getNotificationSettings } from "@/repository/notifications";
+import { findAllProviders } from "@/repository/provider";
+
+export interface CacheHitRateAlertTaskResult {
+  payload: CacheHitRateAlertData;
+  dedupKeysToSet: string[];
+  cooldownMinutes: number;
+}
+
+export type CacheHitRateAlertDedupMode = "global" | "none";
+
+export interface GenerateCacheHitRateAlertPayloadOptions {
+  dedupMode?: CacheHitRateAlertDedupMode;
+}
+
+function parseNumber(input: string | null | undefined, fallback: number): number {
+  if (input === null || input === undefined) return fallback;
+  const value = Number(input);
+  return Number.isFinite(value) ? value : fallback;
+}
+
+function parseIntNumber(input: number | null | undefined, fallback: number): number {
+  if (input === null || input === undefined) return fallback;
+  return Number.isFinite(input) ? Math.trunc(input) : fallback;
+}
+
+function resolveWindowMode(
+  mode: string | null | undefined,
+  intervalMinutes: number
+): {
+  mode: CacheHitRateAlertSettingsWindowMode;
+  durationMinutes: number;
+} {
+  switch (mode) {
+    case "5m":
+      return { mode: "5m", durationMinutes: 5 };
+    case "30m":
+      return { mode: "30m", durationMinutes: 30 };
+    case "1h":
+      return { mode: "1h", durationMinutes: 60 };
+    case "1.5h":
+      return { mode: "1.5h", durationMinutes: 90 };
+    default: {
+      if (intervalMinutes <= 5) return { mode: "5m", durationMinutes: 5 };
+      if (intervalMinutes <= 30) return { mode: "30m", durationMinutes: 30 };
+      if (intervalMinutes <= 60) return { mode: "1h", durationMinutes: 60 };
+      return { mode: "1.5h", durationMinutes: 90 };
+    }
+  }
+}
+
+function buildRedisKeyPart(value: string): string {
+  return Buffer.from(value, "utf8").toString("base64url");
+}
+
+function buildCooldownKey(params: {
+  providerId: number;
+  model: string;
+  windowMode: string;
+  bindingId?: number;
+}): string {
+  return [
+    "cache-hit-rate-alert",
+    "v1",
+    ...(params.bindingId === undefined ? [] : ["binding", String(params.bindingId)]),
+    String(params.providerId),
+    buildRedisKeyPart(params.model),
+    params.windowMode,
+  ].join(":");
+}
+
+export function buildCacheHitRateAlertCooldownKey(params: {
+  providerId: number;
+  model: string;
+  windowMode: string;
+  bindingId?: number;
+}): string {
+  return buildCooldownKey(params);
+}
+
+export interface CacheHitRateAlertCooldownApplyResult {
+  payload: CacheHitRateAlertData;
+  dedupKeysToSet: string[];
+  suppressedCount: number;
+}
+
+export async function applyCacheHitRateAlertCooldownToPayload(params: {
+  payload: CacheHitRateAlertData;
+  bindingId?: number;
+}): Promise<CacheHitRateAlertCooldownApplyResult> {
+  const { payload, bindingId } = params;
+  const cooldownMinutes = payload.settings?.cooldownMinutes ?? 0;
+  if (payload.anomalies.length === 0 || cooldownMinutes <= 0) {
+    return {
+      payload,
+      dedupKeysToSet: [],
+      suppressedCount: 0,
+    };
+  }
+
+  const redis = getRedisClient({ allowWhenRateLimitDisabled: true });
+  if (!redis) {
+    return {
+      payload,
+      dedupKeysToSet: payload.anomalies.map((a) =>
+        buildCooldownKey({
+          providerId: a.providerId,
+          model: a.model,
+          windowMode: payload.window.mode,
+          bindingId,
+        })
+      ),
+      suppressedCount: 0,
+    };
+  }
+
+  const anomalyPairs = payload.anomalies.map((anomaly) => ({
+    anomaly,
+    key: buildCooldownKey({
+      providerId: anomaly.providerId,
+      model: anomaly.model,
+      windowMode: payload.window.mode,
+      bindingId,
+    }),
+  }));
+
+  const keys = anomalyPairs.map((p) => p.key);
+  let values: Array<string | null>;
+
+  try {
+    values = await redis.mget(...keys);
+  } catch (error) {
+    logger.warn({
+      action: "cache_hit_rate_alert_dedup_read_failed",
+      keysCount: keys.length,
+      windowMode: payload.window.mode,
+      error: error instanceof Error ? error.message : String(error),
+    });
+
+    return {
+      payload,
+      dedupKeysToSet: keys,
+      suppressedCount: 0,
+    };
+  }
+
+  const remainingPairs: typeof anomalyPairs = [];
+  let suppressedCount = 0;
+  for (let i = 0; i < anomalyPairs.length; i++) {
+    if (values[i]) {
+      suppressedCount++;
+      continue;
+    }
+    remainingPairs.push(anomalyPairs[i]);
+  }
+
+  const remaining = remainingPairs.map((p) => p.anomaly);
+  const dedupKeysToSet = remainingPairs.map((p) => p.key);
+
+  return {
+    payload: {
+      ...payload,
+      anomalies: remaining,
+      suppressedCount: (payload.suppressedCount ?? 0) + suppressedCount,
+    },
+    dedupKeysToSet,
+    suppressedCount,
+  };
+}
+
+function toDecisionMetric(
+  row: Awaited<ReturnType<typeof findProviderModelCacheHitRateMetricsForAlert>>[number]
+): CacheHitRateAlertMetric {
+  return {
+    providerId: row.providerId,
+    model: row.model,
+    totalRequests: row.totalRequests,
+    denominatorTokens: row.denominatorTokens,
+    hitRateTokens: row.hitRateTokens,
+    eligibleRequests: row.eligibleRequests,
+    eligibleDenominatorTokens: row.eligibleDenominatorTokens,
+    hitRateTokensEligible: row.hitRateTokensEligible,
+  };
+}
+
+function getStartOfToday(timezone: string, now: Date): Date {
+  const zonedNow = toZonedTime(now, timezone);
+  const zonedStart = startOfDay(zonedNow);
+  return fromZonedTime(zonedStart, timezone);
+}
+
+export async function generateCacheHitRateAlertPayload(
+  options: GenerateCacheHitRateAlertPayloadOptions = {}
+): Promise<CacheHitRateAlertTaskResult | null> {
+  const settings = await getNotificationSettings();
+
+  if (!settings.enabled || !settings.cacheHitRateAlertEnabled) {
+    logger.info({ action: "cache_hit_rate_alert_disabled" });
+    return null;
+  }
+
+  const dedupMode: CacheHitRateAlertDedupMode = options.dedupMode ?? "global";
+
+  const intervalMinutes = Math.max(1, parseIntNumber(settings.cacheHitRateAlertCheckInterval, 5));
+  const MAX_LOOKBACK_DAYS = 90;
+  const lookbackDays = Math.min(
+    parseIntNumber(settings.cacheHitRateAlertHistoricalLookbackDays, 7),
+    MAX_LOOKBACK_DAYS
+  );
+  const cooldownMinutes = parseIntNumber(settings.cacheHitRateAlertCooldownMinutes, 30);
+
+  const decisionSettings: CacheHitRateAlertDecisionSettings = {
+    absMin: parseNumber(settings.cacheHitRateAlertAbsMin, 0.05),
+    dropRel: parseNumber(settings.cacheHitRateAlertDropRel, 0.3),
+    dropAbs: parseNumber(settings.cacheHitRateAlertDropAbs, 0.1),
+    minEligibleRequests: parseIntNumber(settings.cacheHitRateAlertMinEligibleRequests, 20),
+    minEligibleTokens: parseIntNumber(settings.cacheHitRateAlertMinEligibleTokens, 0),
+    topN: parseIntNumber(settings.cacheHitRateAlertTopN, 10),
+  };
+
+  const { mode: resolvedWindowMode, durationMinutes } = resolveWindowMode(
+    settings.cacheHitRateAlertWindowMode,
+    intervalMinutes
+  );
+
+  const now = new Date();
+  const currentEnd = now;
+  const currentStart = new Date(now.getTime() - durationMinutes * 60 * 1000);
+  const prevStart = new Date(now.getTime() - durationMinutes * 2 * 60 * 1000);
+  const prevEnd = currentStart;
+
+  const timezone = await resolveSystemTimezone();
+  const todayStart = getStartOfToday(timezone, now);
+  const historicalStartZoned = addDays(toZonedTime(todayStart, timezone), -lookbackDays);
+  const historicalStart = fromZonedTime(historicalStartZoned, timezone);
+  const historicalEnd = todayStart;
+
+  // today 窗口仅用于“当日累计”基线;currentStart 可能在午夜附近早于 todayStart,因此这里做一次 clamp
+  const todayEnd = currentStart < todayStart ? todayStart : currentStart;
+
+  logger.info({
+    action: "cache_hit_rate_alert_generate_start",
+    windowMode: resolvedWindowMode,
+    durationMinutes,
+    intervalMinutes,
+    lookbackDays,
+    cooldownMinutes,
+    decisionSettings,
+    timezone,
+  });
+
+  const [currentRows, prevRows, todayRows, historicalRows] = await Promise.all([
+    findProviderModelCacheHitRateMetricsForAlert(
+      { start: currentStart, end: currentEnd },
+      undefined,
+      {
+        windowMode: "rolling",
+        statusCodeMode: "2xx",
+      }
+    ),
+    findProviderModelCacheHitRateMetricsForAlert({ start: prevStart, end: prevEnd }, undefined, {
+      windowMode: "rolling",
+      statusCodeMode: "2xx",
+    }),
+    todayStart < todayEnd
+      ? findProviderModelCacheHitRateMetricsForAlert(
+          { start: todayStart, end: todayEnd },
+          undefined,
+          {
+            windowMode: "rolling",
+            statusCodeMode: "2xx",
+          }
+        )
+      : Promise.resolve([]),
+    historicalStart < historicalEnd
+      ? findProviderModelCacheHitRateMetricsForAlert(
+          { start: historicalStart, end: historicalEnd },
+          undefined,
+          { windowMode: "rolling", statusCodeMode: "2xx" }
+        )
+      : Promise.resolve([]),
+  ]);
+
+  const anomalies = decideCacheHitRateAnomalies({
+    current: currentRows.map(toDecisionMetric),
+    prev: prevRows.map(toDecisionMetric),
+    today: todayRows.map(toDecisionMetric),
+    historical: historicalRows.map(toDecisionMetric),
+    settings: decisionSettings,
+  });
+
+  if (anomalies.length === 0) {
+    logger.info({
+      action: "cache_hit_rate_alert_no_anomalies",
+      windowMode: resolvedWindowMode,
+      durationMinutes,
+    });
+    return null;
+  }
+
+  const redis =
+    dedupMode === "global" && cooldownMinutes > 0
+      ? getRedisClient({ allowWhenRateLimitDisabled: true })
+      : null;
+  const suppressedKeys = new Set<string>();
+  const anomalyPairs = anomalies.map((anomaly) => ({
+    anomaly,
+    key: buildCooldownKey({
+      providerId: anomaly.providerId,
+      model: anomaly.model,
+      windowMode: resolvedWindowMode,
+    }),
+  }));
+
+  if (redis) {
+    const keys = anomalyPairs.map((p) => p.key);
+
+    try {
+      const values = await redis.mget(...keys);
+      for (let i = 0; i < keys.length; i++) {
+        if (values[i]) {
+          suppressedKeys.add(keys[i]);
+        }
+      }
+    } catch (error) {
+      logger.warn({
+        action: "cache_hit_rate_alert_dedup_read_failed",
+        keysCount: keys.length,
+        windowMode: resolvedWindowMode,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  }
+
+  const suppressedCount = suppressedKeys.size;
+  const remaining = redis
+    ? anomalyPairs.filter((p) => !suppressedKeys.has(p.key)).map((p) => p.anomaly)
+    : anomalies;
+
+  if (remaining.length === 0) {
+    logger.info({
+      action: "cache_hit_rate_alert_all_suppressed",
+      suppressedCount,
+      windowMode: resolvedWindowMode,
+      durationMinutes,
+      cooldownMinutes,
+    });
+    return null;
+  }
+
+  const providers = await findAllProviders();
+  const providerMap = new Map(providers.map((p) => [p.id, p]));
+
+  const window: CacheHitRateAlertWindow = {
+    mode: resolvedWindowMode,
+    startTime: currentStart.toISOString(),
+    endTime: currentEnd.toISOString(),
+    durationMinutes,
+  };
+
+  const settingsSnapshot: CacheHitRateAlertSettingsSnapshot = {
+    windowMode: settings.cacheHitRateAlertWindowMode ?? "auto",
+    checkIntervalMinutes: intervalMinutes,
+    historicalLookbackDays: lookbackDays,
+    minEligibleRequests: decisionSettings.minEligibleRequests,
+    minEligibleTokens: decisionSettings.minEligibleTokens,
+    absMin: decisionSettings.absMin,
+    dropRel: decisionSettings.dropRel,
+    dropAbs: decisionSettings.dropAbs,
+    cooldownMinutes,
+    topN: decisionSettings.topN,
+  };
+
+  const payload: CacheHitRateAlertData = {
+    window,
+    anomalies: remaining.map((a) => {
+      const provider = providerMap.get(a.providerId);
+      return {
+        providerId: a.providerId,
+        providerName: provider?.name,
+        providerType: provider?.providerType,
+        model: a.model,
+        baselineSource: a.baselineSource,
+        current: a.current,
+        baseline: a.baseline,
+        deltaAbs: a.deltaAbs,
+        deltaRel: a.deltaRel,
+        dropAbs: a.dropAbs,
+        reasonCodes: a.reasonCodes,
+      };
+    }),
+    suppressedCount,
+    settings: settingsSnapshot,
+    generatedAt: new Date().toISOString(),
+  };
+
+  const dedupKeysToSet =
+    dedupMode === "global"
+      ? payload.anomalies.map((a) =>
+          buildCooldownKey({
+            providerId: a.providerId,
+            model: a.model,
+            windowMode: resolvedWindowMode,
+          })
+        )
+      : [];
+
+  logger.info({
+    action: "cache_hit_rate_alert_generated",
+    windowMode: resolvedWindowMode,
+    durationMinutes,
+    anomalies: payload.anomalies.length,
+    suppressedCount,
+  });
+
+  return { payload, dedupKeysToSet, cooldownMinutes };
+}
+
+export async function commitCacheHitRateAlertCooldown(
+  keys: string[],
+  cooldownMinutes: number
+): Promise<void> {
+  if (keys.length === 0) return;
+  if (cooldownMinutes <= 0) return;
+
+  const redis = getRedisClient({ allowWhenRateLimitDisabled: true });
+  if (!redis) return;
+
+  // Redis EX 需要整数秒;这里做一次截断,避免配置被写成小数导致提交去重失败。
+  const ttlSeconds = Math.max(1, Math.trunc(cooldownMinutes * 60));
+  const pipeline = redis.pipeline();
+  for (const key of keys) {
+    pipeline.set(key, "1", "EX", ttlSeconds);
+  }
+
+  try {
+    const results = await pipeline.exec();
+    if (!results) return;
+
+    for (let i = 0; i < results.length; i++) {
+      const [error] = results[i];
+      if (!error) continue;
+
+      logger.warn({
+        action: "cache_hit_rate_alert_dedup_write_failed",
+        key: keys[i],
+        keysCount: keys.length,
+        cooldownMinutes,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  } catch (error) {
+    logger.warn({
+      action: "cache_hit_rate_alert_dedup_write_failed",
+      keysCount: keys.length,
+      cooldownMinutes,
+      error: error instanceof Error ? error.message : String(error),
+    });
+  }
+}

+ 7 - 0
src/lib/webhook/index.ts

@@ -6,11 +6,18 @@ export { sendWebhookMessage, WebhookNotifier } from "./notifier";
 export { createRenderer, type Renderer } from "./renderers";
 // Templates
 export {
+  buildCacheHitRateAlertMessage,
   buildCircuitBreakerMessage,
   buildCostAlertMessage,
   buildDailyLeaderboardMessage,
 } from "./templates";
 export type {
+  CacheHitRateAlertAnomaly,
+  CacheHitRateAlertBaselineSource,
+  CacheHitRateAlertData,
+  CacheHitRateAlertSample,
+  CacheHitRateAlertSettingsSnapshot,
+  CacheHitRateAlertWindow,
   CircuitBreakerAlertData,
   CostAlertData,
   DailyLeaderboardData,

+ 130 - 0
src/lib/webhook/templates/cache-hit-rate-alert.ts

@@ -0,0 +1,130 @@
+import type { CacheHitRateAlertAnomaly, CacheHitRateAlertData, StructuredMessage } from "../types";
+import { formatDateTime } from "../utils/date";
+
+function formatPercent(rate: number): string {
+  if (!Number.isFinite(rate)) return "";
+  return `${(rate * 100).toFixed(1)}%`;
+}
+
+function formatNumber(value: number): string {
+  if (!Number.isFinite(value)) return "";
+  return Math.round(value).toString();
+}
+
+function formatAnomalyTitle(anomaly: CacheHitRateAlertAnomaly): string {
+  const provider = anomaly.providerName?.trim()
+    ? anomaly.providerName.trim()
+    : `Provider #${anomaly.providerId}`;
+  return `${provider} / ${anomaly.model}`;
+}
+
+function buildAnomalyDetails(anomaly: CacheHitRateAlertAnomaly): string {
+  const lines: string[] = [];
+
+  lines.push(
+    `当前(${anomaly.current.kind}): ${formatPercent(anomaly.current.hitRateTokens)} (req=${formatNumber(
+      anomaly.current.requests
+    )}, tok=${formatNumber(anomaly.current.denominatorTokens)})`
+  );
+
+  if (anomaly.baseline) {
+    const source = anomaly.baselineSource ?? "unknown";
+    lines.push(
+      `基线(${source} ${anomaly.baseline.kind}): ${formatPercent(
+        anomaly.baseline.hitRateTokens
+      )} (req=${formatNumber(anomaly.baseline.requests)}, tok=${formatNumber(
+        anomaly.baseline.denominatorTokens
+      )})`
+    );
+  } else {
+    lines.push("基线: 无");
+  }
+
+  if (anomaly.dropAbs !== null && Number.isFinite(anomaly.dropAbs)) {
+    lines.push(`绝对跌幅: ${formatPercent(anomaly.dropAbs)}`);
+  }
+  if (anomaly.deltaRel !== null && Number.isFinite(anomaly.deltaRel)) {
+    lines.push(`相对变化: ${formatPercent(anomaly.deltaRel)}`);
+  }
+
+  return lines.join("\n");
+}
+
+export function buildCacheHitRateAlertMessage(
+  data: CacheHitRateAlertData,
+  timezone?: string
+): StructuredMessage {
+  const tz = timezone || "UTC";
+  const anomalyCount = data.anomalies.length;
+
+  return {
+    header: {
+      title: "缓存命中率异常告警",
+      icon: "[CACHE]",
+      level: "warning",
+    },
+    sections: [
+      {
+        content: [
+          {
+            type: "quote",
+            value: anomalyCount > 0 ? `检测到缓存命中率异常(${anomalyCount} 条)` : "未检测到异常",
+          },
+        ],
+      },
+      {
+        title: "检测窗口",
+        content: [
+          {
+            type: "fields",
+            items: [
+              {
+                label: "窗口",
+                value: `${data.window.mode} (${data.window.durationMinutes} 分钟)`,
+              },
+              { label: "开始", value: formatDateTime(data.window.startTime, tz) },
+              { label: "结束", value: formatDateTime(data.window.endTime, tz) },
+              { label: "抑制数量", value: String(data.suppressedCount) },
+            ],
+          },
+        ],
+      },
+      {
+        title: "阈值",
+        content: [
+          {
+            type: "fields",
+            items: [
+              { label: "绝对下限(absMin)", value: formatPercent(data.settings.absMin) },
+              { label: "绝对跌幅(dropAbs)", value: formatPercent(data.settings.dropAbs) },
+              { label: "相对跌幅(dropRel)", value: formatPercent(data.settings.dropRel) },
+              {
+                label: "最小样本",
+                value: `req>=${data.settings.minEligibleRequests}, tok>=${data.settings.minEligibleTokens}`,
+              },
+              { label: "冷却", value: `${data.settings.cooldownMinutes} 分钟` },
+            ],
+          },
+        ],
+      },
+      ...(anomalyCount > 0
+        ? [
+            {
+              title: "异常列表",
+              content: [
+                {
+                  type: "list" as const,
+                  style: "bullet" as const,
+                  items: data.anomalies.map((a) => ({
+                    primary: formatAnomalyTitle(a),
+                    secondary: buildAnomalyDetails(a),
+                  })),
+                },
+              ],
+            },
+          ]
+        : []),
+    ],
+    timestamp: new Date(),
+  };
+}

+ 17 - 0
src/lib/webhook/templates/defaults.ts

@@ -33,6 +33,22 @@ export const DEFAULT_TEMPLATES = {
     quotaLimit: "{{quota_limit}}",
     usagePercent: "{{usage_percent}}",
   },
+
+  cache_hit_rate_alert: {
+    title: "{{title}}",
+    windowMode: "{{window_mode}}",
+    windowStart: "{{window_start}}",
+    windowEnd: "{{window_end}}",
+    anomalyCount: "{{anomaly_count}}",
+    suppressedCount: "{{suppressed_count}}",
+    anomalies: "{{anomalies_json}}",
+    absMin: "{{abs_min}}",
+    dropRel: "{{drop_rel}}",
+    dropAbs: "{{drop_abs}}",
+    cooldownMinutes: "{{cooldown_minutes}}",
+    topN: "{{top_n}}",
+    generatedAt: "{{generated_at}}",
+  },
 } as const;
 
 export const DEFAULT_TEMPLATE_BY_NOTIFICATION_TYPE: Record<
@@ -42,4 +58,5 @@ export const DEFAULT_TEMPLATE_BY_NOTIFICATION_TYPE: Record<
   circuit_breaker: DEFAULT_TEMPLATES.circuit_breaker,
   daily_leaderboard: DEFAULT_TEMPLATES.daily_leaderboard,
   cost_alert: DEFAULT_TEMPLATES.cost_alert,
+  cache_hit_rate_alert: DEFAULT_TEMPLATES.cache_hit_rate_alert,
 };

+ 1 - 0
src/lib/webhook/templates/index.ts

@@ -1,3 +1,4 @@
+export { buildCacheHitRateAlertMessage } from "./cache-hit-rate-alert";
 export { buildCircuitBreakerMessage } from "./circuit-breaker";
 export { buildCostAlertMessage } from "./cost-alert";
 export { buildDailyLeaderboardMessage } from "./daily-leaderboard";

+ 34 - 0
src/lib/webhook/templates/placeholders.ts

@@ -1,4 +1,5 @@
 import type {
+  CacheHitRateAlertData,
   CircuitBreakerAlertData,
   CostAlertData,
   DailyLeaderboardData,
@@ -18,6 +19,7 @@ export const WEBHOOK_NOTIFICATION_TYPES = [
   "circuit_breaker",
   "daily_leaderboard",
   "cost_alert",
+  "cache_hit_rate_alert",
 ] as const satisfies readonly WebhookNotificationType[];
 
 export const TEMPLATE_PLACEHOLDERS = {
@@ -59,6 +61,20 @@ export const TEMPLATE_PLACEHOLDERS = {
     { key: "{{quota_limit}}", label: "配额上限", description: "配额限制金额" },
     { key: "{{usage_percent}}", label: "使用比例", description: "百分比(0-100)" },
   ],
+  cache_hit_rate_alert: [
+    { key: "{{window_mode}}", label: "窗口模式", description: "auto/5m/30m/1h/1.5h" },
+    { key: "{{window_start}}", label: "窗口开始", description: "ISO 8601 格式" },
+    { key: "{{window_end}}", label: "窗口结束", description: "ISO 8601 格式" },
+    { key: "{{anomaly_count}}", label: "告警数量", description: "本次告警条数" },
+    { key: "{{suppressed_count}}", label: "抑制数量", description: "冷却/去重抑制的条数" },
+    { key: "{{anomalies_json}}", label: "告警明细", description: "JSON 格式 anomalies 列表" },
+    { key: "{{abs_min}}", label: "绝对下限", description: "absMin (0-1)" },
+    { key: "{{drop_rel}}", label: "相对跌幅阈值", description: "dropRel (0-1)" },
+    { key: "{{drop_abs}}", label: "绝对跌幅阈值", description: "dropAbs (0-1)" },
+    { key: "{{cooldown_minutes}}", label: "冷却分钟", description: "cooldownMinutes" },
+    { key: "{{top_n}}", label: "TopN", description: "topN" },
+    { key: "{{generated_at}}", label: "生成时间", description: "ISO 8601 格式" },
+  ],
 } as const satisfies Record<string, readonly TemplatePlaceholder[]>;
 
 export function getTemplatePlaceholders(
@@ -120,6 +136,24 @@ export function buildTemplateVariables(params: {
     values["{{usage_percent}}"] = buildUsagePercent(ca);
   }
 
+  if (notificationType === "cache_hit_rate_alert") {
+    const ch = data as Partial<CacheHitRateAlertData> | undefined;
+    values["{{window_mode}}"] = ch?.window?.mode ?? "";
+    values["{{window_start}}"] = ch?.window?.startTime ?? "";
+    values["{{window_end}}"] = ch?.window?.endTime ?? "";
+    values["{{anomaly_count}}"] = ch?.anomalies ? String(ch.anomalies.length) : "0";
+    values["{{suppressed_count}}"] =
+      ch?.suppressedCount !== undefined ? String(ch.suppressedCount) : "0";
+    values["{{anomalies_json}}"] = ch?.anomalies ? safeJsonStringify(ch.anomalies) : "[]";
+    values["{{abs_min}}"] = ch?.settings?.absMin !== undefined ? String(ch.settings.absMin) : "";
+    values["{{drop_rel}}"] = ch?.settings?.dropRel !== undefined ? String(ch.settings.dropRel) : "";
+    values["{{drop_abs}}"] = ch?.settings?.dropAbs !== undefined ? String(ch.settings.dropAbs) : "";
+    values["{{cooldown_minutes}}"] =
+      ch?.settings?.cooldownMinutes !== undefined ? String(ch.settings.cooldownMinutes) : "";
+    values["{{top_n}}"] = ch?.settings?.topN !== undefined ? String(ch.settings.topN) : "";
+    values["{{generated_at}}"] = ch?.generatedAt ?? "";
+  }
+
   return values;
 }
 

+ 53 - 0
src/lib/webhook/templates/test-messages.ts

@@ -1,5 +1,6 @@
 import type { NotificationJobType } from "@/lib/constants/notification.constants";
 import type { StructuredMessage } from "../types";
+import { buildCacheHitRateAlertMessage } from "./cache-hit-rate-alert";
 import { buildCircuitBreakerMessage } from "./circuit-breaker";
 import { buildCostAlertMessage } from "./cost-alert";
 import { buildDailyLeaderboardMessage } from "./daily-leaderboard";
@@ -46,5 +47,57 @@ export function buildTestMessage(type: NotificationJobType, timezone?: string):
         totalRequests: 270,
         totalCost: 22.7,
       });
+
+    case "cache-hit-rate-alert":
+      return buildCacheHitRateAlertMessage(
+        {
+          window: {
+            mode: "5m",
+            startTime: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
+            endTime: new Date().toISOString(),
+            durationMinutes: 5,
+          },
+          anomalies: [
+            {
+              providerId: 1,
+              providerName: "测试供应商",
+              providerType: "claude",
+              model: "test-model",
+              baselineSource: "historical",
+              current: {
+                kind: "eligible",
+                requests: 100,
+                denominatorTokens: 10000,
+                hitRateTokens: 0.12,
+              },
+              baseline: {
+                kind: "eligible",
+                requests: 100,
+                denominatorTokens: 10000,
+                hitRateTokens: 0.45,
+              },
+              deltaAbs: -0.33,
+              deltaRel: -0.7333,
+              dropAbs: 0.33,
+              reasonCodes: ["abs_min", "drop_abs_rel"],
+            },
+          ],
+          suppressedCount: 0,
+          settings: {
+            windowMode: "auto",
+            checkIntervalMinutes: 5,
+            historicalLookbackDays: 7,
+            minEligibleRequests: 20,
+            minEligibleTokens: 0,
+            absMin: 0.05,
+            dropRel: 0.3,
+            dropAbs: 0.1,
+            cooldownMinutes: 30,
+            topN: 10,
+          },
+          generatedAt: new Date().toISOString(),
+        },
+        timezone
+      );
   }
 }

+ 79 - 1
src/lib/webhook/types.ts

@@ -78,13 +78,91 @@ export interface CostAlertData {
   period: string;
 }
 
+export interface CacheHitRateAlertSample {
+  kind: "eligible" | "overall";
+  requests: number;
+  denominatorTokens: number;
+  hitRateTokens: number;
+}
+
+export type CacheHitRateAlertBaselineSource = "historical" | "today" | "prev" | null;
+
+export const CACHE_HIT_RATE_ALERT_SETTINGS_WINDOW_MODES = [
+  "auto",
+  "5m",
+  "30m",
+  "1h",
+  "1.5h",
+] as const;
+
+export type CacheHitRateAlertSettingsWindowMode =
+  (typeof CACHE_HIT_RATE_ALERT_SETTINGS_WINDOW_MODES)[number];
+
+export function isCacheHitRateAlertSettingsWindowMode(
+  value: unknown
+): value is CacheHitRateAlertSettingsWindowMode {
+  return (
+    typeof value === "string" &&
+    (CACHE_HIT_RATE_ALERT_SETTINGS_WINDOW_MODES as readonly string[]).includes(value)
+  );
+}
+
+export interface CacheHitRateAlertAnomaly {
+  providerId: number;
+  providerName?: string;
+  providerType?: string;
+  model: string;
+
+  baselineSource: CacheHitRateAlertBaselineSource;
+  current: CacheHitRateAlertSample;
+  baseline: CacheHitRateAlertSample | null;
+
+  deltaAbs: number | null;
+  deltaRel: number | null;
+  dropAbs: number | null;
+
+  reasonCodes: string[];
+}
+
+export interface CacheHitRateAlertWindow {
+  mode: CacheHitRateAlertSettingsWindowMode;
+  startTime: string;
+  endTime: string;
+  durationMinutes: number;
+}
+
+export interface CacheHitRateAlertSettingsSnapshot {
+  windowMode: CacheHitRateAlertSettingsWindowMode;
+  checkIntervalMinutes: number;
+  historicalLookbackDays: number;
+  minEligibleRequests: number;
+  minEligibleTokens: number;
+  absMin: number;
+  dropRel: number;
+  dropAbs: number;
+  cooldownMinutes: number;
+  topN: number;
+}
+
+export interface CacheHitRateAlertData {
+  window: CacheHitRateAlertWindow;
+  anomalies: CacheHitRateAlertAnomaly[];
+  suppressedCount: number;
+  settings: CacheHitRateAlertSettingsSnapshot;
+  generatedAt: string;
+}
+
 /**
  * Webhook 相关类型
  */
 
 export type ProviderType = "wechat" | "feishu" | "dingtalk" | "telegram" | "custom";
 
-export type WebhookNotificationType = "circuit_breaker" | "daily_leaderboard" | "cost_alert";
+export type WebhookNotificationType =
+  | "circuit_breaker"
+  | "daily_leaderboard"
+  | "cost_alert"
+  | "cache_hit_rate_alert";
 
 export interface WebhookTargetConfig {
   id?: number;

+ 360 - 0
src/repository/cache-hit-rate-alert.ts

@@ -0,0 +1,360 @@
+import "server-only";
+
+import { and, desc, eq, gte, isNull, lt, sql } from "drizzle-orm";
+import { alias } from "drizzle-orm/pg-core";
+import { db } from "@/drizzle/db";
+import { messageRequest, providers } from "@/drizzle/schema";
+import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions";
+import { getSystemSettings } from "@/repository/system-config";
+import type { ProviderType } from "@/types/provider";
+
+export interface TimeRange {
+  start: Date;
+  end: Date;
+}
+
+export type CacheHitRateAlertWindowMode = "rolling" | "strict";
+
+export interface CacheHitRateAlertQueryConfig {
+  /**
+   * rolling: 允许 prev request 落在 timeRange 之外(更适合滚动窗口)
+   * strict: 仅当 prev request 也在 timeRange 内时才计 eligible
+   */
+  windowMode?: CacheHitRateAlertWindowMode;
+  /** 2xx: 仅统计 2xx(推荐);all: 不筛状态码 */
+  statusCodeMode?: "2xx" | "all";
+  /**
+   * 当无法从 row(5m/1h 细分字段或 cacheTtlApplied)推断 TTL 时使用的 fallback。
+   * 优先级:ttlFallbackSecondsByProviderType > ttlFallbackSecondsDefault
+   */
+  ttlFallbackSecondsByProviderType?: Partial<Record<ProviderType, number>>;
+  ttlFallbackSecondsDefault?: number;
+}
+
+export interface ProviderModelCacheHitRateAlertMetric {
+  providerId: number;
+  providerType: ProviderType;
+  model: string;
+
+  totalRequests: number;
+  cacheSignalRequests: number;
+  cacheHitRequests: number;
+
+  sumInputTokens: number;
+  sumCacheCreationTokens: number;
+  sumCacheReadTokens: number;
+  denominatorTokens: number;
+
+  /** 与排行榜一致:cache_read / (input + cache_creation + cache_read) */
+  hitRateTokens: number;
+  engagementRate: number;
+
+  eligibleRequests: number;
+  eligibleDenominatorTokens: number;
+  eligibleCacheReadTokens: number;
+  hitRateTokensEligible: number;
+}
+
+function clampRate01(value: unknown): number {
+  if (typeof value !== "number" || !Number.isFinite(value)) return 0;
+  return Math.min(Math.max(value, 0), 1);
+}
+
+function normalizeTtlFallbackSeconds(config: CacheHitRateAlertQueryConfig): {
+  defaultSeconds: number;
+  byType: Record<ProviderType, number>;
+} {
+  const TTL_FALLBACK_DEFAULT_SECONDS = 3600;
+  const TTL_FALLBACK_MAX_SECONDS = 2_147_483_647; // int4 max,避免异常配置导致 SQL 参数溢出/无穷大
+
+  const toSafeTtlSeconds = (input: unknown, fallbackSeconds: number): number => {
+    if (typeof input !== "number" || !Number.isFinite(input) || !Number.isInteger(input)) {
+      return fallbackSeconds;
+    }
+    if (input < 0) return fallbackSeconds;
+    return Math.min(input, TTL_FALLBACK_MAX_SECONDS);
+  };
+
+  const defaultSeconds = toSafeTtlSeconds(
+    config.ttlFallbackSecondsDefault,
+    TTL_FALLBACK_DEFAULT_SECONDS
+  );
+  const base: Record<ProviderType, number> = {
+    claude: defaultSeconds,
+    "claude-auth": defaultSeconds,
+    codex: 6 * 3600,
+    gemini: defaultSeconds,
+    "gemini-cli": defaultSeconds,
+    "openai-compatible": 6 * 3600,
+  };
+  const overrides = config.ttlFallbackSecondsByProviderType ?? {};
+  const byType: Record<ProviderType, number> = { ...base };
+
+  for (const [key, rawSeconds] of Object.entries(overrides)) {
+    if (!Object.hasOwn(byType, key)) continue;
+    const providerType = key as ProviderType;
+    byType[providerType] = toSafeTtlSeconds(rawSeconds, byType[providerType]);
+  }
+
+  return {
+    defaultSeconds,
+    byType,
+  };
+}
+
+function normalizeStatusCodeMode(config: CacheHitRateAlertQueryConfig): "2xx" | "all" {
+  return config.statusCodeMode ?? "2xx";
+}
+
+function normalizeWindowMode(config: CacheHitRateAlertQueryConfig): CacheHitRateAlertWindowMode {
+  return config.windowMode ?? "rolling";
+}
+
+export async function findProviderModelCacheHitRateMetricsForAlert(
+  timeRange: TimeRange,
+  providerType?: ProviderType,
+  config: CacheHitRateAlertQueryConfig = {}
+): Promise<ProviderModelCacheHitRateAlertMetric[]> {
+  const startMs = timeRange.start.getTime();
+  const endMs = timeRange.end.getTime();
+  if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs >= endMs) {
+    return [];
+  }
+
+  const statusCodeMode = normalizeStatusCodeMode(config);
+  const windowMode = normalizeWindowMode(config);
+  const ttlFallback = normalizeTtlFallbackSeconds(config);
+
+  const systemSettings = await getSystemSettings();
+  const billingModelSource = systemSettings.billingModelSource;
+
+  const modelField =
+    billingModelSource === "original"
+      ? sql<string>`COALESCE(${messageRequest.originalModel}, ${messageRequest.model})`
+      : sql<string>`COALESCE(${messageRequest.model}, ${messageRequest.originalModel})`;
+
+  const prev = alias(messageRequest, "prev_message_request");
+  const prevExcludeWarmupCondition = sql`(${prev.blockedBy} IS NULL OR ${prev.blockedBy} <> 'warmup')`;
+
+  const cacheSignalCondition = sql`(
+    COALESCE(${messageRequest.cacheCreationInputTokens}, 0) > 0
+    OR COALESCE(${messageRequest.cacheReadInputTokens}, 0) > 0
+  )`;
+  const cacheHitCondition = sql`(COALESCE(${messageRequest.cacheReadInputTokens}, 0) > 0)`;
+
+  const hasSessionIdCondition = sql`(
+    ${messageRequest.sessionId} IS NOT NULL
+    AND btrim(${messageRequest.sessionId}) <> ''
+  )`;
+
+  const gapToPrevSecondsExpr = sql<
+    number | null
+  >`EXTRACT(EPOCH FROM (${messageRequest.createdAt} - ${prev.createdAt}))::double precision`;
+
+  // TTL fallback 映射只在 normalizeTtlFallbackSeconds() 维护一份;
+  // 这里的 SQL CASE 直接由 ttlFallback.byType 生成,避免 TS/SQL 双份维护产生漂移。
+  const ttlFallbackWhenClauses = (
+    Object.entries(ttlFallback.byType) as Array<[ProviderType, number]>
+  ).map(
+    ([providerType, seconds]) =>
+      sql`WHEN ${providers.providerType} = ${providerType} THEN ${seconds}`
+  );
+
+  const ttlFallbackSecondsExpr = sql<number>`CASE
+    ${sql.join(ttlFallbackWhenClauses, sql` `)}
+    ELSE ${ttlFallback.defaultSeconds}
+  END`;
+
+  // 重要:swap_cache_ttl_applied 仅用于“计费口径”的 5m/1h 翻转(见 Provider.swapCacheTtlBilling)。
+  // eligible/TTL/gap 属于“缓存语义口径”,这里需要把 5m/1h 还原回真实 TTL,再用于 gap<=TTL 的判断。
+  const cacheCreation5mTokensForTtlExpr = sql<number | null>`CASE
+    WHEN COALESCE(${messageRequest.swapCacheTtlApplied}, false) THEN ${messageRequest.cacheCreation1hInputTokens}
+    ELSE ${messageRequest.cacheCreation5mInputTokens}
+  END`;
+  const cacheCreation1hTokensForTtlExpr = sql<number | null>`CASE
+    WHEN COALESCE(${messageRequest.swapCacheTtlApplied}, false) THEN ${messageRequest.cacheCreation5mInputTokens}
+    ELSE ${messageRequest.cacheCreation1hInputTokens}
+  END`;
+  const cacheTtlAppliedForTtlExpr = sql<string | null>`CASE
+    WHEN COALESCE(${messageRequest.swapCacheTtlApplied}, false) THEN (
+      CASE
+        WHEN ${messageRequest.cacheTtlApplied} = '5m' THEN '1h'
+        WHEN ${messageRequest.cacheTtlApplied} = '1h' THEN '5m'
+        ELSE ${messageRequest.cacheTtlApplied}
+      END
+    )
+    ELSE ${messageRequest.cacheTtlApplied}
+  END`;
+
+  // cache_ttl_applied 理论上应是短字符串(例如 5m/1h/3600s),但数据库字段可能被
+  // 异常/恶意写入过大的数值,从而导致 ::int 或乘法溢出。本处对纯数字 TTL 做位数与范围
+  // 护栏:无效值统一回退到 ttlFallbackSecondsExpr,避免查询直接失败。
+  const ttlAppliedNumberTextExpr = sql<string>`substring(${cacheTtlAppliedForTtlExpr} from '^[0-9]+')`;
+  const ttlAppliedNumberMaxDigits = 9;
+  const ttlAppliedNumberMaxSeconds = 7 * 24 * 3600;
+  const ttlAppliedNumberMaxHours = Math.floor(ttlAppliedNumberMaxSeconds / 3600);
+  const ttlAppliedNumberMaxMinutes = Math.floor(ttlAppliedNumberMaxSeconds / 60);
+
+  const ttlSecondsExpr = sql<number>`CASE
+    WHEN COALESCE(${cacheCreation1hTokensForTtlExpr}, 0) > 0 THEN 3600
+    WHEN COALESCE(${cacheCreation5mTokensForTtlExpr}, 0) > 0 THEN 300
+    WHEN ${cacheTtlAppliedForTtlExpr} = '1h' THEN 3600
+    WHEN ${cacheTtlAppliedForTtlExpr} = '5m' THEN 300
+    WHEN ${cacheTtlAppliedForTtlExpr} = 'mixed' THEN 3600
+    WHEN ${cacheTtlAppliedForTtlExpr} ~ '^[0-9]+h$' THEN (
+      CASE
+        WHEN char_length(${ttlAppliedNumberTextExpr}) > ${ttlAppliedNumberMaxDigits}
+          THEN ${ttlFallbackSecondsExpr}
+        WHEN (${ttlAppliedNumberTextExpr})::int > ${ttlAppliedNumberMaxHours}
+          THEN ${ttlFallbackSecondsExpr}
+        ELSE (${ttlAppliedNumberTextExpr})::int * 3600
+      END
+    )
+    WHEN ${cacheTtlAppliedForTtlExpr} ~ '^[0-9]+m$' THEN (
+      CASE
+        WHEN char_length(${ttlAppliedNumberTextExpr}) > ${ttlAppliedNumberMaxDigits}
+          THEN ${ttlFallbackSecondsExpr}
+        WHEN (${ttlAppliedNumberTextExpr})::int > ${ttlAppliedNumberMaxMinutes}
+          THEN ${ttlFallbackSecondsExpr}
+        ELSE (${ttlAppliedNumberTextExpr})::int * 60
+      END
+    )
+    WHEN ${cacheTtlAppliedForTtlExpr} ~ '^[0-9]+s$' THEN (
+      CASE
+        WHEN char_length(${ttlAppliedNumberTextExpr}) > ${ttlAppliedNumberMaxDigits}
+          THEN ${ttlFallbackSecondsExpr}
+        WHEN (${ttlAppliedNumberTextExpr})::int > ${ttlAppliedNumberMaxSeconds}
+          THEN ${ttlFallbackSecondsExpr}
+        ELSE (${ttlAppliedNumberTextExpr})::int
+      END
+    )
+    ELSE ${ttlFallbackSecondsExpr}
+  END`;
+
+  const eligibleConditionsRaw = [
+    hasSessionIdCondition,
+    sql`${messageRequest.requestSequence} > 1`,
+    sql`${prev.createdAt} IS NOT NULL`,
+    windowMode === "strict"
+      ? sql`(${prev.createdAt} >= ${timeRange.start} AND ${prev.createdAt} < ${timeRange.end})`
+      : undefined,
+    sql`${gapToPrevSecondsExpr} >= 0::double precision`,
+    sql`${gapToPrevSecondsExpr} <= (${ttlSecondsExpr})::double precision`,
+  ] as const;
+
+  const eligibleConditions = eligibleConditionsRaw.filter(
+    (c): c is NonNullable<(typeof eligibleConditionsRaw)[number]> => !!c
+  );
+
+  const eligibleCondition = and(...eligibleConditions);
+
+  const denominatorTokensExpr = sql<number>`(
+    COALESCE(${messageRequest.inputTokens}, 0)::double precision +
+    COALESCE(${messageRequest.cacheCreationInputTokens}, 0)::double precision +
+    COALESCE(${messageRequest.cacheReadInputTokens}, 0)::double precision
+  )`;
+
+  const totalRequestsExpr = sql<number>`count(*)::double precision`;
+  const cacheSignalRequestsExpr = sql<number>`count(*) FILTER (WHERE ${cacheSignalCondition})::double precision`;
+  const cacheHitRequestsExpr = sql<number>`count(*) FILTER (WHERE ${cacheHitCondition})::double precision`;
+
+  const sumInputTokensExpr = sql<number>`COALESCE(sum(COALESCE(${messageRequest.inputTokens}, 0))::double precision, 0::double precision)`;
+  const sumCacheCreationTokensExpr = sql<number>`COALESCE(sum(COALESCE(${messageRequest.cacheCreationInputTokens}, 0))::double precision, 0::double precision)`;
+  const sumCacheReadTokensExpr = sql<number>`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`;
+  const sumDenominatorTokensExpr = sql<number>`COALESCE(sum(${denominatorTokensExpr})::double precision, 0::double precision)`;
+
+  const hitRateTokensExpr = sql<number>`COALESCE(
+    ${sumCacheReadTokensExpr} / NULLIF(${sumDenominatorTokensExpr}, 0::double precision),
+    0::double precision
+  )`;
+
+  const engagementRateExpr = sql<number>`COALESCE(
+    ${cacheSignalRequestsExpr} / NULLIF(${totalRequestsExpr}, 0::double precision),
+    0::double precision
+  )`;
+
+  const eligibleRequestsExpr = sql<number>`count(*) FILTER (WHERE ${eligibleCondition})::double precision`;
+  const eligibleDenominatorTokensExpr = sql<number>`COALESCE(
+    sum(${denominatorTokensExpr}) FILTER (WHERE ${eligibleCondition})::double precision,
+    0::double precision
+  )`;
+  const eligibleCacheReadTokensExpr = sql<number>`COALESCE(
+    sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0)) FILTER (WHERE ${eligibleCondition})::double precision,
+    0::double precision
+  )`;
+  const hitRateTokensEligibleExpr = sql<number>`COALESCE(
+    ${eligibleCacheReadTokensExpr} / NULLIF(${eligibleDenominatorTokensExpr}, 0::double precision),
+    0::double precision
+  )`;
+
+  const whereConditionsRaw = [
+    isNull(messageRequest.deletedAt),
+    EXCLUDE_WARMUP_CONDITION,
+    gte(messageRequest.createdAt, timeRange.start),
+    lt(messageRequest.createdAt, timeRange.end),
+    providerType ? eq(providers.providerType, providerType) : undefined,
+    statusCodeMode === "2xx" ? gte(messageRequest.statusCode, 200) : undefined,
+    statusCodeMode === "2xx" ? lt(messageRequest.statusCode, 300) : undefined,
+    sql`${modelField} IS NOT NULL AND btrim(${modelField}) <> ''`,
+  ] as const;
+
+  const whereConditions = whereConditionsRaw.filter(
+    (c): c is NonNullable<(typeof whereConditionsRaw)[number]> => !!c
+  );
+
+  const rows = await db
+    .select({
+      providerId: messageRequest.providerId,
+      providerType: providers.providerType,
+      model: modelField,
+      totalRequests: totalRequestsExpr,
+      cacheSignalRequests: cacheSignalRequestsExpr,
+      cacheHitRequests: cacheHitRequestsExpr,
+      sumInputTokens: sumInputTokensExpr,
+      sumCacheCreationTokens: sumCacheCreationTokensExpr,
+      sumCacheReadTokens: sumCacheReadTokensExpr,
+      denominatorTokens: sumDenominatorTokensExpr,
+      hitRateTokens: hitRateTokensExpr,
+      engagementRate: engagementRateExpr,
+      eligibleRequests: eligibleRequestsExpr,
+      eligibleDenominatorTokens: eligibleDenominatorTokensExpr,
+      eligibleCacheReadTokens: eligibleCacheReadTokensExpr,
+      hitRateTokensEligible: hitRateTokensEligibleExpr,
+    })
+    .from(messageRequest)
+    .innerJoin(
+      providers,
+      and(eq(messageRequest.providerId, providers.id), isNull(providers.deletedAt))
+    )
+    .leftJoin(
+      prev,
+      and(
+        eq(prev.sessionId, messageRequest.sessionId),
+        eq(prev.requestSequence, sql<number>`(${messageRequest.requestSequence} - 1)`),
+        isNull(prev.deletedAt),
+        prevExcludeWarmupCondition
+      )
+    )
+    .where(and(...whereConditions))
+    .groupBy(messageRequest.providerId, providers.providerType, modelField)
+    .orderBy(desc(totalRequestsExpr));
+
+  return rows.map((row) => ({
+    providerId: row.providerId,
+    providerType: row.providerType,
+    model: row.model,
+    totalRequests: row.totalRequests,
+    cacheSignalRequests: row.cacheSignalRequests,
+    cacheHitRequests: row.cacheHitRequests,
+    sumInputTokens: row.sumInputTokens,
+    sumCacheCreationTokens: row.sumCacheCreationTokens,
+    sumCacheReadTokens: row.sumCacheReadTokens,
+    denominatorTokens: row.denominatorTokens,
+    hitRateTokens: clampRate01(row.hitRateTokens),
+    engagementRate: clampRate01(row.engagementRate),
+    eligibleRequests: row.eligibleRequests,
+    eligibleDenominatorTokens: row.eligibleDenominatorTokens,
+    eligibleCacheReadTokens: row.eligibleCacheReadTokens,
+    hitRateTokensEligible: clampRate01(row.hitRateTokensEligible),
+  }));
+}

+ 5 - 1
src/repository/notification-bindings.ts

@@ -6,7 +6,11 @@ import { notificationTargetBindings, webhookTargets } from "@/drizzle/schema";
 import { resolveSystemTimezone } from "@/lib/utils/timezone";
 import type { WebhookProviderType, WebhookTarget, WebhookTestResult } from "./webhook-targets";
 
-export type NotificationType = "circuit_breaker" | "daily_leaderboard" | "cost_alert";
+export type NotificationType =
+  | "circuit_breaker"
+  | "daily_leaderboard"
+  | "cost_alert"
+  | "cache_hit_rate_alert";
 
 export interface NotificationBinding {
   id: number;

+ 119 - 0
src/repository/notifications.ts

@@ -4,6 +4,10 @@ import { eq } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import { notificationSettings } from "@/drizzle/schema";
 import { logger } from "@/lib/logger";
+import {
+  type CacheHitRateAlertSettingsWindowMode,
+  isCacheHitRateAlertSettingsWindowMode,
+} from "@/lib/webhook/types";
 
 /**
  * 通知设置类型
@@ -29,6 +33,20 @@ export interface NotificationSettings {
   costAlertThreshold: string | null; // numeric 类型作为 string
   costAlertCheckInterval: number | null;
 
+  // 缓存命中率异常告警配置(provider × model)
+  cacheHitRateAlertEnabled: boolean;
+  cacheHitRateAlertWebhook: string | null;
+  cacheHitRateAlertWindowMode: CacheHitRateAlertSettingsWindowMode | null;
+  cacheHitRateAlertCheckInterval: number | null;
+  cacheHitRateAlertHistoricalLookbackDays: number | null;
+  cacheHitRateAlertMinEligibleRequests: number | null;
+  cacheHitRateAlertMinEligibleTokens: number | null;
+  cacheHitRateAlertAbsMin: string | null; // numeric 类型作为 string
+  cacheHitRateAlertDropRel: string | null; // numeric 类型作为 string
+  cacheHitRateAlertDropAbs: string | null; // numeric 类型作为 string
+  cacheHitRateAlertCooldownMinutes: number | null;
+  cacheHitRateAlertTopN: number | null;
+
   createdAt: Date;
   updatedAt: Date;
 }
@@ -52,6 +70,19 @@ export interface UpdateNotificationSettingsInput {
   costAlertWebhook?: string | null;
   costAlertThreshold?: string;
   costAlertCheckInterval?: number;
+
+  cacheHitRateAlertEnabled?: boolean;
+  cacheHitRateAlertWebhook?: string | null;
+  cacheHitRateAlertWindowMode?: CacheHitRateAlertSettingsWindowMode;
+  cacheHitRateAlertCheckInterval?: number;
+  cacheHitRateAlertHistoricalLookbackDays?: number;
+  cacheHitRateAlertMinEligibleRequests?: number;
+  cacheHitRateAlertMinEligibleTokens?: number;
+  cacheHitRateAlertAbsMin?: string;
+  cacheHitRateAlertDropRel?: string;
+  cacheHitRateAlertDropAbs?: string;
+  cacheHitRateAlertCooldownMinutes?: number;
+  cacheHitRateAlertTopN?: number;
 }
 
 /**
@@ -182,6 +213,14 @@ function isColumnMissingError(error: unknown, depth = 0): boolean {
   return false;
 }
 
+function normalizeCacheHitRateAlertWindowMode(
+  value: unknown
+): CacheHitRateAlertSettingsWindowMode | null {
+  if (value === null) return null;
+  if (isCacheHitRateAlertSettingsWindowMode(value)) return value;
+  return "auto";
+}
+
 /**
  * 创建默认通知设置
  */
@@ -201,6 +240,19 @@ function createFallbackSettings(): NotificationSettings {
     costAlertWebhook: null,
     costAlertThreshold: "0.80",
     costAlertCheckInterval: 60,
+
+    cacheHitRateAlertEnabled: false,
+    cacheHitRateAlertWebhook: null,
+    cacheHitRateAlertWindowMode: "auto",
+    cacheHitRateAlertCheckInterval: 5,
+    cacheHitRateAlertHistoricalLookbackDays: 7,
+    cacheHitRateAlertMinEligibleRequests: 20,
+    cacheHitRateAlertMinEligibleTokens: 0,
+    cacheHitRateAlertAbsMin: "0.05",
+    cacheHitRateAlertDropRel: "0.3",
+    cacheHitRateAlertDropAbs: "0.1",
+    cacheHitRateAlertCooldownMinutes: 30,
+    cacheHitRateAlertTopN: 10,
     createdAt: now,
     updatedAt: now,
   };
@@ -217,6 +269,10 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
       return {
         ...settings,
         useLegacyMode: settings.useLegacyMode ?? false,
+        cacheHitRateAlertEnabled: settings.cacheHitRateAlertEnabled ?? false,
+        cacheHitRateAlertWindowMode: normalizeCacheHitRateAlertWindowMode(
+          settings.cacheHitRateAlertWindowMode
+        ),
         createdAt: settings.createdAt ?? new Date(),
         updatedAt: settings.updatedAt ?? new Date(),
       };
@@ -234,6 +290,17 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
         costAlertEnabled: false,
         costAlertThreshold: "0.80",
         costAlertCheckInterval: 60,
+        cacheHitRateAlertEnabled: false,
+        cacheHitRateAlertWindowMode: "auto",
+        cacheHitRateAlertCheckInterval: 5,
+        cacheHitRateAlertHistoricalLookbackDays: 7,
+        cacheHitRateAlertMinEligibleRequests: 20,
+        cacheHitRateAlertMinEligibleTokens: 0,
+        cacheHitRateAlertAbsMin: "0.05",
+        cacheHitRateAlertDropRel: "0.3",
+        cacheHitRateAlertDropAbs: "0.1",
+        cacheHitRateAlertCooldownMinutes: 30,
+        cacheHitRateAlertTopN: 10,
       })
       .onConflictDoNothing()
       .returning();
@@ -242,6 +309,10 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
       return {
         ...created,
         useLegacyMode: created.useLegacyMode ?? false,
+        cacheHitRateAlertEnabled: created.cacheHitRateAlertEnabled ?? false,
+        cacheHitRateAlertWindowMode: normalizeCacheHitRateAlertWindowMode(
+          created.cacheHitRateAlertWindowMode
+        ),
         createdAt: created.createdAt ?? new Date(),
         updatedAt: created.updatedAt ?? new Date(),
       };
@@ -257,6 +328,10 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
     return {
       ...fallback,
       useLegacyMode: fallback.useLegacyMode ?? false,
+      cacheHitRateAlertEnabled: fallback.cacheHitRateAlertEnabled ?? false,
+      cacheHitRateAlertWindowMode: normalizeCacheHitRateAlertWindowMode(
+        fallback.cacheHitRateAlertWindowMode
+      ),
       createdAt: fallback.createdAt ?? new Date(),
       updatedAt: fallback.updatedAt ?? new Date(),
     };
@@ -331,6 +406,47 @@ export async function updateNotificationSettings(
       updates.costAlertCheckInterval = payload.costAlertCheckInterval;
     }
 
+    // 缓存命中率异常告警配置
+    if (payload.cacheHitRateAlertEnabled !== undefined) {
+      updates.cacheHitRateAlertEnabled = payload.cacheHitRateAlertEnabled;
+    }
+    if (payload.cacheHitRateAlertWebhook !== undefined) {
+      updates.cacheHitRateAlertWebhook = payload.cacheHitRateAlertWebhook;
+    }
+    if (payload.cacheHitRateAlertWindowMode !== undefined) {
+      updates.cacheHitRateAlertWindowMode = normalizeCacheHitRateAlertWindowMode(
+        payload.cacheHitRateAlertWindowMode
+      );
+    }
+    if (payload.cacheHitRateAlertCheckInterval !== undefined) {
+      updates.cacheHitRateAlertCheckInterval = payload.cacheHitRateAlertCheckInterval;
+    }
+    if (payload.cacheHitRateAlertHistoricalLookbackDays !== undefined) {
+      updates.cacheHitRateAlertHistoricalLookbackDays =
+        payload.cacheHitRateAlertHistoricalLookbackDays;
+    }
+    if (payload.cacheHitRateAlertMinEligibleRequests !== undefined) {
+      updates.cacheHitRateAlertMinEligibleRequests = payload.cacheHitRateAlertMinEligibleRequests;
+    }
+    if (payload.cacheHitRateAlertMinEligibleTokens !== undefined) {
+      updates.cacheHitRateAlertMinEligibleTokens = payload.cacheHitRateAlertMinEligibleTokens;
+    }
+    if (payload.cacheHitRateAlertAbsMin !== undefined) {
+      updates.cacheHitRateAlertAbsMin = payload.cacheHitRateAlertAbsMin;
+    }
+    if (payload.cacheHitRateAlertDropRel !== undefined) {
+      updates.cacheHitRateAlertDropRel = payload.cacheHitRateAlertDropRel;
+    }
+    if (payload.cacheHitRateAlertDropAbs !== undefined) {
+      updates.cacheHitRateAlertDropAbs = payload.cacheHitRateAlertDropAbs;
+    }
+    if (payload.cacheHitRateAlertCooldownMinutes !== undefined) {
+      updates.cacheHitRateAlertCooldownMinutes = payload.cacheHitRateAlertCooldownMinutes;
+    }
+    if (payload.cacheHitRateAlertTopN !== undefined) {
+      updates.cacheHitRateAlertTopN = payload.cacheHitRateAlertTopN;
+    }
+
     const [updated] = await db
       .update(notificationSettings)
       .set(updates)
@@ -343,6 +459,9 @@ export async function updateNotificationSettings(
 
     return {
       ...updated,
+      cacheHitRateAlertWindowMode: normalizeCacheHitRateAlertWindowMode(
+        updated.cacheHitRateAlertWindowMode
+      ),
       createdAt: updated.createdAt ?? new Date(),
       updatedAt: updated.updatedAt ?? new Date(),
     };

+ 2 - 3
src/repository/provider.ts

@@ -896,12 +896,11 @@ export async function updateProviderPrioritiesBatch(
       ${priorityCol} = CASE id ${sql.join(cases, sql` `)} ELSE ${priorityCol} END,
       ${updatedAtCol} = NOW()
     WHERE id IN (${idList}) AND deleted_at IS NULL
+    RETURNING id
   `;
 
   const result = await db.execute(query);
-
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  return Number((result as any).count) || 0;
+  return Array.from(result).length;
 }
 
 export async function deleteProvider(id: number): Promise<boolean> {

+ 37 - 47
tests/unit/components/form/client-restrictions-editor.test.tsx

@@ -7,9 +7,13 @@ import { act } from "react";
 import { createRoot } from "react-dom/client";
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
-vi.mock("@/lib/client-restrictions/client-presets", () => ({
-  CLIENT_RESTRICTION_PRESET_OPTIONS: [],
-}));
+vi.mock("@/lib/client-restrictions/client-presets", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/lib/client-restrictions/client-presets")>();
+  return {
+    ...actual,
+    CLIENT_RESTRICTION_PRESET_OPTIONS: [],
+  };
+});
 
 vi.mock("@/components/ui/tag-input", () => ({
   TagInput: vi.fn(() => null),
@@ -33,11 +37,15 @@ function render(node: ReactNode) {
 
 type TagInputProps = { onChange: (v: string[]) => void; value: string[] };
 
-function getTagInputOnChange(callIndex: number): (values: string[]) => void {
+function getTagInputProps(callIndex: number): TagInputProps {
   const calls = vi.mocked(TagInput).mock.calls;
   const call = calls[callIndex];
   if (!call) throw new Error(`TagInput call ${callIndex} not found (got ${calls.length} calls)`);
-  return (call[0] as TagInputProps).onChange;
+  return call[0] as TagInputProps;
+}
+
+function getTagInputOnChange(callIndex: number): (values: string[]) => void {
+  return getTagInputProps(callIndex).onChange;
 }
 
 describe("ClientRestrictionsEditor", () => {
@@ -61,9 +69,16 @@ describe("ClientRestrictionsEditor", () => {
         blocked={blocked}
         onAllowedChange={onAllowedChange}
         onBlockedChange={onBlockedChange}
-        allowedLabel="Allowed"
-        blockedLabel="Blocked"
-        getPresetLabel={(v) => v}
+        translations={{
+          allowAction: "允许",
+          blockAction: "阻止",
+          customAllowedLabel: "自定义允许",
+          customAllowedPlaceholder: "",
+          customBlockedLabel: "自定义阻止",
+          customBlockedPlaceholder: "",
+          customHelp: "",
+          presetClients: {},
+        }}
       />
     );
   }
@@ -76,59 +91,34 @@ describe("ClientRestrictionsEditor", () => {
       unmount();
     });
 
-    it("trims whitespace from values", () => {
-      const unmount = renderEditor([], []);
-      act(() => getTagInputOnChange(0)(["  a  ", " b", "c "]));
-      expect(onAllowedChange).toHaveBeenCalledWith(["a", "b", "c"]);
-      unmount();
-    });
+    it("preserves preset aliases and filters them out from custom input", () => {
+      const unmount = renderEditor(["claude-code-cli", "my-ide"], []);
+      expect(getTagInputProps(0).value).toEqual(["my-ide"]);
 
-    it("filters out empty and whitespace-only entries", () => {
-      const unmount = renderEditor([], []);
-      act(() => getTagInputOnChange(0)(["a", "", "  ", "b"]));
-      expect(onAllowedChange).toHaveBeenCalledWith(["a", "b"]);
-      unmount();
-    });
-  });
+      act(() => getTagInputOnChange(0)(["next-ide", "claude-code-cli"]));
+      expect(onAllowedChange).toHaveBeenCalledWith(["claude-code-cli", "next-ide"]);
 
-  describe("allow/block mutual exclusion", () => {
-    it("removes overlapping items from blocked when added to allowed", () => {
-      const unmount = renderEditor([], ["b", "c"]);
-      act(() => getTagInputOnChange(0)(["a", "b"]));
-      expect(onAllowedChange).toHaveBeenCalledWith(["a", "b"]);
-      expect(onBlockedChange).toHaveBeenCalledWith(["c"]);
       unmount();
     });
 
-    it("does not call onBlockedChange when allowed has no overlap with blocked", () => {
-      const unmount = renderEditor([], ["c", "d"]);
+    it("does not change blocked values when editing allowed custom values", () => {
+      const unmount = renderEditor([], ["b", "c"]);
       act(() => getTagInputOnChange(0)(["a", "b"]));
       expect(onAllowedChange).toHaveBeenCalledWith(["a", "b"]);
       expect(onBlockedChange).not.toHaveBeenCalled();
       unmount();
     });
+  });
 
-    it("removes overlapping items from allowed when added to blocked", () => {
-      const unmount = renderEditor(["a", "b"], []);
-      act(() => getTagInputOnChange(1)(["b", "c"]));
-      expect(onBlockedChange).toHaveBeenCalledWith(["b", "c"]);
-      expect(onAllowedChange).toHaveBeenCalledWith(["a"]);
-      unmount();
-    });
+  describe("custom blocked field", () => {
+    it("preserves preset aliases and filters them out from custom input", () => {
+      const unmount = renderEditor([], ["claude-code-vscode", "blocked-ide"]);
+      expect(getTagInputProps(1).value).toEqual(["blocked-ide"]);
 
-    it("does not call onAllowedChange when blocked has no overlap with allowed", () => {
-      const unmount = renderEditor(["a", "b"], []);
-      act(() => getTagInputOnChange(1)(["c", "d"]));
-      expect(onBlockedChange).toHaveBeenCalledWith(["c", "d"]);
+      act(() => getTagInputOnChange(1)(["next-blocked", "claude-code-vscode"]));
+      expect(onBlockedChange).toHaveBeenCalledWith(["claude-code-vscode", "next-blocked"]);
       expect(onAllowedChange).not.toHaveBeenCalled();
-      unmount();
-    });
 
-    it("clears all blocked when all items are moved to allowed", () => {
-      const unmount = renderEditor([], ["x", "y"]);
-      act(() => getTagInputOnChange(0)(["x", "y", "z"]));
-      expect(onAllowedChange).toHaveBeenCalledWith(["x", "y", "z"]);
-      expect(onBlockedChange).toHaveBeenCalledWith([]);
       unmount();
     });
   });

+ 312 - 0
tests/unit/lib/cache-hit-rate-alert/cooldown-dedup.test.ts

@@ -0,0 +1,312 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { getRedisClient } from "@/lib/redis/client";
+import type { CacheHitRateAlertData, CacheHitRateAlertSample } from "@/lib/webhook";
+import {
+  applyCacheHitRateAlertCooldownToPayload,
+  buildCacheHitRateAlertCooldownKey,
+} from "@/lib/notification/tasks/cache-hit-rate-alert";
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis/client", () => ({
+  getRedisClient: vi.fn(),
+}));
+
+type RedisMock = {
+  mget: ReturnType<typeof vi.fn>;
+};
+
+function createRedisMock(): RedisMock {
+  return {
+    mget: vi.fn(),
+  };
+}
+
+function sample(hitRateTokens: number): CacheHitRateAlertSample {
+  return {
+    kind: "eligible",
+    requests: 10,
+    denominatorTokens: 1000,
+    hitRateTokens,
+  };
+}
+
+function payload(anomalies: CacheHitRateAlertData["anomalies"]): CacheHitRateAlertData {
+  return {
+    window: {
+      mode: "5m",
+      startTime: "2026-02-25T00:00:00.000Z",
+      endTime: "2026-02-25T00:05:00.000Z",
+      durationMinutes: 5,
+    },
+    anomalies,
+    suppressedCount: 0,
+    settings: {
+      windowMode: "5m",
+      checkIntervalMinutes: 5,
+      historicalLookbackDays: 7,
+      minEligibleRequests: 20,
+      minEligibleTokens: 0,
+      absMin: 0.05,
+      dropRel: 0.3,
+      dropAbs: 0.1,
+      cooldownMinutes: 30,
+      topN: 10,
+    },
+    generatedAt: "2026-02-25T00:05:00.000Z",
+  };
+}
+
+describe("cache-hit-rate-alert cooldown dedup", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("builds binding-scoped keys when bindingId is provided", () => {
+    const globalKey = buildCacheHitRateAlertCooldownKey({
+      providerId: 1,
+      model: "m",
+      windowMode: "5m",
+    });
+    const bindingKey = buildCacheHitRateAlertCooldownKey({
+      providerId: 1,
+      model: "m",
+      windowMode: "5m",
+      bindingId: 42,
+    });
+
+    const encodedModel = Buffer.from("m", "utf8").toString("base64url");
+
+    const globalParts = globalKey.split(":");
+    expect(globalParts).toHaveLength(5);
+    expect(globalParts.slice(0, 2)).toEqual(["cache-hit-rate-alert", "v1"]);
+    expect(globalParts[2]).toBe("1");
+    expect(globalParts[3]).toBe(encodedModel);
+    expect(globalParts[4]).toBe("5m");
+
+    const bindingParts = bindingKey.split(":");
+    expect(bindingParts).toHaveLength(7);
+    expect(bindingParts.slice(0, 4)).toEqual(["cache-hit-rate-alert", "v1", "binding", "42"]);
+    expect(bindingParts[4]).toBe("1");
+    expect(bindingParts[5]).toBe(encodedModel);
+    expect(bindingParts[6]).toBe("5m");
+    expect(bindingKey).not.toEqual(globalKey);
+  });
+
+  it("filters suppressed anomalies per binding", async () => {
+    const redis = createRedisMock();
+    redis.mget.mockResolvedValueOnce(["1", null]);
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    const input = {
+      ...payload([
+        {
+          providerId: 1,
+          model: "m1",
+          baselineSource: "prev",
+          current: sample(0.5),
+          baseline: sample(0.8),
+          deltaAbs: -0.3,
+          deltaRel: -0.375,
+          dropAbs: 0.3,
+          reasonCodes: ["drop_abs_rel"],
+        },
+        {
+          providerId: 2,
+          model: "m2",
+          baselineSource: "prev",
+          current: sample(0.5),
+          baseline: sample(0.8),
+          deltaAbs: -0.3,
+          deltaRel: -0.375,
+          dropAbs: 0.3,
+          reasonCodes: ["drop_abs_rel"],
+        },
+      ]),
+      suppressedCount: 2,
+    };
+
+    const result = await applyCacheHitRateAlertCooldownToPayload({ payload: input, bindingId: 7 });
+
+    expect(redis.mget).toHaveBeenCalledTimes(1);
+    const passedKeys = redis.mget.mock.calls[0];
+    expect(passedKeys).toHaveLength(2);
+    expect(passedKeys[0]).toContain(":binding:7:");
+    expect(passedKeys[1]).toContain(":binding:7:");
+
+    expect(result.suppressedCount).toBe(1);
+    expect(result.payload.suppressedCount).toBe(3);
+    expect(result.payload.anomalies).toHaveLength(1);
+    expect(result.payload.anomalies[0].providerId).toBe(2);
+
+    expect(result.dedupKeysToSet).toHaveLength(1);
+    expect(result.dedupKeysToSet[0]).toBe(passedKeys[1]);
+  });
+
+  it("returns all anomalies when cooldownMinutes=0 (no Redis)", async () => {
+    const input = {
+      ...payload([
+        {
+          providerId: 1,
+          model: "m1",
+          baselineSource: "prev",
+          current: sample(0.5),
+          baseline: sample(0.8),
+          deltaAbs: -0.3,
+          deltaRel: -0.375,
+          dropAbs: 0.3,
+          reasonCodes: ["drop_abs_rel"],
+        },
+      ]),
+      settings: {
+        ...payload([]).settings,
+        cooldownMinutes: 0,
+      },
+    };
+
+    const result = await applyCacheHitRateAlertCooldownToPayload({ payload: input, bindingId: 7 });
+
+    expect(getRedisClient).not.toHaveBeenCalled();
+    expect(result.suppressedCount).toBe(0);
+    expect(result.payload.anomalies).toHaveLength(1);
+    expect(result.dedupKeysToSet).toHaveLength(0);
+  });
+
+  it("returns all anomalies when Redis is unavailable (null client)", async () => {
+    vi.mocked(getRedisClient).mockReturnValue(null);
+
+    const input = payload([
+      {
+        providerId: 1,
+        model: "m1",
+        baselineSource: "prev",
+        current: sample(0.5),
+        baseline: sample(0.8),
+        deltaAbs: -0.3,
+        deltaRel: -0.375,
+        dropAbs: 0.3,
+        reasonCodes: ["drop_abs_rel"],
+      },
+    ]);
+
+    const result = await applyCacheHitRateAlertCooldownToPayload({ payload: input, bindingId: 7 });
+
+    expect(getRedisClient).toHaveBeenCalledTimes(1);
+    expect(result.suppressedCount).toBe(0);
+    expect(result.payload.anomalies).toHaveLength(1);
+    expect(result.dedupKeysToSet).toHaveLength(1);
+    expect(result.dedupKeysToSet[0]).toContain(":binding:7:");
+  });
+
+  it("returns all anomalies when redis.mget throws (best-effort dedup)", async () => {
+    const redis = createRedisMock();
+    redis.mget.mockRejectedValueOnce(new Error("boom"));
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    const input = payload([
+      {
+        providerId: 1,
+        model: "m1",
+        baselineSource: "prev",
+        current: sample(0.5),
+        baseline: sample(0.8),
+        deltaAbs: -0.3,
+        deltaRel: -0.375,
+        dropAbs: 0.3,
+        reasonCodes: ["drop_abs_rel"],
+      },
+      {
+        providerId: 2,
+        model: "m2",
+        baselineSource: "prev",
+        current: sample(0.5),
+        baseline: sample(0.8),
+        deltaAbs: -0.3,
+        deltaRel: -0.375,
+        dropAbs: 0.3,
+        reasonCodes: ["drop_abs_rel"],
+      },
+    ]);
+
+    const result = await applyCacheHitRateAlertCooldownToPayload({ payload: input, bindingId: 7 });
+
+    expect(redis.mget).toHaveBeenCalledTimes(1);
+    const passedKeys = redis.mget.mock.calls[0];
+    expect(passedKeys).toHaveLength(2);
+    expect(passedKeys[0]).toContain(":binding:7:");
+    expect(passedKeys[1]).toContain(":binding:7:");
+
+    expect(result.suppressedCount).toBe(0);
+    expect(result.payload.suppressedCount).toBe(0);
+    expect(result.payload.anomalies).toHaveLength(2);
+    expect(result.dedupKeysToSet).toEqual(passedKeys);
+  });
+
+  it("handles empty anomalies list", async () => {
+    const input = payload([]);
+
+    const result = await applyCacheHitRateAlertCooldownToPayload({ payload: input, bindingId: 7 });
+
+    expect(getRedisClient).not.toHaveBeenCalled();
+    expect(result.suppressedCount).toBe(0);
+    expect(result.payload.anomalies).toHaveLength(0);
+    expect(result.dedupKeysToSet).toHaveLength(0);
+  });
+
+  it("suppresses all anomalies when Redis reports all keys present", async () => {
+    const redis = createRedisMock();
+    redis.mget.mockResolvedValueOnce(["1", "1"]);
+    vi.mocked(getRedisClient).mockReturnValue(
+      redis as unknown as NonNullable<ReturnType<typeof getRedisClient>>
+    );
+
+    const input = payload([
+      {
+        providerId: 1,
+        model: "m1",
+        baselineSource: "prev",
+        current: sample(0.5),
+        baseline: sample(0.8),
+        deltaAbs: -0.3,
+        deltaRel: -0.375,
+        dropAbs: 0.3,
+        reasonCodes: ["drop_abs_rel"],
+      },
+      {
+        providerId: 2,
+        model: "m2",
+        baselineSource: "prev",
+        current: sample(0.5),
+        baseline: sample(0.8),
+        deltaAbs: -0.3,
+        deltaRel: -0.375,
+        dropAbs: 0.3,
+        reasonCodes: ["drop_abs_rel"],
+      },
+    ]);
+
+    const result = await applyCacheHitRateAlertCooldownToPayload({ payload: input, bindingId: 7 });
+
+    expect(redis.mget).toHaveBeenCalledTimes(1);
+    const passedKeys = redis.mget.mock.calls[0];
+    expect(passedKeys).toHaveLength(2);
+    expect(passedKeys[0]).toContain(":binding:7:");
+    expect(passedKeys[1]).toContain(":binding:7:");
+
+    expect(result.suppressedCount).toBe(2);
+    expect(result.payload.suppressedCount).toBe(2);
+    expect(result.payload.anomalies).toHaveLength(0);
+    expect(result.dedupKeysToSet).toHaveLength(0);
+  });
+});

+ 496 - 0
tests/unit/lib/cache-hit-rate-alert/decision.test.ts

@@ -0,0 +1,496 @@
+import { describe, expect, it } from "vitest";
+import {
+  decideCacheHitRateAnomalies,
+  type CacheHitRateAlertMetric,
+  type CacheHitRateAlertDecisionSettings,
+} from "@/lib/cache-hit-rate-alert/decision";
+
+function metric(
+  input: Partial<CacheHitRateAlertMetric> & { providerId: number; model: string }
+): CacheHitRateAlertMetric {
+  return {
+    providerId: input.providerId,
+    model: input.model,
+    totalRequests: input.totalRequests ?? 100,
+    denominatorTokens: input.denominatorTokens ?? 10000,
+    hitRateTokens: input.hitRateTokens ?? 0,
+    eligibleRequests: input.eligibleRequests ?? 100,
+    eligibleDenominatorTokens: input.eligibleDenominatorTokens ?? 10000,
+    hitRateTokensEligible: input.hitRateTokensEligible ?? input.hitRateTokens ?? 0,
+  };
+}
+
+const defaultSettings: CacheHitRateAlertDecisionSettings = {
+  absMin: 0.05,
+  dropRel: 0.3,
+  dropAbs: 0.1,
+  minEligibleRequests: 20,
+  minEligibleTokens: 0,
+  topN: 10,
+};
+
+describe("decideCacheHitRateAnomalies", () => {
+  it("should return empty when topN is 0", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01, topN: 0 },
+    });
+
+    expect(anomalies).toHaveLength(0);
+  });
+
+  it("should prefer historical baseline over today/prev", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.4 })],
+      today: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.35 })],
+      historical: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
+      settings: defaultSettings,
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].baselineSource).toBe("historical");
+  });
+
+  it("should fall back to today baseline when historical kind-sample is insufficient", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.4 })],
+      today: [
+        metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5, eligibleRequests: 50 }),
+      ],
+      historical: [
+        metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.9, eligibleRequests: 1 }),
+      ],
+      settings: defaultSettings,
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].baselineSource).toBe("today");
+  });
+
+  it("should fall back to prev baseline when historical/today kind-samples are insufficient", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.1 })],
+      prev: [
+        metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.6, eligibleRequests: 50 }),
+      ],
+      today: [
+        metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.9, eligibleRequests: 1 }),
+      ],
+      historical: [
+        metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.9, eligibleRequests: 1 }),
+      ],
+      settings: { ...defaultSettings, absMin: 0.01 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].baselineSource).toBe("prev");
+  });
+
+  it("should treat baseline as insufficient when eligible tokens below minEligibleTokens", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [
+        metric({
+          providerId: 1,
+          model: "m",
+          eligibleRequests: 50,
+          eligibleDenominatorTokens: 2000,
+          hitRateTokensEligible: 0.1,
+        }),
+      ],
+      prev: [],
+      today: [
+        metric({
+          providerId: 1,
+          model: "m",
+          eligibleRequests: 50,
+          eligibleDenominatorTokens: 2000,
+          hitRateTokensEligible: 0.6,
+        }),
+      ],
+      historical: [
+        metric({
+          providerId: 1,
+          model: "m",
+          eligibleRequests: 50,
+          eligibleDenominatorTokens: 10,
+          hitRateTokensEligible: 0.9,
+        }),
+      ],
+      settings: { ...defaultSettings, absMin: 0.01, minEligibleTokens: 1000 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].baselineSource).toBe("today");
+  });
+
+  it("should fall back to overall when eligible sample is insufficient", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [
+        metric({
+          providerId: 1,
+          model: "m",
+          totalRequests: 100,
+          denominatorTokens: 10000,
+          hitRateTokens: 0.1,
+          eligibleRequests: 1,
+          eligibleDenominatorTokens: 100,
+          hitRateTokensEligible: 0,
+        }),
+      ],
+      prev: [
+        metric({
+          providerId: 1,
+          model: "m",
+          totalRequests: 100,
+          denominatorTokens: 10000,
+          hitRateTokens: 0.5,
+          eligibleRequests: 1,
+          eligibleDenominatorTokens: 100,
+          hitRateTokensEligible: 0.5,
+        }),
+      ],
+      today: [],
+      historical: [],
+      settings: defaultSettings,
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].current.kind).toBe("overall");
+    expect(anomalies[0].baseline?.kind).toBe("overall");
+    expect(anomalies[0].reasonCodes).toContain("eligible_insufficient");
+  });
+
+  it("should fall back to overall when eligible tokens are insufficient", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [
+        metric({
+          providerId: 1,
+          model: "m",
+          totalRequests: 50,
+          denominatorTokens: 2000,
+          hitRateTokens: 0.1,
+          eligibleRequests: 50,
+          eligibleDenominatorTokens: 10,
+          hitRateTokensEligible: 0.9,
+        }),
+      ],
+      prev: [
+        metric({
+          providerId: 1,
+          model: "m",
+          totalRequests: 50,
+          denominatorTokens: 2000,
+          hitRateTokens: 0.6,
+          eligibleRequests: 50,
+          eligibleDenominatorTokens: 10,
+          hitRateTokensEligible: 0.9,
+        }),
+      ],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01, minEligibleTokens: 1000 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].current.kind).toBe("overall");
+    expect(anomalies[0].baseline?.kind).toBe("overall");
+    expect(anomalies[0].reasonCodes).toContain("eligible_insufficient");
+    expect(anomalies[0].reasonCodes).toContain("use_overall");
+  });
+
+  it("should not compare eligible current against overall baseline", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [
+        metric({
+          providerId: 1,
+          model: "m",
+          eligibleRequests: 100,
+          eligibleDenominatorTokens: 10000,
+          hitRateTokensEligible: 0.2,
+          totalRequests: 100,
+          denominatorTokens: 10000,
+          hitRateTokens: 0.2,
+        }),
+      ],
+      prev: [
+        metric({
+          providerId: 1,
+          model: "m",
+          // baseline eligible 不足,但 overall 足够
+          eligibleRequests: 1,
+          eligibleDenominatorTokens: 100,
+          hitRateTokensEligible: 0.9,
+          totalRequests: 100,
+          denominatorTokens: 10000,
+          hitRateTokens: 0.9,
+        }),
+      ],
+      today: [],
+      historical: [],
+      settings: defaultSettings,
+    });
+
+    expect(anomalies).toHaveLength(0);
+  });
+
+  it("should filter invalid metrics in map inputs", () => {
+    const current = new Map<string, CacheHitRateAlertMetric>([
+      ["k1", metric({ providerId: 1, model: "", hitRateTokensEligible: 0 })],
+      ["k2", metric({ providerId: 2, model: "m", hitRateTokensEligible: 0 })],
+    ]);
+
+    const prev = new Map<string, CacheHitRateAlertMetric>([
+      ["k1", metric({ providerId: 1, model: "", hitRateTokensEligible: 0.2 })],
+      ["k2", metric({ providerId: 2, model: "m", hitRateTokensEligible: 0.2 })],
+    ]);
+
+    const anomalies = decideCacheHitRateAnomalies({
+      current,
+      prev,
+      today: new Map<string, CacheHitRateAlertMetric>(),
+      historical: new Map<string, CacheHitRateAlertMetric>(),
+      settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].providerId).toBe(2);
+    expect(anomalies[0].model).toBe("m");
+  });
+
+  it("should return empty when eligible and overall samples are insufficient", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [
+        metric({
+          providerId: 1,
+          model: "m",
+          totalRequests: 1,
+          denominatorTokens: 10,
+          hitRateTokens: 0,
+          eligibleRequests: 1,
+          eligibleDenominatorTokens: 10,
+          hitRateTokensEligible: 0,
+        }),
+      ],
+      prev: [],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, minEligibleRequests: 20, minEligibleTokens: 1000 },
+    });
+
+    expect(anomalies).toHaveLength(0);
+  });
+
+  it("should trigger drop_abs_rel when thresholds are met", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].reasonCodes).toContain("drop_abs_rel");
+    expect(anomalies[0].dropAbs).toBeCloseTo(0.3, 10);
+  });
+
+  it("should not trigger drop_abs_rel when only dropAbs is met (AND)", () => {
+    // baseline=0.5, current=0.375
+    // dropAbs=0.125 >= 0.1(满足),dropRel=0.125/0.5=0.25 < 0.3(不满足)
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.375 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01, dropAbs: 0.1, dropRel: 0.3 },
+    });
+
+    expect(anomalies).toHaveLength(0);
+  });
+
+  it("should not trigger drop_abs_rel when only dropRel is met (AND)", () => {
+    // baseline=0.25, current=0.15625
+    // dropAbs=0.09375 < 0.1(不满足),dropRel=0.09375/0.25=0.375 >= 0.3(满足)
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.15625 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.25 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01, dropAbs: 0.1, dropRel: 0.3 },
+    });
+
+    expect(anomalies).toHaveLength(0);
+  });
+
+  it("should trigger abs_min when current is below absMin", () => {
+    const shouldTrigger = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.03 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.2 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
+    });
+
+    expect(shouldTrigger).toHaveLength(1);
+    expect(shouldTrigger[0].reasonCodes).toContain("abs_min");
+
+    const shouldNotTrigger = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.06 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
+    });
+
+    expect(shouldNotTrigger).toHaveLength(0);
+  });
+
+  it("abs_min should not trigger when current equals absMin", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.05 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.5 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.9, dropRel: 0.9 },
+    });
+
+    expect(anomalies).toHaveLength(0);
+  });
+
+  it("abs_min 在缺失基线时也应触发", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.01 })],
+      prev: [],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].baselineSource).toBeNull();
+    expect(anomalies[0].baseline).toBeNull();
+    expect(anomalies[0].deltaAbs).toBeNull();
+    expect(anomalies[0].deltaRel).toBeNull();
+    expect(anomalies[0].dropAbs).toBeNull();
+    expect(anomalies[0].reasonCodes).toContain("baseline_missing");
+    expect(anomalies[0].reasonCodes).toContain("abs_min");
+  });
+
+  it("dropAbs 在 current 高于 baseline 且仅触发 abs_min 时应 clamp 为 0", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.01 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.1, dropRel: 0.3 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].reasonCodes).toContain("abs_min");
+    expect(anomalies[0].reasonCodes).not.toContain("drop_abs_rel");
+    expect(anomalies[0].dropAbs).toBe(0);
+  });
+
+  it("should set deltaRel to null when baseline hit rate is 0", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, dropAbs: 0.9, dropRel: 0.9 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].baseline?.hitRateTokens).toBe(0);
+    expect(anomalies[0].deltaRel).toBeNull();
+  });
+
+  it("should trigger drop_abs_rel when thresholds are met exactly (>=)", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.3 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.4 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01, dropAbs: 0.1, dropRel: 0.25 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].reasonCodes).toContain("drop_abs_rel");
+    expect(anomalies[0].dropAbs).toBeCloseTo(0.1, 10);
+  });
+
+  it("should not add drop_abs_rel when only dropAbs is met (AND) even if abs_min triggers", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.06 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.01, dropRel: 0.5 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].reasonCodes).toContain("abs_min");
+    expect(anomalies[0].reasonCodes).not.toContain("drop_abs_rel");
+  });
+
+  it("should not add drop_abs_rel when only dropRel is met (AND) even if abs_min triggers", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.02 })],
+      prev: [metric({ providerId: 1, model: "m", hitRateTokensEligible: 0.04 })],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.05, dropAbs: 0.03, dropRel: 0.5 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].reasonCodes).toContain("abs_min");
+    expect(anomalies[0].reasonCodes).not.toContain("drop_abs_rel");
+  });
+
+  it("should sort by severity and respect topN", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [
+        metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.1 }),
+        metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.25 }),
+      ],
+      prev: [
+        metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.6 }),
+        metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.5 }),
+      ],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01, topN: 1 },
+    });
+
+    expect(anomalies).toHaveLength(1);
+    expect(anomalies[0].providerId).toBe(1);
+    expect(anomalies[0].model).toBe("a");
+  });
+
+  it("should break severity ties by providerId/model for deterministic ordering", () => {
+    const anomalies = decideCacheHitRateAnomalies({
+      current: [
+        metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.1 }),
+        metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.1 }),
+      ],
+      prev: [
+        metric({ providerId: 2, model: "b", hitRateTokensEligible: 0.6 }),
+        metric({ providerId: 1, model: "a", hitRateTokensEligible: 0.6 }),
+      ],
+      today: [],
+      historical: [],
+      settings: { ...defaultSettings, absMin: 0.01, topN: 2 },
+    });
+
+    expect(anomalies).toHaveLength(2);
+    expect(anomalies[0].providerId).toBe(1);
+    expect(anomalies[0].model).toBe("a");
+    expect(anomalies[1].providerId).toBe(2);
+    expect(anomalies[1].model).toBe("b");
+  });
+});

+ 26 - 4
tests/unit/repository/provider.test.ts

@@ -27,7 +27,7 @@ function sqlToString(sqlObj: unknown): string {
         if (Object.hasOwn(anyNode, "value")) {
           const { value } = anyNode;
           if (Array.isArray(value)) {
-            return value.map(String).join("");
+            return value.map(walk).join("");
           }
           if (value === null || value === undefined) return "";
           return String(value);
@@ -51,7 +51,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => {
   test("returns 0 and does not execute SQL when updates is empty", async () => {
     vi.resetModules();
 
-    const executeMock = vi.fn(async () => ({ rowCount: 0 }));
+    const executeMock = vi.fn(async () => []);
 
     vi.doMock("@/drizzle/db", () => ({
       db: {
@@ -69,7 +69,7 @@ describe("provider repository - updateProviderPrioritiesBatch", () => {
   test("generates CASE batch update SQL and returns affected rows", async () => {
     vi.resetModules();
 
-    const executeMock = vi.fn(async () => ({ rowCount: 2 }));
+    const executeMock = vi.fn(async () => [{ id: 1 }, { id: 2 }]);
 
     vi.doMock("@/drizzle/db", () => ({
       db: {
@@ -96,12 +96,13 @@ describe("provider repository - updateProviderPrioritiesBatch", () => {
     expect(sqlText).toContain("WHEN 2 THEN 3");
     expect(sqlText).toContain("updated_at = NOW()");
     expect(sqlText).toContain("WHERE id IN (1, 2) AND deleted_at IS NULL");
+    expect(sqlText).toContain("RETURNING id");
   });
 
   test("deduplicates provider ids (last update wins)", async () => {
     vi.resetModules();
 
-    const executeMock = vi.fn(async () => ({ rowCount: 1 }));
+    const executeMock = vi.fn(async () => [{ id: 1 }]);
 
     vi.doMock("@/drizzle/db", () => ({
       db: {
@@ -123,5 +124,26 @@ describe("provider repository - updateProviderPrioritiesBatch", () => {
 
     expect(sqlText).toContain("WHEN 1 THEN 2");
     expect(sqlText).toContain("WHERE id IN (1) AND deleted_at IS NULL");
+    expect(sqlText).toContain("RETURNING id");
+  });
+
+  test("propagates db.execute errors", async () => {
+    vi.resetModules();
+
+    const executeMock = vi.fn(async () => {
+      throw new Error("DB connection failed");
+    });
+
+    vi.doMock("@/drizzle/db", () => ({
+      db: {
+        execute: executeMock,
+      },
+    }));
+
+    const { updateProviderPrioritiesBatch } = await import("@/repository/provider");
+
+    await expect(updateProviderPrioritiesBatch([{ id: 1, priority: 0 }])).rejects.toThrow(
+      "DB connection failed"
+    );
   });
 });

+ 152 - 0
tests/unit/webhook/templates/templates.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect, it } from "vitest";
+import { buildCacheHitRateAlertMessage } from "@/lib/webhook/templates/cache-hit-rate-alert";
 import { buildCircuitBreakerMessage } from "@/lib/webhook/templates/circuit-breaker";
 import { buildCostAlertMessage } from "@/lib/webhook/templates/cost-alert";
 import { buildDailyLeaderboardMessage } from "@/lib/webhook/templates/daily-leaderboard";
 import type {
+  CacheHitRateAlertData,
   CircuitBreakerAlertData,
   CostAlertData,
   DailyLeaderboardData,
@@ -196,4 +198,154 @@ describe("Message Templates", () => {
       expect(sectionsStr).toContain("暂无数据");
     });
   });
+
+  describe("buildCacheHitRateAlertMessage", () => {
+    it("should create structured message for cache hit rate alert", () => {
+      const data: CacheHitRateAlertData = {
+        window: {
+          mode: "5m",
+          startTime: "2026-02-24T00:00:00.000Z",
+          endTime: "2026-02-24T00:05:00.000Z",
+          durationMinutes: 5,
+        },
+        anomalies: [
+          {
+            providerId: 1,
+            providerName: "OpenAI",
+            providerType: "openai-compatible",
+            model: "gpt-4o",
+            baselineSource: "historical",
+            current: {
+              kind: "eligible",
+              requests: 100,
+              denominatorTokens: 10000,
+              hitRateTokens: 0.1,
+            },
+            baseline: {
+              kind: "eligible",
+              requests: 200,
+              denominatorTokens: 20000,
+              hitRateTokens: 0.5,
+            },
+            deltaAbs: -0.4,
+            deltaRel: -0.8,
+            dropAbs: 0.4,
+            reasonCodes: ["abs_min"],
+          },
+        ],
+        suppressedCount: 0,
+        settings: {
+          windowMode: "auto",
+          checkIntervalMinutes: 5,
+          historicalLookbackDays: 7,
+          minEligibleRequests: 20,
+          minEligibleTokens: 0,
+          absMin: 0.05,
+          dropRel: 0.3,
+          dropAbs: 0.1,
+          cooldownMinutes: 30,
+          topN: 10,
+        },
+        generatedAt: "2026-02-24T00:05:00.000Z",
+      };
+
+      const message = buildCacheHitRateAlertMessage(data, "UTC");
+
+      expect(message.header.level).toBe("warning");
+      expect(message.header.icon).toBe("[CACHE]");
+      expect(message.header.title).toContain("缓存命中率");
+      expect(message.timestamp).toBeInstanceOf(Date);
+
+      const sectionsStr = JSON.stringify(message.sections);
+      expect(sectionsStr).toContain("OpenAI");
+      expect(sectionsStr).toContain("gpt-4o");
+      expect(sectionsStr).toContain("5m");
+      expect(sectionsStr).toContain("异常列表");
+    });
+
+    it("should handle anomalies with null baseline", () => {
+      const data: CacheHitRateAlertData = {
+        window: {
+          mode: "5m",
+          startTime: "2026-02-24T00:00:00.000Z",
+          endTime: "2026-02-24T00:05:00.000Z",
+          durationMinutes: 5,
+        },
+        anomalies: [
+          {
+            providerId: 1,
+            providerName: "OpenAI",
+            providerType: "openai-compatible",
+            model: "gpt-4o",
+            baselineSource: null,
+            current: {
+              kind: "eligible",
+              requests: 100,
+              denominatorTokens: 10000,
+              hitRateTokens: 0.1,
+            },
+            baseline: null,
+            deltaAbs: null,
+            deltaRel: null,
+            dropAbs: null,
+            reasonCodes: ["abs_min"],
+          },
+        ],
+        suppressedCount: 0,
+        settings: {
+          windowMode: "auto",
+          checkIntervalMinutes: 5,
+          historicalLookbackDays: 7,
+          minEligibleRequests: 20,
+          minEligibleTokens: 0,
+          absMin: 0.05,
+          dropRel: 0.3,
+          dropAbs: 0.1,
+          cooldownMinutes: 30,
+          topN: 10,
+        },
+        generatedAt: "2026-02-24T00:05:00.000Z",
+      };
+
+      const message = buildCacheHitRateAlertMessage(data, "UTC");
+
+      expect(message.header.level).toBe("warning");
+      expect(message.timestamp).toBeInstanceOf(Date);
+
+      const sectionsStr = JSON.stringify(message.sections);
+      expect(sectionsStr).toContain("gpt-4o");
+      expect(sectionsStr).toContain("基线: 无");
+    });
+
+    it("should handle empty anomalies", () => {
+      const data: CacheHitRateAlertData = {
+        window: {
+          mode: "30m",
+          startTime: "2026-02-24T00:00:00.000Z",
+          endTime: "2026-02-24T00:30:00.000Z",
+          durationMinutes: 30,
+        },
+        anomalies: [],
+        suppressedCount: 2,
+        settings: {
+          windowMode: "30m",
+          checkIntervalMinutes: 5,
+          historicalLookbackDays: 7,
+          minEligibleRequests: 20,
+          minEligibleTokens: 0,
+          absMin: 0.05,
+          dropRel: 0.3,
+          dropAbs: 0.1,
+          cooldownMinutes: 30,
+          topN: 10,
+        },
+        generatedAt: "2026-02-24T00:30:00.000Z",
+      };
+
+      const message = buildCacheHitRateAlertMessage(data, "UTC");
+      const sectionsStr = JSON.stringify(message.sections);
+      expect(sectionsStr).toContain("未检测到异常");
+      expect(sectionsStr).not.toContain("异常列表");
+    });
+  });
 });