Преглед изворни кода

feat: add provider cache hit rate ranking to dashboard and leaderboard

- Introduced new translations for provider cache hit rate in English, Japanese, Russian, Chinese (Simplified and Traditional).
- Updated leaderboard component to support provider cache hit rate as a new scope.
- Enhanced API endpoints to include provider cache hit rate in leaderboard queries.
- Added new database functions to calculate and retrieve provider cache hit rate rankings.

These changes improve the dashboard's functionality by allowing users to view and analyze provider cache hit rates, enhancing overall data insights.
ding113 пре 3 месеци
родитељ
комит
4e462d53b4

+ 2 - 0
messages/en/dashboard.json

@@ -240,6 +240,7 @@
       "keys": "Key Rankings",
       "userRanking": "User Rankings",
       "providerRanking": "Provider Rankings",
+      "providerCacheHitRateRanking": "Provider Cache Hit Rate",
       "modelRanking": "Model Rankings",
       "dailyRanking": "Today",
       "weeklyRanking": "This Week",
@@ -266,6 +267,7 @@
       "provider": "Provider",
       "model": "Model",
       "cost": "Cost",
+      "cacheHitRate": "Cache Hit Rate",
       "successRate": "Success Rate",
       "avgResponseTime": "Avg Response Time"
     },

+ 2 - 0
messages/ja/dashboard.json

@@ -239,6 +239,7 @@
       "keys": "キー ランキング",
       "userRanking": "ユーザーランキング",
       "providerRanking": "プロバイダーランキング",
+      "providerCacheHitRateRanking": "プロバイダーキャッシュ命中率",
       "modelRanking": "モデルランキング",
       "dailyRanking": "今日",
       "weeklyRanking": "今週",
@@ -265,6 +266,7 @@
       "provider": "プロバイダー",
       "model": "モデル",
       "cost": "コスト",
+      "cacheHitRate": "キャッシュ命中率",
       "successRate": "成功率",
       "avgResponseTime": "平均応答時間"
     },

+ 2 - 0
messages/ru/dashboard.json

@@ -239,6 +239,7 @@
       "keys": "Рейтинг ключей",
       "userRanking": "Рейтинг пользователей",
       "providerRanking": "Рейтинг поставщиков",
+      "providerCacheHitRateRanking": "Рейтинг по попаданиям в кэш",
       "modelRanking": "Рейтинг моделей",
       "dailyRanking": "Сегодня",
       "weeklyRanking": "Эта неделя",
@@ -265,6 +266,7 @@
       "provider": "Поставщик",
       "model": "Модель",
       "cost": "Стоимость",
+      "cacheHitRate": "Попадания в кэш",
       "successRate": "Процент успеха",
       "avgResponseTime": "Среднее время ответа"
     },

+ 14 - 12
messages/zh-CN/dashboard.json

@@ -239,12 +239,13 @@
       "users": "用户排行",
       "keys": "密钥排行",
       "userRanking": "用户排行",
-      "providerRanking": "供应商排行",
-      "modelRanking": "模型排行",
-      "dailyRanking": "今日",
-      "weeklyRanking": "本周",
-      "monthlyRanking": "本月",
-      "allTimeRanking": "全部"
+    "providerRanking": "供应商排行",
+    "providerCacheHitRateRanking": "供应商缓存命中率排行",
+    "modelRanking": "模型排行",
+    "dailyRanking": "今日",
+    "weeklyRanking": "本周",
+    "monthlyRanking": "本月",
+    "allTimeRanking": "全部"
     },
     "dateRange": {
       "to": "至",
@@ -263,12 +264,13 @@
       "requests": "请求数",
       "tokens": "Token 数",
       "consumedAmount": "消耗金额",
-      "provider": "供应商",
-      "model": "模型",
-      "cost": "成本",
-      "successRate": "成功率",
-      "avgResponseTime": "平均响应时间"
-    },
+    "provider": "供应商",
+    "model": "模型",
+    "cost": "成本",
+    "cacheHitRate": "缓存命中率",
+    "successRate": "成功率",
+    "avgResponseTime": "平均响应时间"
+  },
     "states": {
       "loading": "加载中...",
       "noData": "暂无数据",

+ 2 - 0
messages/zh-TW/dashboard.json

@@ -240,6 +240,7 @@
       "keys": "密鑰排名",
       "userRanking": "使用者排名",
       "providerRanking": "供應商排名",
+      "providerCacheHitRateRanking": "供應商快取命中率排行",
       "modelRanking": "模型排名",
       "dailyRanking": "今日",
       "weeklyRanking": "本週",
@@ -266,6 +267,7 @@
       "provider": "供應商",
       "model": "模型",
       "cost": "成本",
+      "cacheHitRate": "快取命中率",
       "successRate": "成功率",
       "avgResponseTime": "平均回覆時間"
     },

+ 60 - 6
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx

@@ -12,6 +12,7 @@ import type {
   LeaderboardEntry,
   LeaderboardPeriod,
   ModelLeaderboardEntry,
+  ProviderCacheHitRateLeaderboardEntry,
   ProviderLeaderboardEntry,
 } from "@/repository/leaderboard";
 import { DateRangePicker } from "./date-range-picker";
@@ -21,11 +22,14 @@ interface LeaderboardViewProps {
   isAdmin: boolean;
 }
 
-type LeaderboardScope = "user" | "provider" | "model";
+type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model";
 type UserEntry = LeaderboardEntry & { totalCostFormatted?: string };
 type ProviderEntry = ProviderLeaderboardEntry & { totalCostFormatted?: string };
+type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry & {
+  totalCostFormatted?: string;
+};
 type ModelEntry = ModelLeaderboardEntry & { totalCostFormatted?: string };
-type AnyEntry = UserEntry | ProviderEntry | ModelEntry;
+type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry;
 
 const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"];
 
@@ -35,7 +39,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
 
   const urlScope = searchParams.get("scope") as LeaderboardScope | null;
   const initialScope: LeaderboardScope =
-    (urlScope === "provider" || urlScope === "model") && isAdmin ? urlScope : "user";
+    (urlScope === "provider" || urlScope === "providerCacheHitRate" || urlScope === "model") &&
+    isAdmin
+      ? urlScope
+      : "user";
   const urlPeriod = searchParams.get("period") as LeaderboardPeriod | null;
   const initialPeriod: LeaderboardPeriod =
     urlPeriod && VALID_PERIODS.includes(urlPeriod) ? urlPeriod : "daily";
@@ -52,7 +59,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
   useEffect(() => {
     const urlScopeParam = searchParams.get("scope") as LeaderboardScope | null;
     const normalizedScope: LeaderboardScope =
-      (urlScopeParam === "provider" || urlScopeParam === "model") && isAdmin
+      (urlScopeParam === "provider" ||
+        urlScopeParam === "providerCacheHitRate" ||
+        urlScopeParam === "model") &&
+      isAdmin
         ? urlScopeParam
         : "user";
 
@@ -114,7 +124,15 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
   );
 
   const skeletonColumns =
-    scope === "user" ? 5 : scope === "provider" ? 7 : scope === "model" ? 6 : 5;
+    scope === "user"
+      ? 5
+      : scope === "provider"
+        ? 7
+        : scope === "providerCacheHitRate"
+          ? 5
+          : scope === "model"
+            ? 6
+            : 5;
   const skeletonGridStyle = { gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))` };
 
   // 列定义(根据 scope 动态切换)
@@ -185,6 +203,33 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
     },
   ];
 
+  const providerCacheHitRateColumns: ColumnDef<ProviderCacheHitRateEntry>[] = [
+    {
+      header: t("columns.provider"),
+      cell: (row, index) => (
+        <span className={index < 3 ? "font-semibold" : ""}>
+          {(row as ProviderCacheHitRateEntry).providerName}
+        </span>
+      ),
+    },
+    {
+      header: t("columns.requests"),
+      className: "text-right",
+      cell: (row) => (row as ProviderCacheHitRateEntry).totalRequests.toLocaleString(),
+    },
+    {
+      header: t("columns.cacheHitRate"),
+      className: "text-right",
+      cell: (row) =>
+        `${(Number((row as ProviderCacheHitRateEntry).cacheHitRate || 0) * 100).toFixed(1)}%`,
+    },
+    {
+      header: t("columns.tokens"),
+      className: "text-right",
+      cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalTokens),
+    },
+  ];
+
   const modelColumns: ColumnDef<ModelEntry>[] = [
     {
       header: t("columns.model"),
@@ -225,6 +270,8 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
         return userColumns as ColumnDef<AnyEntry>[];
       case "provider":
         return providerColumns as ColumnDef<AnyEntry>[];
+      case "providerCacheHitRate":
+        return providerCacheHitRateColumns as ColumnDef<AnyEntry>[];
       case "model":
         return modelColumns as ColumnDef<AnyEntry>[];
     }
@@ -236,6 +283,8 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
         return (row as UserEntry).userId;
       case "provider":
         return (row as ProviderEntry).providerId;
+      case "providerCacheHitRate":
+        return (row as ProviderCacheHitRateEntry).providerId;
       case "model":
         return (row as ModelEntry).model;
     }
@@ -246,9 +295,14 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
       {/* Scope toggle */}
       <div className="flex flex-wrap gap-4 items-center mb-4">
         <Tabs value={scope} onValueChange={(v) => setScope(v as LeaderboardScope)}>
-          <TabsList className={isAdmin ? "grid grid-cols-3" : ""}>
+          <TabsList className={isAdmin ? "grid grid-cols-4" : ""}>
             <TabsTrigger value="user">{t("tabs.userRanking")}</TabsTrigger>
             {isAdmin && <TabsTrigger value="provider">{t("tabs.providerRanking")}</TabsTrigger>}
+            {isAdmin && (
+              <TabsTrigger value="providerCacheHitRate">
+                {t("tabs.providerCacheHitRateRanking")}
+              </TabsTrigger>
+            )}
             {isAdmin && <TabsTrigger value="model">{t("tabs.modelRanking")}</TabsTrigger>}
           </TabsList>
         </Tabs>

+ 8 - 3
src/app/api/leaderboard/route.ts

@@ -20,7 +20,7 @@ export const runtime = "nodejs";
 
 /**
  * 获取排行榜数据
- * GET /api/leaderboard?period=daily|weekly|monthly|allTime|custom&scope=user|provider|model
+ * GET /api/leaderboard?period=daily|weekly|monthly|allTime|custom&scope=user|provider|providerCacheHitRate|model
  * 当 period=custom 时,需要提供 startDate 和 endDate 参数 (YYYY-MM-DD 格式)
  *
  * 需要认证,普通用户需要 allowGlobalUsageView 权限
@@ -69,9 +69,14 @@ export async function GET(request: NextRequest) {
       );
     }
 
-    if (scope !== "user" && scope !== "provider" && scope !== "model") {
+    if (
+      scope !== "user" &&
+      scope !== "provider" &&
+      scope !== "providerCacheHitRate" &&
+      scope !== "model"
+    ) {
       return NextResponse.json(
-        { error: "参数 scope 必须是 'user'、'provider' 或 'model'" },
+        { error: "参数 scope 必须是 'user'、'provider'、'providerCacheHitRate' 或 'model'" },
         { status: 400 }
       );
     }

+ 88 - 37
src/app/v1/_lib/proxy/response-handler.ts

@@ -1280,6 +1280,25 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
     }
   }
 
+  // 兼容部分 relay / 旧字段命名:claude_cache_creation_5_m_tokens / claude_cache_creation_1_h_tokens
+  // 仅在标准字段缺失时使用,避免重复统计
+  if (
+    result.cache_creation_5m_input_tokens === undefined &&
+    typeof usage.claude_cache_creation_5_m_tokens === "number"
+  ) {
+    result.cache_creation_5m_input_tokens = usage.claude_cache_creation_5_m_tokens;
+    cacheCreationDetailedTotal += usage.claude_cache_creation_5_m_tokens;
+    hasAny = true;
+  }
+  if (
+    result.cache_creation_1h_input_tokens === undefined &&
+    typeof usage.claude_cache_creation_1_h_tokens === "number"
+  ) {
+    result.cache_creation_1h_input_tokens = usage.claude_cache_creation_1_h_tokens;
+    cacheCreationDetailedTotal += usage.claude_cache_creation_1_h_tokens;
+    hasAny = true;
+  }
+
   if (result.cache_creation_input_tokens === undefined && cacheCreationDetailedTotal > 0) {
     result.cache_creation_input_tokens = cacheCreationDetailedTotal;
   }
@@ -1316,7 +1335,7 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
   return hasAny ? result : null;
 }
 
-function parseUsageFromResponseText(
+export function parseUsageFromResponseText(
   responseText: string,
   providerType: string | null | undefined
 ): {
@@ -1404,11 +1423,29 @@ function parseUsageFromResponseText(
     const events = parseSSEData(responseText);
 
     // Claude SSE 特殊处理:
-    // - message_start 包含 input tokens 和缓存创建字段(5m/1h 区分计费)
-    // - message_delta 包含最终的 output_tokens
-    // 需要分别提取并合并
+    // - message_delta 通常包含更完整的 usage(应优先使用)
+    // - message_start 可能包含 cache_creation 的 TTL 细分字段(作为缺失字段的补充)
     let messageStartUsage: UsageMetrics | null = null;
-    let messageDeltaOutputTokens: number | null = null;
+    let messageDeltaUsage: UsageMetrics | null = null;
+
+    const mergeUsageMetrics = (base: UsageMetrics | null, patch: UsageMetrics): UsageMetrics => {
+      if (!base) {
+        return { ...patch };
+      }
+
+      return {
+        input_tokens: patch.input_tokens ?? base.input_tokens,
+        output_tokens: patch.output_tokens ?? base.output_tokens,
+        cache_creation_input_tokens:
+          patch.cache_creation_input_tokens ?? base.cache_creation_input_tokens,
+        cache_creation_5m_input_tokens:
+          patch.cache_creation_5m_input_tokens ?? base.cache_creation_5m_input_tokens,
+        cache_creation_1h_input_tokens:
+          patch.cache_creation_1h_input_tokens ?? base.cache_creation_1h_input_tokens,
+        cache_ttl: patch.cache_ttl ?? base.cache_ttl,
+        cache_read_input_tokens: patch.cache_read_input_tokens ?? base.cache_read_input_tokens,
+      };
+    };
 
     for (const event of events) {
       if (typeof event.data !== "object" || !event.data) {
@@ -1417,37 +1454,54 @@ function parseUsageFromResponseText(
 
       const data = event.data as Record<string, unknown>;
 
-      // Claude message_start format: data.message.usage
-      // 提取 input tokens 和缓存字段
-      if (event.event === "message_start" && data.message && typeof data.message === "object") {
-        const messageObj = data.message as Record<string, unknown>;
-        if (messageObj.usage && typeof messageObj.usage === "object") {
-          const extracted = extractUsageMetrics(messageObj.usage);
+      if (event.event === "message_start") {
+        // Claude message_start format: data.message.usage
+        // 部分 relay 可能是 data.usage(无 message 包裹)
+        let usageValue: unknown = null;
+        if (data.message && typeof data.message === "object") {
+          const messageObj = data.message as Record<string, unknown>;
+          usageValue = messageObj.usage;
+        }
+        if (!usageValue) {
+          usageValue = data.usage;
+        }
+
+        if (usageValue && typeof usageValue === "object") {
+          const extracted = extractUsageMetrics(usageValue);
           if (extracted) {
-            messageStartUsage = extracted;
+            messageStartUsage = mergeUsageMetrics(messageStartUsage, extracted);
             logger.debug("[ResponseHandler] Extracted usage from message_start", {
-              source: "sse.message_start.message.usage",
+              source:
+                usageValue === data.usage
+                  ? "sse.message_start.usage"
+                  : "sse.message_start.message.usage",
               usage: extracted,
             });
           }
         }
       }
 
-      // Claude message_delta format: data.usage.output_tokens
-      // 提取最终的 output_tokens(在流结束时)
-      if (event.event === "message_delta" && data.usage && typeof data.usage === "object") {
-        const deltaUsage = data.usage as Record<string, unknown>;
-        if (typeof deltaUsage.output_tokens === "number") {
-          messageDeltaOutputTokens = deltaUsage.output_tokens;
-          logger.debug("[ResponseHandler] Extracted output_tokens from message_delta", {
-            source: "sse.message_delta.usage.output_tokens",
-            outputTokens: messageDeltaOutputTokens,
-          });
+      if (event.event === "message_delta") {
+        // Claude message_delta format: data.usage
+        let usageValue: unknown = data.usage;
+        if (!usageValue && data.delta && typeof data.delta === "object") {
+          usageValue = (data.delta as Record<string, unknown>).usage;
+        }
+
+        if (usageValue && typeof usageValue === "object") {
+          const extracted = extractUsageMetrics(usageValue);
+          if (extracted) {
+            messageDeltaUsage = mergeUsageMetrics(messageDeltaUsage, extracted);
+            logger.debug("[ResponseHandler] Extracted usage from message_delta", {
+              source: "sse.message_delta.usage",
+              usage: extracted,
+            });
+          }
         }
       }
 
       // 非 Claude 格式的 SSE 处理(Gemini 等)
-      if (!messageStartUsage && !messageDeltaOutputTokens) {
+      if (!messageStartUsage && !messageDeltaUsage) {
         // Standard usage fields (data.usage)
         applyUsageValue(data.usage, `sse.${event.event}.usage`);
 
@@ -1463,20 +1517,17 @@ function parseUsageFromResponseText(
       }
     }
 
-    // 合并 Claude SSE 的 message_start 和 message_delta 数据
-    if (messageStartUsage) {
-      // 使用 message_delta 中的 output_tokens 覆盖 message_start 中的值
-      if (messageDeltaOutputTokens !== null) {
-        messageStartUsage.output_tokens = messageDeltaOutputTokens;
-        logger.debug(
-          "[ResponseHandler] Merged output_tokens from message_delta into message_start usage",
-          {
-            finalOutputTokens: messageDeltaOutputTokens,
-          }
-        );
+    // Claude SSE 合并规则:优先使用 message_delta,缺失字段再回退到 message_start
+    const mergedClaudeUsage = (() => {
+      if (messageDeltaUsage && messageStartUsage) {
+        return mergeUsageMetrics(messageStartUsage, messageDeltaUsage);
       }
-      usageMetrics = adjustUsageForProviderType(messageStartUsage, providerType);
-      usageRecord = messageStartUsage as unknown as Record<string, unknown>;
+      return messageDeltaUsage ?? messageStartUsage;
+    })();
+
+    if (mergedClaudeUsage) {
+      usageMetrics = adjustUsageForProviderType(mergedClaudeUsage, providerType);
+      usageRecord = mergedClaudeUsage as unknown as Record<string, unknown>;
       logger.debug("[ResponseHandler] Final merged usage from Claude SSE", {
         providerType,
         usage: usageMetrics,

+ 29 - 2
src/lib/redis/leaderboard-cache.ts

@@ -5,30 +5,40 @@ import {
   type DateRangeParams,
   findAllTimeLeaderboard,
   findAllTimeModelLeaderboard,
+  findAllTimeProviderCacheHitRateLeaderboard,
   findAllTimeProviderLeaderboard,
   findCustomRangeLeaderboard,
   findCustomRangeModelLeaderboard,
+  findCustomRangeProviderCacheHitRateLeaderboard,
   findCustomRangeProviderLeaderboard,
   findDailyLeaderboard,
   findDailyModelLeaderboard,
+  findDailyProviderCacheHitRateLeaderboard,
   findDailyProviderLeaderboard,
   findMonthlyLeaderboard,
   findMonthlyModelLeaderboard,
+  findMonthlyProviderCacheHitRateLeaderboard,
   findMonthlyProviderLeaderboard,
   findWeeklyLeaderboard,
   findWeeklyModelLeaderboard,
+  findWeeklyProviderCacheHitRateLeaderboard,
   findWeeklyProviderLeaderboard,
   type LeaderboardEntry,
   type LeaderboardPeriod,
   type ModelLeaderboardEntry,
+  type ProviderCacheHitRateLeaderboardEntry,
   type ProviderLeaderboardEntry,
 } from "@/repository/leaderboard";
 import { getRedisClient } from "./client";
 
 export type { LeaderboardPeriod, DateRangeParams };
-export type LeaderboardScope = "user" | "provider" | "model";
+export type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model";
 
-type LeaderboardData = LeaderboardEntry[] | ProviderLeaderboardEntry[] | ModelLeaderboardEntry[];
+type LeaderboardData =
+  | LeaderboardEntry[]
+  | ProviderLeaderboardEntry[]
+  | ProviderCacheHitRateLeaderboardEntry[]
+  | ModelLeaderboardEntry[];
 
 /**
  * 构建缓存键
@@ -79,6 +89,9 @@ async function queryDatabase(
     if (scope === "provider") {
       return await findCustomRangeProviderLeaderboard(dateRange);
     }
+    if (scope === "providerCacheHitRate") {
+      return await findCustomRangeProviderCacheHitRateLeaderboard(dateRange);
+    }
     return await findCustomRangeModelLeaderboard(dateRange);
   }
 
@@ -110,6 +123,20 @@ async function queryDatabase(
         return await findDailyProviderLeaderboard();
     }
   }
+  if (scope === "providerCacheHitRate") {
+    switch (period) {
+      case "daily":
+        return await findDailyProviderCacheHitRateLeaderboard();
+      case "weekly":
+        return await findWeeklyProviderCacheHitRateLeaderboard();
+      case "monthly":
+        return await findMonthlyProviderCacheHitRateLeaderboard();
+      case "allTime":
+        return await findAllTimeProviderCacheHitRateLeaderboard();
+      default:
+        return await findDailyProviderCacheHitRateLeaderboard();
+    }
+  }
   // model scope
   switch (period) {
     case "daily":

+ 128 - 0
src/repository/leaderboard.ts

@@ -30,6 +30,18 @@ export interface ProviderLeaderboardEntry {
   avgResponseTime: number; // 毫秒
 }
 
+/**
+ * 供应商缓存命中率排行榜条目类型
+ */
+export interface ProviderCacheHitRateLeaderboardEntry {
+  providerId: number;
+  providerName: string;
+  totalRequests: number;
+  totalCost: number;
+  totalTokens: number;
+  cacheHitRate: number; // 0-1 之间的小数,UI 层负责格式化为百分比
+}
+
 /**
  * 模型排行榜条目类型
  */
@@ -211,6 +223,46 @@ export async function findAllTimeProviderLeaderboard(): Promise<ProviderLeaderbo
   return findProviderLeaderboardWithTimezone("allTime", timezone);
 }
 
+/**
+ * 查询今日供应商缓存命中率排行榜(不限制数量)
+ */
+export async function findDailyProviderCacheHitRateLeaderboard(): Promise<
+  ProviderCacheHitRateLeaderboardEntry[]
+> {
+  const timezone = getEnvConfig().TZ;
+  return findProviderCacheHitRateLeaderboardWithTimezone("daily", timezone);
+}
+
+/**
+ * 查询本月供应商缓存命中率排行榜(不限制数量)
+ */
+export async function findMonthlyProviderCacheHitRateLeaderboard(): Promise<
+  ProviderCacheHitRateLeaderboardEntry[]
+> {
+  const timezone = getEnvConfig().TZ;
+  return findProviderCacheHitRateLeaderboardWithTimezone("monthly", timezone);
+}
+
+/**
+ * 查询本周供应商缓存命中率排行榜(不限制数量)
+ */
+export async function findWeeklyProviderCacheHitRateLeaderboard(): Promise<
+  ProviderCacheHitRateLeaderboardEntry[]
+> {
+  const timezone = getEnvConfig().TZ;
+  return findProviderCacheHitRateLeaderboardWithTimezone("weekly", timezone);
+}
+
+/**
+ * 查询全部时间供应商缓存命中率排行榜(不限制数量)
+ */
+export async function findAllTimeProviderCacheHitRateLeaderboard(): Promise<
+  ProviderCacheHitRateLeaderboardEntry[]
+> {
+  const timezone = getEnvConfig().TZ;
+  return findProviderCacheHitRateLeaderboardWithTimezone("allTime", timezone);
+}
+
 /**
  * 通用供应商排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确)
  */
@@ -261,6 +313,72 @@ async function findProviderLeaderboardWithTimezone(
   }));
 }
 
+/**
+ * 通用供应商缓存命中率排行榜查询函数
+ *
+ * 计算规则:
+ * - 仅统计需要缓存的请求(cache_creation_input_tokens 与 cache_read_input_tokens 不同时为 0/null)
+ * - 命中率 = cache_read / (input + output + cache_creation + cache_read)
+ */
+async function findProviderCacheHitRateLeaderboardWithTimezone(
+  period: LeaderboardPeriod,
+  timezone: string,
+  dateRange?: DateRangeParams
+): Promise<ProviderCacheHitRateLeaderboardEntry[]> {
+  const totalTokensExpr = sql<number>`(
+    COALESCE(${messageRequest.inputTokens}, 0) +
+    COALESCE(${messageRequest.outputTokens}, 0) +
+    COALESCE(${messageRequest.cacheCreationInputTokens}, 0) +
+    COALESCE(${messageRequest.cacheReadInputTokens}, 0)
+  )`;
+
+  const cacheRequiredCondition = sql`(
+    COALESCE(${messageRequest.cacheCreationInputTokens}, 0) > 0
+    OR COALESCE(${messageRequest.cacheReadInputTokens}, 0) > 0
+  )`;
+
+  const sumTotalTokens = sql<number>`COALESCE(sum(${totalTokensExpr})::double precision, 0::double precision)`;
+  const sumCacheReadTokens = sql<number>`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`;
+
+  const cacheHitRateExpr = sql<number>`COALESCE(
+    ${sumCacheReadTokens} / NULLIF(${sumTotalTokens}, 0::double precision),
+    0::double precision
+  )`;
+
+  const rankings = await db
+    .select({
+      providerId: messageRequest.providerId,
+      providerName: providers.name,
+      totalRequests: sql<number>`count(*)::double precision`,
+      totalCost: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
+      totalTokens: sumTotalTokens,
+      cacheHitRate: cacheHitRateExpr,
+    })
+    .from(messageRequest)
+    .innerJoin(
+      providers,
+      and(sql`${messageRequest.providerId} = ${providers.id}`, isNull(providers.deletedAt))
+    )
+    .where(
+      and(
+        isNull(messageRequest.deletedAt),
+        buildDateCondition(period, timezone, dateRange),
+        cacheRequiredCondition
+      )
+    )
+    .groupBy(messageRequest.providerId, providers.name)
+    .orderBy(desc(cacheHitRateExpr), desc(sql`count(*)`));
+
+  return rankings.map((entry) => ({
+    providerId: entry.providerId,
+    providerName: entry.providerName,
+    totalRequests: entry.totalRequests,
+    totalCost: parseFloat(entry.totalCost),
+    totalTokens: entry.totalTokens,
+    cacheHitRate: Math.min(Math.max(entry.cacheHitRate ?? 0, 0), 1),
+  }));
+}
+
 /**
  * 查询自定义日期范围供应商消耗排行榜
  */
@@ -271,6 +389,16 @@ export async function findCustomRangeProviderLeaderboard(
   return findProviderLeaderboardWithTimezone("custom", timezone, dateRange);
 }
 
+/**
+ * 查询自定义日期范围供应商缓存命中率排行榜
+ */
+export async function findCustomRangeProviderCacheHitRateLeaderboard(
+  dateRange: DateRangeParams
+): Promise<ProviderCacheHitRateLeaderboardEntry[]> {
+  const timezone = getEnvConfig().TZ;
+  return findProviderCacheHitRateLeaderboardWithTimezone("custom", timezone, dateRange);
+}
+
 /**
  * 查询今日模型调用排行榜(不限制数量)
  * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai)

+ 123 - 0
tests/unit/proxy/anthropic-usage-parsing.test.ts

@@ -0,0 +1,123 @@
+import { describe, expect, test } from "vitest";
+import { parseUsageFromResponseText } from "@/app/v1/_lib/proxy/response-handler";
+
+function buildSse(events: Array<{ event: string; data: unknown }>): string {
+  return events
+    .flatMap(({ event, data }) => [`event: ${event}`, `data: ${JSON.stringify(data)}`, ""])
+    .join("\n");
+}
+
+describe("parseUsageFromResponseText (Anthropic/Claude SSE usage)", () => {
+  test("prefers message_delta and falls back to message_start for missing fields", () => {
+    const sse = buildSse([
+      {
+        event: "message_start",
+        data: {
+          type: "message_start",
+          message: {
+            usage: {
+              input_tokens: 12,
+              cache_creation_input_tokens: 1641,
+              cache_read_input_tokens: 171876,
+              output_tokens: 1,
+              cache_creation: { ephemeral_1h_input_tokens: 1641 },
+            },
+          },
+        },
+      },
+      {
+        event: "message_delta",
+        data: {
+          type: "message_delta",
+          delta: { stop_reason: "end_turn" },
+          usage: {
+            input_tokens: 9,
+            cache_creation_input_tokens: 458843,
+            cache_read_input_tokens: 14999,
+            output_tokens: 2273,
+          },
+        },
+      },
+    ]);
+
+    const { usageMetrics, usageRecord } = parseUsageFromResponseText(sse, "anthropic");
+
+    expect(usageRecord).not.toBeNull();
+    expect(usageMetrics).toMatchObject({
+      input_tokens: 9,
+      output_tokens: 2273,
+      cache_creation_input_tokens: 458843,
+      cache_read_input_tokens: 14999,
+      cache_creation_1h_input_tokens: 1641,
+      cache_ttl: "1h",
+    });
+  });
+
+  test("falls back to message_start when message_delta only provides output_tokens", () => {
+    const sse = buildSse([
+      {
+        event: "message_start",
+        data: {
+          type: "message_start",
+          message: {
+            usage: {
+              input_tokens: 12,
+              cache_creation_input_tokens: 1641,
+              cache_read_input_tokens: 171876,
+              output_tokens: 1,
+              cache_creation: { ephemeral_1h_input_tokens: 1641 },
+            },
+          },
+        },
+      },
+      {
+        event: "message_delta",
+        data: {
+          type: "message_delta",
+          delta: { stop_reason: "end_turn" },
+          usage: { output_tokens: 2273 },
+        },
+      },
+    ]);
+
+    const { usageMetrics, usageRecord } = parseUsageFromResponseText(sse, "anthropic");
+
+    expect(usageRecord).not.toBeNull();
+    expect(usageMetrics).toMatchObject({
+      input_tokens: 12,
+      output_tokens: 2273,
+      cache_creation_input_tokens: 1641,
+      cache_read_input_tokens: 171876,
+      cache_creation_1h_input_tokens: 1641,
+      cache_ttl: "1h",
+    });
+  });
+
+  test("handles message_delta-only streams", () => {
+    const sse = buildSse([
+      {
+        event: "message_delta",
+        data: {
+          type: "message_delta",
+          delta: { stop_reason: "end_turn" },
+          usage: {
+            input_tokens: 9,
+            cache_creation_input_tokens: 458843,
+            cache_read_input_tokens: 14999,
+            output_tokens: 2273,
+          },
+        },
+      },
+    ]);
+
+    const { usageMetrics, usageRecord } = parseUsageFromResponseText(sse, "anthropic");
+
+    expect(usageRecord).not.toBeNull();
+    expect(usageMetrics).toMatchObject({
+      input_tokens: 9,
+      output_tokens: 2273,
+      cache_creation_input_tokens: 458843,
+      cache_read_input_tokens: 14999,
+    });
+  });
+});