Procházet zdrojové kódy

feat(leaderboard): add provider avg-cost metrics and cache-hit model drilldown (#753)

Provider ranking adds avgCostPerRequest and avgCostPerMillionTokens with
null-safe division guards. Cache-hit ranking gains per-provider expandable
model-level breakdown (modelStats) via a second grouped query keyed by
billingModelSource. LeaderboardTable now supports generic expandable rows.

API route formats new fields immutably via spread pattern. i18n keys added
for all 5 locales. 16 new tests cover repository semantics, API formatting,
and multi-provider model grouping.
Ding před 3 dny
rodič
revize
087299273a

+ 5 - 1
messages/en/dashboard.json

@@ -410,8 +410,12 @@
       "successRate": "Success Rate",
       "avgResponseTime": "Avg Response Time",
       "avgTtfbMs": "Avg TTFB",
-      "avgTokensPerSecond": "Avg tok/s"
+      "avgTokensPerSecond": "Avg tok/s",
+      "avgCostPerRequest": "Avg Cost/Req",
+      "avgCostPerMillionTokens": "Avg Cost/1M Tokens"
     },
+    "expandModelStats": "Expand model details",
+    "collapseModelStats": "Collapse model details",
     "states": {
       "loading": "Loading...",
       "noData": "No data available",

+ 5 - 1
messages/ja/dashboard.json

@@ -410,8 +410,12 @@
       "successRate": "成功率(%)",
       "avgResponseTime": "平均応答時間",
       "avgTtfbMs": "平均TTFB",
-      "avgTokensPerSecond": "平均トークン/秒"
+      "avgTokensPerSecond": "平均トークン/秒",
+      "avgCostPerRequest": "平均リクエスト単価",
+      "avgCostPerMillionTokens": "100万トークンあたりコスト"
     },
+    "expandModelStats": "モデル詳細を展開",
+    "collapseModelStats": "モデル詳細を折りたたむ",
     "states": {
       "loading": "読み込み中...",
       "noData": "データなし",

+ 5 - 1
messages/ru/dashboard.json

@@ -410,8 +410,12 @@
       "successRate": "Процент успеха",
       "avgResponseTime": "Среднее время ответа",
       "avgTtfbMs": "Средний TTFB",
-      "avgTokensPerSecond": "Средн. ток/с"
+      "avgTokensPerSecond": "Средн. ток/с",
+      "avgCostPerRequest": "Ср. стоимость/запрос",
+      "avgCostPerMillionTokens": "Ср. стоимость/1М токенов"
     },
+    "expandModelStats": "Развернуть модели",
+    "collapseModelStats": "Свернуть модели",
     "states": {
       "loading": "Загрузка...",
       "noData": "Нет данных",

+ 5 - 1
messages/zh-CN/dashboard.json

@@ -410,8 +410,12 @@
     "successRate": "成功率",
     "avgResponseTime": "平均响应时间",
     "avgTtfbMs": "平均 TTFB",
-    "avgTokensPerSecond": "平均输出速率"
+    "avgTokensPerSecond": "平均输出速率",
+    "avgCostPerRequest": "平均单次请求成本",
+    "avgCostPerMillionTokens": "平均百万 Token 成本"
   },
+    "expandModelStats": "展开模型详情",
+    "collapseModelStats": "收起模型详情",
     "states": {
       "loading": "加载中...",
       "noData": "暂无数据",

+ 5 - 1
messages/zh-TW/dashboard.json

@@ -410,8 +410,12 @@
       "successRate": "成功率(%)",
       "avgResponseTime": "平均回覆時間",
       "avgTtfbMs": "平均 TTFB(ms)",
-      "avgTokensPerSecond": "平均輸出速率"
+      "avgTokensPerSecond": "平均輸出速率",
+      "avgCostPerRequest": "平均每次請求成本",
+      "avgCostPerMillionTokens": "平均每百萬 Token 成本"
     },
+    "expandModelStats": "展開模型詳情",
+    "collapseModelStats": "收起模型詳情",
     "states": {
       "loading": "載入中...",
       "noData": "暫無資料",

+ 71 - 15
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx

@@ -1,8 +1,17 @@
 "use client";
 
-import { ArrowDown, ArrowUp, ArrowUpDown, Award, Medal, Trophy } from "lucide-react";
+import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
+  Award,
+  ChevronDown,
+  ChevronRight,
+  Medal,
+  Trophy,
+} from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useMemo, useState } from "react";
+import { Fragment, useMemo, useState } from "react";
 import { Badge } from "@/components/ui/badge";
 import { Card, CardContent } from "@/components/ui/card";
 import {
@@ -32,6 +41,7 @@ interface LeaderboardTableProps<T> {
   period: LeaderboardPeriod;
   columns: ColumnDef<T>[]; // 不包含"排名"列,组件会自动添加
   getRowKey?: (row: T, index: number) => string | number;
+  renderExpandedContent?: (row: T, index: number) => React.ReactNode | null;
 }
 
 export function LeaderboardTable<T>({
@@ -39,6 +49,7 @@ export function LeaderboardTable<T>({
   period,
   columns,
   getRowKey,
+  renderExpandedContent,
 }: LeaderboardTableProps<T>) {
   const t = useTranslations("dashboard.leaderboard");
 
@@ -46,6 +57,20 @@ export function LeaderboardTable<T>({
   const [sortKey, setSortKey] = useState<string | null>(null);
   const [sortDirection, setSortDirection] = useState<SortDirection>(null);
 
+  // 展开行状态
+  const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());
+  const toggleRow = (key: string | number) => {
+    setExpandedRows((prev) => {
+      const next = new Set(prev);
+      if (next.has(key)) {
+        next.delete(key);
+      } else {
+        next.add(key);
+      }
+      return next;
+    });
+  };
+
   // 判断列是否需要加粗
   const getShouldBold = (col: ColumnDef<T>) => {
     const isActiveSortColumn = sortKey === col.sortKey && sortDirection !== null;
@@ -204,22 +229,53 @@ export function LeaderboardTable<T>({
                 const rank = index + 1;
                 const isTopThree = rank <= 3;
                 const rowKey = getRowKey ? (getRowKey(row, index) ?? index) : index;
+                const hasExpandable = renderExpandedContent != null;
+                const expandedContent = hasExpandable ? renderExpandedContent(row, index) : null;
+                const isExpanded = expandedRows.has(rowKey);
 
                 return (
-                  <TableRow key={rowKey} className={isTopThree ? "bg-muted/50" : ""}>
-                    <TableCell>{getRankBadge(rank)}</TableCell>
-                    {columns.map((col, idx) => {
-                      const shouldBold = getShouldBold(col);
-                      return (
-                        <TableCell
-                          key={idx}
-                          className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`}
-                        >
-                          {col.cell(row, index)}
+                  <Fragment key={rowKey}>
+                    <TableRow
+                      className={`${isTopThree ? "bg-muted/50" : ""} ${hasExpandable && expandedContent ? "cursor-pointer" : ""}`}
+                      onClick={
+                        hasExpandable && expandedContent ? () => toggleRow(rowKey) : undefined
+                      }
+                    >
+                      <TableCell>
+                        <div className="flex items-center gap-1">
+                          {hasExpandable && expandedContent ? (
+                            isExpanded ? (
+                              <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
+                            ) : (
+                              <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
+                            )
+                          ) : null}
+                          {getRankBadge(rank)}
+                        </div>
+                      </TableCell>
+                      {columns.map((col, idx) => {
+                        const shouldBold = getShouldBold(col);
+                        return (
+                          <TableCell
+                            key={idx}
+                            className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`}
+                          >
+                            {col.cell(row, index)}
+                          </TableCell>
+                        );
+                      })}
+                    </TableRow>
+                    {isExpanded && expandedContent && (
+                      <TableRow
+                        key={`${rowKey}-expanded`}
+                        className="bg-muted/30 hover:bg-muted/30"
+                      >
+                        <TableCell colSpan={columns.length + 1} className="p-0">
+                          {expandedContent}
                         </TableCell>
-                      );
-                    })}
-                  </TableRow>
+                      </TableRow>
+                    )}
+                  </Fragment>
                 );
               })}
             </TableBody>

+ 93 - 3
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx

@@ -14,6 +14,7 @@ import type {
   DateRangeParams,
   LeaderboardEntry,
   LeaderboardPeriod,
+  ModelCacheHitStat,
   ModelLeaderboardEntry,
   ProviderCacheHitRateLeaderboardEntry,
   ProviderLeaderboardEntry,
@@ -28,7 +29,11 @@ interface LeaderboardViewProps {
 
 type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model";
 type UserEntry = LeaderboardEntry & { totalCostFormatted?: string };
-type ProviderEntry = ProviderLeaderboardEntry & { totalCostFormatted?: string };
+type ProviderEntry = ProviderLeaderboardEntry & {
+  totalCostFormatted?: string;
+  avgCostPerRequestFormatted?: string | null;
+  avgCostPerMillionTokensFormatted?: string | null;
+};
 type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry;
 type ModelEntry = ModelLeaderboardEntry & { totalCostFormatted?: string };
 type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry;
@@ -163,7 +168,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
     scope === "user"
       ? 5
       : scope === "provider"
-        ? 8
+        ? 10
         : scope === "providerCacheHitRate"
           ? 8
           : scope === "model"
@@ -265,6 +270,28 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
       sortKey: "avgTokensPerSecond",
       getValue: (row) => (row as ProviderEntry).avgTokensPerSecond ?? 0,
     },
+    {
+      header: t("columns.avgCostPerRequest"),
+      className: "text-right font-mono",
+      cell: (row) => {
+        const r = row as ProviderEntry;
+        if (r.avgCostPerRequest == null) return "-";
+        return r.avgCostPerRequestFormatted ?? r.avgCostPerRequest.toFixed(4);
+      },
+      sortKey: "avgCostPerRequest",
+      getValue: (row) => (row as ProviderEntry).avgCostPerRequest ?? 0,
+    },
+    {
+      header: t("columns.avgCostPerMillionTokens"),
+      className: "text-right font-mono",
+      cell: (row) => {
+        const r = row as ProviderEntry;
+        if (r.avgCostPerMillionTokens == null) return "-";
+        return r.avgCostPerMillionTokensFormatted ?? r.avgCostPerMillionTokens.toFixed(2);
+      },
+      sortKey: "avgCostPerMillionTokens",
+      getValue: (row) => (row as ProviderEntry).avgCostPerMillionTokens ?? 0,
+    },
   ];
 
   const providerCacheHitRateColumns: ColumnDef<ProviderCacheHitRateEntry>[] = [
@@ -484,7 +511,70 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
             </CardContent>
           </Card>
         ) : (
-          <LeaderboardTable data={data} period={period} columns={columns} getRowKey={rowKey} />
+          <LeaderboardTable
+            data={data}
+            period={period}
+            columns={columns}
+            getRowKey={rowKey}
+            renderExpandedContent={
+              scope === "providerCacheHitRate"
+                ? (row) => {
+                    const entry = row as ProviderCacheHitRateEntry & {
+                      modelStats?: ModelCacheHitStat[];
+                    };
+                    if (!entry.modelStats || entry.modelStats.length === 0) return null;
+                    return (
+                      <div className="px-8 py-3">
+                        <div className="text-xs text-muted-foreground mb-2 font-medium">
+                          {t("expandModelStats")}
+                        </div>
+                        <table className="w-full text-sm">
+                          <thead>
+                            <tr className="text-xs text-muted-foreground border-b">
+                              <th className="text-left py-1 pr-4">{t("columns.model")}</th>
+                              <th className="text-right py-1 pr-4">{t("columns.requests")}</th>
+                              <th className="text-right py-1 pr-4">
+                                {t("columns.cacheReadTokens")}
+                              </th>
+                              <th className="text-right py-1 pr-4">{t("columns.totalTokens")}</th>
+                              <th className="text-right py-1">{t("columns.cacheHitRate")}</th>
+                            </tr>
+                          </thead>
+                          <tbody>
+                            {entry.modelStats.map((ms) => {
+                              const rate = (ms.cacheHitRate ?? 0) * 100;
+                              const colorClass =
+                                rate >= 85
+                                  ? "text-green-600 dark:text-green-400"
+                                  : rate >= 60
+                                    ? "text-yellow-600 dark:text-yellow-400"
+                                    : "text-orange-600 dark:text-orange-400";
+                              return (
+                                <tr key={ms.model} className="border-b last:border-b-0">
+                                  <td className="py-1 pr-4 font-mono">{ms.model}</td>
+                                  <td className="text-right py-1 pr-4">
+                                    {ms.totalRequests.toLocaleString()}
+                                  </td>
+                                  <td className="text-right py-1 pr-4">
+                                    {formatTokenAmount(ms.cacheReadTokens)}
+                                  </td>
+                                  <td className="text-right py-1 pr-4">
+                                    {formatTokenAmount(ms.totalInputTokens)}
+                                  </td>
+                                  <td className={`text-right py-1 ${colorClass}`}>
+                                    {rate.toFixed(1)}%
+                                  </td>
+                                </tr>
+                              );
+                            })}
+                          </tbody>
+                        </table>
+                      </div>
+                    );
+                  }
+                : undefined
+            }
+          />
         )}
       </div>
     </div>

+ 34 - 10
src/app/api/leaderboard/route.ts

@@ -160,17 +160,41 @@ export async function GET(request: NextRequest) {
         totalCostFormatted: formatCurrency(entry.totalCost, systemSettings.currencyDisplay),
       };
 
-      if (typeof (entry as { cacheCreationCost?: unknown }).cacheCreationCost === "number") {
-        return {
-          ...base,
-          cacheCreationCostFormatted: formatCurrency(
-            (entry as { cacheCreationCost: number }).cacheCreationCost,
-            systemSettings.currencyDisplay
-          ),
-        };
-      }
+      // Provider scope: add avgCost formatted fields
+      const typedEntry = entry as {
+        avgCostPerRequest?: number | null;
+        avgCostPerMillionTokens?: number | null;
+        cacheCreationCost?: number;
+      };
 
-      return base;
+      const providerFields =
+        typeof typedEntry.avgCostPerRequest !== "undefined"
+          ? {
+              avgCostPerRequestFormatted:
+                typedEntry.avgCostPerRequest != null
+                  ? formatCurrency(typedEntry.avgCostPerRequest, systemSettings.currencyDisplay)
+                  : null,
+              avgCostPerMillionTokensFormatted:
+                typedEntry.avgCostPerMillionTokens != null
+                  ? formatCurrency(
+                      typedEntry.avgCostPerMillionTokens,
+                      systemSettings.currencyDisplay
+                    )
+                  : null,
+            }
+          : {};
+
+      const cacheFields =
+        typeof typedEntry.cacheCreationCost === "number"
+          ? {
+              cacheCreationCostFormatted: formatCurrency(
+                typedEntry.cacheCreationCost,
+                systemSettings.currencyDisplay
+              ),
+            }
+          : {};
+
+      return { ...base, ...providerFields, ...cacheFields };
     });
 
     logger.info("Leaderboard API: Access granted", {

+ 82 - 10
src/repository/leaderboard.ts

@@ -41,6 +41,19 @@ export interface ProviderLeaderboardEntry {
   successRate: number; // 0-1 之间的小数,UI 层负责格式化为百分比
   avgTtfbMs: number; // 毫秒
   avgTokensPerSecond: number; // tok/s(仅统计流式且可计算的请求)
+  avgCostPerRequest: number | null; // totalCost / totalRequests, null when totalRequests === 0
+  avgCostPerMillionTokens: number | null; // totalCost * 1_000_000 / totalTokens, null when totalTokens === 0
+}
+
+/**
+ * 供应商缓存命中率 - 模型级统计
+ */
+export interface ModelCacheHitStat {
+  model: string;
+  totalRequests: number;
+  cacheReadTokens: number;
+  totalInputTokens: number;
+  cacheHitRate: number; // 0-1
 }
 
 /**
@@ -58,6 +71,7 @@ export interface ProviderCacheHitRateLeaderboardEntry {
   /** @deprecated Use totalInputTokens instead */
   totalTokens: number;
   cacheHitRate: number; // 0-1 之间的小数,UI 层负责格式化为百分比
+  modelStats: ModelCacheHitStat[];
 }
 
 /**
@@ -413,16 +427,23 @@ async function findProviderLeaderboardWithTimezone(
     .groupBy(messageRequest.providerId, providers.name)
     .orderBy(desc(sql`sum(${messageRequest.costUsd})`));
 
-  return rankings.map((entry) => ({
-    providerId: entry.providerId,
-    providerName: entry.providerName,
-    totalRequests: entry.totalRequests,
-    totalCost: parseFloat(entry.totalCost),
-    totalTokens: entry.totalTokens,
-    successRate: entry.successRate ?? 0,
-    avgTtfbMs: entry.avgTtfbMs ?? 0,
-    avgTokensPerSecond: entry.avgTokensPerSecond ?? 0,
-  }));
+  return rankings.map((entry) => {
+    const totalCost = parseFloat(entry.totalCost);
+    const totalRequests = entry.totalRequests;
+    const totalTokens = entry.totalTokens;
+    return {
+      providerId: entry.providerId,
+      providerName: entry.providerName,
+      totalRequests,
+      totalCost,
+      totalTokens,
+      successRate: entry.successRate ?? 0,
+      avgTtfbMs: entry.avgTtfbMs ?? 0,
+      avgTokensPerSecond: entry.avgTokensPerSecond ?? 0,
+      avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null,
+      avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null,
+    };
+  });
 }
 
 /**
@@ -488,6 +509,56 @@ async function findProviderCacheHitRateLeaderboardWithTimezone(
     .groupBy(messageRequest.providerId, providers.name)
     .orderBy(desc(cacheHitRateExpr), desc(sql`count(*)`));
 
+  // Model-level cache hit breakdown per provider
+  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 modelTotalInput = sql<number>`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`;
+  const modelCacheRead = sql<number>`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`;
+  const modelCacheHitRate = sql<number>`COALESCE(
+    ${modelCacheRead} / NULLIF(${modelTotalInput}, 0::double precision),
+    0::double precision
+  )`;
+
+  const modelRows = await db
+    .select({
+      providerId: messageRequest.providerId,
+      model: modelField,
+      totalRequests: sql<number>`count(*)::double precision`,
+      cacheReadTokens: modelCacheRead,
+      totalInputTokens: modelTotalInput,
+      cacheHitRate: modelCacheHitRate,
+    })
+    .from(messageRequest)
+    .innerJoin(
+      providers,
+      and(sql`${messageRequest.providerId} = ${providers.id}`, isNull(providers.deletedAt))
+    )
+    .where(
+      and(...whereConditions.filter((c): c is NonNullable<(typeof whereConditions)[number]> => !!c))
+    )
+    .groupBy(messageRequest.providerId, modelField)
+    .orderBy(desc(modelCacheHitRate), desc(sql`count(*)`));
+
+  // Group model stats by providerId
+  const modelStatsByProvider = new Map<number, ModelCacheHitStat[]>();
+  for (const row of modelRows) {
+    if (!row.model || row.model.trim() === "") continue;
+    const stats = modelStatsByProvider.get(row.providerId) ?? [];
+    stats.push({
+      model: row.model,
+      totalRequests: row.totalRequests,
+      cacheReadTokens: row.cacheReadTokens,
+      totalInputTokens: row.totalInputTokens,
+      cacheHitRate: Math.min(Math.max(row.cacheHitRate ?? 0, 0), 1),
+    });
+    modelStatsByProvider.set(row.providerId, stats);
+  }
+
   return rankings.map((entry) => ({
     providerId: entry.providerId,
     providerName: entry.providerName,
@@ -498,6 +569,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone(
     totalInputTokens: entry.totalInputTokens,
     totalTokens: entry.totalInputTokens, // deprecated, for backward compatibility
     cacheHitRate: Math.min(Math.max(entry.cacheHitRate ?? 0, 0), 1),
+    modelStats: modelStatsByProvider.get(entry.providerId) ?? [],
   }));
 }
 

+ 115 - 0
tests/unit/api/leaderboard-route.test.ts

@@ -83,4 +83,119 @@ describe("GET /api/leaderboard", () => {
     expect(options.userTags).toBeUndefined();
     expect(options.userGroups).toBeUndefined();
   });
+
+  describe("additive provider fields", () => {
+    it("includes avgCostPerRequest and avgCostPerMillionTokens in provider scope response", async () => {
+      mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
+      mocks.getLeaderboardWithCache.mockResolvedValue([
+        {
+          providerId: 1,
+          providerName: "test-provider",
+          totalRequests: 100,
+          totalCost: 5.0,
+          totalTokens: 500000,
+          successRate: 0.95,
+          avgTtfbMs: 200,
+          avgTokensPerSecond: 50,
+          avgCostPerRequest: 0.05,
+          avgCostPerMillionTokens: 10.0,
+        },
+      ]);
+
+      const { GET } = await import("@/app/api/leaderboard/route");
+      const url = "http://localhost/api/leaderboard?scope=provider&period=daily";
+      const response = await GET({ nextUrl: new URL(url) } as any);
+      const body = await response.json();
+
+      expect(response.status).toBe(200);
+      expect(body).toHaveLength(1);
+
+      const entry = body[0];
+      // Additive fields must be present
+      expect(entry).toHaveProperty("avgCostPerRequest", 0.05);
+      expect(entry).toHaveProperty("avgCostPerMillionTokens", 10.0);
+      // Formatted variants should exist
+      expect(entry).toHaveProperty("avgCostPerRequestFormatted");
+      expect(entry).toHaveProperty("avgCostPerMillionTokensFormatted");
+      // Existing fields must still be present
+      expect(entry).toHaveProperty("totalCostFormatted");
+      expect(entry).toHaveProperty("providerId", 1);
+      expect(entry).toHaveProperty("providerName", "test-provider");
+    });
+
+    it("formats null avgCost fields without error", async () => {
+      mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
+      mocks.getLeaderboardWithCache.mockResolvedValue([
+        {
+          providerId: 2,
+          providerName: "zero-provider",
+          totalRequests: 0,
+          totalCost: 0,
+          totalTokens: 0,
+          successRate: 0,
+          avgTtfbMs: 0,
+          avgTokensPerSecond: 0,
+          avgCostPerRequest: null,
+          avgCostPerMillionTokens: null,
+        },
+      ]);
+
+      const { GET } = await import("@/app/api/leaderboard/route");
+      const url = "http://localhost/api/leaderboard?scope=provider&period=daily";
+      const response = await GET({ nextUrl: new URL(url) } as any);
+      const body = await response.json();
+
+      expect(response.status).toBe(200);
+      const entry = body[0];
+      expect(entry.avgCostPerRequest).toBeNull();
+      expect(entry.avgCostPerMillionTokens).toBeNull();
+    });
+
+    it("includes modelStats in providerCacheHitRate scope response", async () => {
+      mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } });
+      mocks.getLeaderboardWithCache.mockResolvedValue([
+        {
+          providerId: 1,
+          providerName: "cache-provider",
+          totalRequests: 50,
+          cacheReadTokens: 10000,
+          totalCost: 2.5,
+          cacheCreationCost: 1.0,
+          totalInputTokens: 20000,
+          totalTokens: 20000,
+          cacheHitRate: 0.5,
+          modelStats: [
+            {
+              model: "claude-3-opus",
+              totalRequests: 30,
+              cacheReadTokens: 8000,
+              totalInputTokens: 15000,
+              cacheHitRate: 0.53,
+            },
+            {
+              model: "claude-3-sonnet",
+              totalRequests: 20,
+              cacheReadTokens: 2000,
+              totalInputTokens: 5000,
+              cacheHitRate: 0.4,
+            },
+          ],
+        },
+      ]);
+
+      const { GET } = await import("@/app/api/leaderboard/route");
+      const url = "http://localhost/api/leaderboard?scope=providerCacheHitRate&period=daily";
+      const response = await GET({ nextUrl: new URL(url) } as any);
+      const body = await response.json();
+
+      expect(response.status).toBe(200);
+      expect(body).toHaveLength(1);
+
+      const entry = body[0];
+      expect(entry).toHaveProperty("modelStats");
+      expect(entry.modelStats).toHaveLength(2);
+      expect(entry.modelStats[0]).toHaveProperty("model", "claude-3-opus");
+      expect(entry.modelStats[0]).toHaveProperty("cacheHitRate", 0.53);
+    });
+  });
 });

+ 473 - 0
tests/unit/repository/leaderboard-provider-metrics.test.ts

@@ -0,0 +1,473 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * GREEN tests for provider leaderboard average cost metrics and cache-hit model breakdown.
+ *
+ * These tests verify the semantic contracts:
+ * - avgCostPerRequest = totalCost / totalRequests (null when totalRequests === 0)
+ * - avgCostPerMillionTokens = totalCost * 1_000_000 / totalTokens (null when totalTokens === 0)
+ * - ProviderCacheHitRateLeaderboardEntry.modelStats: nested model-level breakdown
+ */
+
+const createChainMock = (resolvedData: unknown[]) => ({
+  from: vi.fn().mockReturnThis(),
+  innerJoin: vi.fn().mockReturnThis(),
+  where: vi.fn().mockReturnThis(),
+  groupBy: vi.fn().mockReturnThis(),
+  orderBy: vi.fn().mockResolvedValue(resolvedData),
+});
+
+// Track select calls to return different chains for different queries
+let selectCallIndex = 0;
+let chainMocks: ReturnType<typeof createChainMock>[] = [];
+
+const mockSelect = vi.fn(() => {
+  const chain = chainMocks[selectCallIndex] ?? createChainMock([]);
+  selectCallIndex++;
+  return chain;
+});
+
+const mocks = vi.hoisted(() => ({
+  resolveSystemTimezone: vi.fn(),
+  getSystemSettings: vi.fn(),
+}));
+
+vi.mock("@/drizzle/db", () => ({
+  db: {
+    select: (...args: unknown[]) => mockSelect(...args),
+  },
+}));
+
+vi.mock("@/drizzle/schema", () => ({
+  messageRequest: {
+    deletedAt: "deletedAt",
+    providerId: "providerId",
+    userId: "userId",
+    costUsd: "costUsd",
+    inputTokens: "inputTokens",
+    outputTokens: "outputTokens",
+    cacheCreationInputTokens: "cacheCreationInputTokens",
+    cacheReadInputTokens: "cacheReadInputTokens",
+    errorMessage: "errorMessage",
+    blockedBy: "blockedBy",
+    createdAt: "createdAt",
+    ttfbMs: "ttfbMs",
+    durationMs: "durationMs",
+    model: "model",
+    originalModel: "originalModel",
+  },
+  providers: {
+    id: "id",
+    name: "name",
+    deletedAt: "deletedAt",
+    providerType: "providerType",
+  },
+  users: {},
+}));
+
+vi.mock("@/lib/utils/timezone", () => ({
+  resolveSystemTimezone: mocks.resolveSystemTimezone,
+}));
+
+vi.mock("@/repository/system-config", () => ({
+  getSystemSettings: mocks.getSystemSettings,
+}));
+
+describe("Provider Leaderboard Average Cost Metrics", () => {
+  beforeEach(() => {
+    vi.resetModules();
+    selectCallIndex = 0;
+    chainMocks = [];
+    mockSelect.mockClear();
+    mocks.resolveSystemTimezone.mockResolvedValue("UTC");
+    mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" });
+  });
+
+  it("computes avgCostPerRequest = totalCost / totalRequests for valid denominators", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "test-provider",
+          totalRequests: 100,
+          totalCost: "5.0",
+          totalTokens: 500000,
+          successRate: 0.95,
+          avgTtfbMs: 200,
+          avgTokensPerSecond: 50,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderLeaderboard();
+
+    expect(result).toHaveLength(1);
+    const entry = result[0];
+    expect(entry).toHaveProperty("avgCostPerRequest");
+    expect(entry.avgCostPerRequest).toBeCloseTo(5.0 / 100);
+
+    type HasAvgCostPerRequest = { avgCostPerRequest: number | null };
+    const _typeCheck: HasAvgCostPerRequest = {} as Awaited<
+      ReturnType<typeof findDailyProviderLeaderboard>
+    >[number];
+    expect(_typeCheck).toBeDefined();
+  });
+
+  it("computes avgCostPerMillionTokens = totalCost * 1_000_000 / totalTokens for valid denominators", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "test-provider",
+          totalRequests: 100,
+          totalCost: "5.0",
+          totalTokens: 500000,
+          successRate: 0.95,
+          avgTtfbMs: 200,
+          avgTokensPerSecond: 50,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderLeaderboard();
+
+    expect(result).toHaveLength(1);
+    const entry = result[0];
+    expect(entry).toHaveProperty("avgCostPerMillionTokens");
+    expect(entry.avgCostPerMillionTokens).toBeCloseTo((5.0 * 1_000_000) / 500000);
+  });
+
+  it("returns null for avgCostPerRequest when totalRequests is 0", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "zero-provider",
+          totalRequests: 0,
+          totalCost: "0",
+          totalTokens: 0,
+          successRate: 0,
+          avgTtfbMs: 0,
+          avgTokensPerSecond: 0,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderLeaderboard();
+
+    expect(result).toHaveLength(1);
+    expect(result[0].avgCostPerRequest).toBeNull();
+  });
+
+  it("returns null for avgCostPerMillionTokens when totalTokens is 0", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "zero-provider",
+          totalRequests: 5,
+          totalCost: "1.0",
+          totalTokens: 0,
+          successRate: 0,
+          avgTtfbMs: 0,
+          avgTokensPerSecond: 0,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderLeaderboard();
+
+    expect(result).toHaveLength(1);
+    expect(result[0].avgCostPerMillionTokens).toBeNull();
+  });
+
+  it("preserves provider sort order by totalCost descending", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "expensive",
+          totalRequests: 100,
+          totalCost: "10.0",
+          totalTokens: 500000,
+          successRate: 0.95,
+          avgTtfbMs: 200,
+          avgTokensPerSecond: 50,
+        },
+        {
+          providerId: 2,
+          providerName: "cheap",
+          totalRequests: 50,
+          totalCost: "2.0",
+          totalTokens: 100000,
+          successRate: 0.9,
+          avgTtfbMs: 300,
+          avgTokensPerSecond: 40,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderLeaderboard();
+
+    expect(result).toHaveLength(2);
+    expect(result[0].totalCost).toBeGreaterThanOrEqual(result[1].totalCost);
+  });
+});
+
+describe("Provider Cache Hit Rate Model Breakdown", () => {
+  beforeEach(() => {
+    vi.resetModules();
+    selectCallIndex = 0;
+    chainMocks = [];
+    mockSelect.mockClear();
+    mocks.resolveSystemTimezone.mockResolvedValue("UTC");
+    mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" });
+  });
+
+  it("includes modelStats field on cache-hit leaderboard entries", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "cache-provider",
+          totalRequests: 50,
+          totalCost: "2.5",
+          cacheReadTokens: 10000,
+          cacheCreationCost: "1.0",
+          totalInputTokens: 20000,
+          cacheHitRate: 0.5,
+        },
+      ]),
+      createChainMock([
+        {
+          providerId: 1,
+          model: "claude-3-opus",
+          totalRequests: 30,
+          cacheReadTokens: 8000,
+          totalInputTokens: 15000,
+          cacheHitRate: 0.53,
+        },
+        {
+          providerId: 1,
+          model: "claude-3-sonnet",
+          totalRequests: 20,
+          cacheReadTokens: 2000,
+          totalInputTokens: 5000,
+          cacheHitRate: 0.4,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderCacheHitRateLeaderboard();
+
+    expect(result).toHaveLength(1);
+    const entry = result[0];
+    expect(entry).toHaveProperty("modelStats");
+    expect(Array.isArray(entry.modelStats)).toBe(true);
+    expect(entry.modelStats).toHaveLength(2);
+    expect(entry.modelStats[0].model).toBe("claude-3-opus");
+  });
+
+  it("provider cache hit ranking sort stability preserved after adding modelStats", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "high-cache",
+          totalRequests: 50,
+          totalCost: "2.5",
+          cacheReadTokens: 15000,
+          cacheCreationCost: "1.0",
+          totalInputTokens: 20000,
+          cacheHitRate: 0.75,
+        },
+        {
+          providerId: 2,
+          providerName: "low-cache",
+          totalRequests: 30,
+          totalCost: "1.0",
+          cacheReadTokens: 2000,
+          cacheCreationCost: "0.5",
+          totalInputTokens: 10000,
+          cacheHitRate: 0.2,
+        },
+      ]),
+      createChainMock([]),
+    ];
+
+    const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderCacheHitRateLeaderboard();
+
+    expect(result).toHaveLength(2);
+    expect(result[0].cacheHitRate).toBeGreaterThanOrEqual(result[1].cacheHitRate);
+  });
+
+  it("model breakdown excludes empty model names and has deterministic order", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "provider-a",
+          totalRequests: 50,
+          totalCost: "2.5",
+          cacheReadTokens: 10000,
+          cacheCreationCost: "1.0",
+          totalInputTokens: 20000,
+          cacheHitRate: 0.5,
+        },
+      ]),
+      createChainMock([
+        {
+          providerId: 1,
+          model: "claude-3-opus",
+          totalRequests: 30,
+          cacheReadTokens: 8000,
+          totalInputTokens: 15000,
+          cacheHitRate: 0.53,
+        },
+        {
+          providerId: 1,
+          model: "",
+          totalRequests: 5,
+          cacheReadTokens: 100,
+          totalInputTokens: 500,
+          cacheHitRate: 0.2,
+        },
+        {
+          providerId: 1,
+          model: "claude-3-sonnet",
+          totalRequests: 15,
+          cacheReadTokens: 1900,
+          totalInputTokens: 4500,
+          cacheHitRate: 0.42,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderCacheHitRateLeaderboard();
+
+    expect(result).toHaveLength(1);
+    const entry = result[0];
+    // Empty model names must be excluded (only 2 valid models)
+    expect(entry.modelStats).toHaveLength(2);
+    for (const ms of entry.modelStats) {
+      expect(ms.model).toBeTruthy();
+      expect(ms.model.trim()).not.toBe("");
+    }
+    // Deterministic order: cacheHitRate desc (0.53 > 0.42)
+    expect(entry.modelStats[0].cacheHitRate).toBeGreaterThanOrEqual(
+      entry.modelStats[entry.modelStats.length - 1].cacheHitRate
+    );
+  });
+
+  it("preserves all existing provider-level fields unchanged", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "full-provider",
+          totalRequests: 50,
+          totalCost: "2.5",
+          cacheReadTokens: 10000,
+          cacheCreationCost: "1.0",
+          totalInputTokens: 20000,
+          cacheHitRate: 0.5,
+        },
+      ]),
+      createChainMock([]),
+    ];
+
+    const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderCacheHitRateLeaderboard();
+
+    expect(result).toHaveLength(1);
+    const entry = result[0];
+    expect(entry).toHaveProperty("providerId", 1);
+    expect(entry).toHaveProperty("providerName", "full-provider");
+    expect(entry).toHaveProperty("totalRequests", 50);
+    expect(entry).toHaveProperty("cacheReadTokens", 10000);
+    expect(entry).toHaveProperty("totalCost", 2.5);
+    expect(entry).toHaveProperty("cacheCreationCost", 1.0);
+    expect(entry).toHaveProperty("totalInputTokens", 20000);
+    expect(entry).toHaveProperty("cacheHitRate", 0.5);
+    expect(entry).toHaveProperty("modelStats");
+  });
+
+  it("groups model stats correctly across multiple providers", async () => {
+    chainMocks = [
+      createChainMock([
+        {
+          providerId: 1,
+          providerName: "provider-alpha",
+          totalRequests: 50,
+          totalCost: "2.5",
+          cacheReadTokens: 10000,
+          cacheCreationCost: "1.0",
+          totalInputTokens: 20000,
+          cacheHitRate: 0.5,
+        },
+        {
+          providerId: 2,
+          providerName: "provider-beta",
+          totalRequests: 30,
+          totalCost: "1.0",
+          cacheReadTokens: 5000,
+          cacheCreationCost: "0.5",
+          totalInputTokens: 10000,
+          cacheHitRate: 0.5,
+        },
+      ]),
+      createChainMock([
+        {
+          providerId: 1,
+          model: "model-a",
+          totalRequests: 30,
+          cacheReadTokens: 6000,
+          totalInputTokens: 12000,
+          cacheHitRate: 0.5,
+        },
+        {
+          providerId: 1,
+          model: "model-b",
+          totalRequests: 20,
+          cacheReadTokens: 4000,
+          totalInputTokens: 8000,
+          cacheHitRate: 0.5,
+        },
+        {
+          providerId: 2,
+          model: "model-c",
+          totalRequests: 30,
+          cacheReadTokens: 5000,
+          totalInputTokens: 10000,
+          cacheHitRate: 0.5,
+        },
+      ]),
+    ];
+
+    const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard");
+    const result = await findDailyProviderCacheHitRateLeaderboard();
+
+    expect(result).toHaveLength(2);
+
+    // Provider 1 should have 2 model stats
+    const p1 = result.find((r) => r.providerId === 1);
+    expect(p1).toBeDefined();
+    expect(p1!.modelStats).toHaveLength(2);
+    const p1Models = p1!.modelStats.map((m) => m.model).sort();
+    expect(p1Models).toEqual(["model-a", "model-b"]);
+
+    // Provider 2 should have 1 model stat
+    const p2 = result.find((r) => r.providerId === 2);
+    expect(p2).toBeDefined();
+    expect(p2!.modelStats).toHaveLength(1);
+    expect(p2!.modelStats[0].model).toBe("model-c");
+  });
+});