diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index d161bd537..851cc15cb 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -11,7 +11,7 @@ import { Trophy, } from "lucide-react"; import { useTranslations } from "next-intl"; -import { Fragment, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -28,7 +28,12 @@ import type { LeaderboardPeriod } from "@/repository/leaderboard"; export interface ColumnDef { header: string; className?: string; - cell: (row: T, index: number) => React.ReactNode; + /** + * index 语义: + * - 父行:按当前排序后的全局行序(从 0 开始) + * - 子行:父行内的子行序(从 0 开始) + */ + cell: (row: T, index: number, isSubRow?: boolean) => React.ReactNode; sortKey?: string; // 用于排序的字段名 getValue?: (row: T) => number | string; // 获取排序值的函数 defaultBold?: boolean; // 默认加粗(无排序时显示加粗) @@ -36,22 +41,32 @@ export interface ColumnDef { type SortDirection = "asc" | "desc" | null; -interface LeaderboardTableProps { - data: T[]; +interface LeaderboardTableProps { + data: TParent[]; period: LeaderboardPeriod; - columns: ColumnDef[]; // 不包含"排名"列,组件会自动添加 - getRowKey?: (row: T, index: number) => string | number; - renderExpandedContent?: (row: T, index: number) => React.ReactNode | null; + columns: ColumnDef[]; // 不包含"排名"列,组件会自动添加 + getRowKey?: (row: TParent, index: number) => string | number; + /** 返回子行数据(非空且长度 > 0 时,父行展示可展开图标) */ + getSubRows?: (row: TParent, index: number) => TSub[] | null | undefined; + /** 子行的 React key(默认使用 `${parentKey}-${subIndex}` 组合) */ + getSubRowKey?: ( + subRow: TSub, + parentRow: TParent, + parentIndex: number, + subIndex: number + ) => string | number; } -export function LeaderboardTable({ +export function LeaderboardTable({ data, period, columns, getRowKey, - renderExpandedContent, -}: LeaderboardTableProps) { + getSubRows, + getSubRowKey, +}: LeaderboardTableProps) { const t = useTranslations("dashboard.leaderboard"); + type TableRow = TParent | TSub; // 排序状态 const [sortKey, setSortKey] = useState(null); @@ -71,8 +86,16 @@ export function LeaderboardTable({ }); }; + // 当调用方未提供稳定 rowKey 时(回退到 index),排序会导致展开态错位;此时在排序/数据变化时清空展开态,至少避免错位展开。 + // biome-ignore lint/correctness/useExhaustiveDependencies: 依赖用于在排序/数据变化时触发清空,避免 index key 造成错位展开 + useEffect(() => { + if (!getRowKey) { + setExpandedRows(new Set()); + } + }, [data, sortKey, sortDirection, getRowKey]); + // 判断列是否需要加粗 - const getShouldBold = (col: ColumnDef) => { + const getShouldBold = (col: ColumnDef) => { const isActiveSortColumn = sortKey === col.sortKey && sortDirection !== null; const noSorting = sortKey === null; return isActiveSortColumn || (col.defaultBold && noSorting); @@ -229,27 +252,34 @@ export function LeaderboardTable({ 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); + const subRows = getSubRows ? getSubRows(row, index) : null; + const hasExpandable = (subRows?.length ?? 0) > 0; + const isExpanded = hasExpandable && expandedRows.has(rowKey); return ( - toggleRow(rowKey) : undefined - } - > +
- {hasExpandable && expandedContent ? ( - isExpanded ? ( - - ) : ( - - ) - ) : null} + {hasExpandable ? ( + + ) : ( + @@ -260,21 +290,38 @@ export function LeaderboardTable({ key={idx} className={`${col.className || ""} ${shouldBold ? "font-bold" : ""}`} > - {col.cell(row, index)} + {col.cell(row, index, false)} ); })} - {isExpanded && expandedContent && ( - - - {expandedContent} - - - )} + {isExpanded && + (subRows ?? []).map((subRow, subIndex) => { + const rawSubKey = getSubRowKey + ? getSubRowKey(subRow, row, index, subIndex) + : subIndex; + const subKey = `${rowKey}-${String(rawSubKey)}`; + return ( + + +
+
+
+ + {columns.map((col, idx) => { + const shouldBold = getShouldBold(col); + return ( + + {col.cell(subRow, subIndex, true)} + + ); + })} + + ); + })} ); })} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index ccc13eb22..66baac510 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -16,6 +16,7 @@ import type { LeaderboardPeriod, ModelCacheHitStat, ModelLeaderboardEntry, + ModelProviderStat, ProviderCacheHitRateLeaderboardEntry, ProviderLeaderboardEntry, } from "@/repository/leaderboard"; @@ -28,14 +29,23 @@ interface LeaderboardViewProps { } type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model"; -type UserEntry = LeaderboardEntry & { totalCostFormatted?: string }; -type ProviderEntry = ProviderLeaderboardEntry & { +type TotalCostFormattedFields = { totalCostFormatted?: string }; +type ProviderCostFormattedFields = { + // API 额外返回的展示用字段(格式化后的字符串) totalCostFormatted?: string; avgCostPerRequestFormatted?: string | null; avgCostPerMillionTokensFormatted?: string | null; }; +type UserEntry = LeaderboardEntry & TotalCostFormattedFields; +type ModelEntry = ModelLeaderboardEntry & TotalCostFormattedFields; +type ModelProviderStatClient = ModelProviderStat & ProviderCostFormattedFields; +type ProviderEntry = Omit & + ProviderCostFormattedFields & { + modelStats?: ModelProviderStatClient[]; + }; +type ProviderTableRow = ProviderEntry | ModelProviderStatClient; type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry; -type ModelEntry = ModelLeaderboardEntry & { totalCostFormatted?: string }; +type ProviderCacheHitRateTableRow = ProviderCacheHitRateEntry | ModelCacheHitStat; type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry; const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; @@ -122,6 +132,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) { url += `&providerType=${encodeURIComponent(providerTypeFilter)}`; } + if (scope === "provider") { + url += "&includeModelStats=1"; + } if (scope === "user") { if (userTagFilters.length > 0) { url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; @@ -177,142 +190,146 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const skeletonGridStyle = { gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))` }; // 列定义(根据 scope 动态切换) + const renderSubModelLabel = (model: string) => ( +
+ {model} +
+ ); + const userColumns: ColumnDef[] = [ { header: t("columns.user"), - cell: (row) => (row as UserEntry).userName, + cell: (row) => row.userName, sortKey: "userName", - getValue: (row) => (row as UserEntry).userName, + getValue: (row) => row.userName, }, { header: t("columns.requests"), className: "text-right", - cell: (row) => (row as UserEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as UserEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.tokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as UserEntry).totalTokens), + cell: (row) => formatTokenAmount(row.totalTokens), sortKey: "totalTokens", - getValue: (row) => (row as UserEntry).totalTokens, + getValue: (row) => row.totalTokens, }, { header: t("columns.consumedAmount"), className: "text-right font-mono", - cell: (row) => { - const r = row as UserEntry & { totalCostFormatted?: string }; - return r.totalCostFormatted ?? r.totalCost; - }, + cell: (row) => row.totalCostFormatted ?? row.totalCost, sortKey: "totalCost", - getValue: (row) => (row as UserEntry).totalCost, + getValue: (row) => row.totalCost, defaultBold: true, }, ]; - const providerColumns: ColumnDef[] = [ + const providerColumns: ColumnDef[] = [ { header: t("columns.provider"), - cell: (row) => (row as ProviderEntry).providerName, + cell: (row) => { + if ("providerName" in row) return row.providerName; + return renderSubModelLabel(row.model); + }, sortKey: "providerName", - getValue: (row) => (row as ProviderEntry).providerName, + getValue: (row) => ("providerName" in row ? row.providerName : row.model), }, { header: t("columns.requests"), className: "text-right", - cell: (row) => (row as ProviderEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as ProviderEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.cost"), className: "text-right font-mono", - cell: (row) => { - const r = row as ProviderEntry & { totalCostFormatted?: string }; - return r.totalCostFormatted ?? r.totalCost; - }, + cell: (row) => row.totalCostFormatted ?? row.totalCost, sortKey: "totalCost", - getValue: (row) => (row as ProviderEntry).totalCost, + getValue: (row) => row.totalCost, defaultBold: true, }, { header: t("columns.tokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderEntry).totalTokens), + cell: (row) => formatTokenAmount(row.totalTokens), sortKey: "totalTokens", - getValue: (row) => (row as ProviderEntry).totalTokens, + getValue: (row) => row.totalTokens, }, { header: t("columns.successRate"), className: "text-right", - cell: (row) => `${(Number((row as ProviderEntry).successRate || 0) * 100).toFixed(1)}%`, + cell: (row) => `${(Number(row.successRate || 0) * 100).toFixed(1)}%`, sortKey: "successRate", - getValue: (row) => (row as ProviderEntry).successRate, + getValue: (row) => row.successRate, }, { header: t("columns.avgTtfbMs"), className: "text-right", cell: (row) => { - const val = (row as ProviderEntry).avgTtfbMs; + const val = row.avgTtfbMs; return val && val > 0 ? `${Math.round(val).toLocaleString()} ms` : "-"; }, sortKey: "avgTtfbMs", - getValue: (row) => (row as ProviderEntry).avgTtfbMs ?? 0, + getValue: (row) => row.avgTtfbMs ?? 0, }, { header: t("columns.avgTokensPerSecond"), className: "text-right", cell: (row) => { - const val = (row as ProviderEntry).avgTokensPerSecond; + const val = row.avgTokensPerSecond; return val && val > 0 ? `${val.toFixed(1)} tok/s` : "-"; }, sortKey: "avgTokensPerSecond", - getValue: (row) => (row as ProviderEntry).avgTokensPerSecond ?? 0, + getValue: (row) => row.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); + if (row.avgCostPerRequest == null) return "-"; + return row.avgCostPerRequestFormatted ?? row.avgCostPerRequest.toFixed(4); }, sortKey: "avgCostPerRequest", - getValue: (row) => (row as ProviderEntry).avgCostPerRequest ?? 0, + getValue: (row) => row.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); + if (row.avgCostPerMillionTokens == null) return "-"; + return row.avgCostPerMillionTokensFormatted ?? row.avgCostPerMillionTokens.toFixed(2); }, sortKey: "avgCostPerMillionTokens", - getValue: (row) => (row as ProviderEntry).avgCostPerMillionTokens ?? 0, + getValue: (row) => row.avgCostPerMillionTokens ?? 0, }, ]; - const providerCacheHitRateColumns: ColumnDef[] = [ + const providerCacheHitRateColumns: ColumnDef[] = [ { header: t("columns.provider"), - cell: (row) => (row as ProviderCacheHitRateEntry).providerName, + cell: (row) => { + if ("providerName" in row) return row.providerName; + return renderSubModelLabel(row.model); + }, sortKey: "providerName", - getValue: (row) => (row as ProviderCacheHitRateEntry).providerName, + getValue: (row) => ("providerName" in row ? row.providerName : row.model), }, { header: t("columns.cacheHitRequests"), className: "text-right", - cell: (row) => (row as ProviderCacheHitRateEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as ProviderCacheHitRateEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.cacheHitRate"), className: "text-right", cell: (row) => { - const rate = Number((row as ProviderCacheHitRateEntry).cacheHitRate || 0) * 100; + const rate = Number(row.cacheHitRate || 0) * 100; const colorClass = rate >= 85 ? "text-green-600 dark:text-green-400" @@ -322,89 +339,107 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return {rate.toFixed(1)}%; }, sortKey: "cacheHitRate", - getValue: (row) => (row as ProviderCacheHitRateEntry).cacheHitRate, + getValue: (row) => row.cacheHitRate, }, { header: t("columns.cacheReadTokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).cacheReadTokens), + cell: (row) => formatTokenAmount(row.cacheReadTokens), sortKey: "cacheReadTokens", - getValue: (row) => (row as ProviderCacheHitRateEntry).cacheReadTokens, + getValue: (row) => row.cacheReadTokens, }, { header: t("columns.totalTokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalInputTokens), + cell: (row) => formatTokenAmount(row.totalInputTokens), sortKey: "totalInputTokens", - getValue: (row) => (row as ProviderCacheHitRateEntry).totalInputTokens, + getValue: (row) => row.totalInputTokens, }, ]; const modelColumns: ColumnDef[] = [ { header: t("columns.model"), - cell: (row) => {(row as ModelEntry).model}, + cell: (row) => {row.model}, sortKey: "model", - getValue: (row) => (row as ModelEntry).model, + getValue: (row) => row.model, }, { header: t("columns.requests"), className: "text-right", - cell: (row) => (row as ModelEntry).totalRequests.toLocaleString(), + cell: (row) => row.totalRequests.toLocaleString(), sortKey: "totalRequests", - getValue: (row) => (row as ModelEntry).totalRequests, + getValue: (row) => row.totalRequests, }, { header: t("columns.tokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ModelEntry).totalTokens), + cell: (row) => formatTokenAmount(row.totalTokens), sortKey: "totalTokens", - getValue: (row) => (row as ModelEntry).totalTokens, + getValue: (row) => row.totalTokens, }, { header: t("columns.cost"), className: "text-right font-mono", - cell: (row) => { - const r = row as ModelEntry & { totalCostFormatted?: string }; - return r.totalCostFormatted ?? r.totalCost; - }, + cell: (row) => row.totalCostFormatted ?? row.totalCost, sortKey: "totalCost", - getValue: (row) => (row as ModelEntry).totalCost, + getValue: (row) => row.totalCost, defaultBold: true, }, { header: t("columns.successRate"), className: "text-right", - cell: (row) => `${(Number((row as ModelEntry).successRate || 0) * 100).toFixed(1)}%`, + cell: (row) => `${(Number(row.successRate || 0) * 100).toFixed(1)}%`, sortKey: "successRate", - getValue: (row) => (row as ModelEntry).successRate, + getValue: (row) => row.successRate, }, ]; - const columns = (() => { - switch (scope) { - case "user": - return userColumns as ColumnDef[]; - case "provider": - return providerColumns as ColumnDef[]; - case "providerCacheHitRate": - return providerCacheHitRateColumns as ColumnDef[]; - case "model": - return modelColumns as ColumnDef[]; - } - })(); - - const rowKey = (row: AnyEntry) => { - switch (scope) { - case "user": - 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; - } + const renderUserTable = () => ( + + data={data as UserEntry[]} + period={period} + columns={userColumns} + getRowKey={(row) => row.userId} + /> + ); + + const renderProviderTable = () => ( + + data={data as ProviderEntry[]} + period={period} + columns={providerColumns} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} + /> + ); + + const renderProviderCacheHitRateTable = () => ( + + data={data as ProviderCacheHitRateEntry[]} + period={period} + columns={providerCacheHitRateColumns} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} + /> + ); + + const renderModelTable = () => ( + + data={data as ModelEntry[]} + period={period} + columns={modelColumns} + getRowKey={(row) => row.model} + /> + ); + + const renderTable = () => { + if (scope === "user") return renderUserTable(); + if (scope === "provider") return renderProviderTable(); + if (scope === "providerCacheHitRate") return renderProviderCacheHitRateTable(); + return renderModelTable(); }; return ( @@ -511,70 +546,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) : ( - { - const entry = row as ProviderCacheHitRateEntry & { - modelStats?: ModelCacheHitStat[]; - }; - if (!entry.modelStats || entry.modelStats.length === 0) return null; - return ( -
-
- {t("expandModelStats")} -
- - - - - - - - - - - - {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 ( - - - - - - - - ); - })} - -
{t("columns.model")}{t("columns.requests")} - {t("columns.cacheReadTokens")} - {t("columns.totalTokens")}{t("columns.cacheHitRate")}
{ms.model} - {ms.totalRequests.toLocaleString()} - - {formatTokenAmount(ms.cacheReadTokens)} - - {formatTokenAmount(ms.totalInputTokens)} - - {rate.toFixed(1)}% -
-
- ); - } - : undefined - } - /> + renderTable() )}
diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index e703a1e71..8fdef012a 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -35,6 +35,7 @@ export const runtime = "nodejs"; * GET /api/leaderboard?period=daily|weekly|monthly|allTime|custom&scope=user|provider|providerCacheHitRate|model * 当 period=custom 时,需要提供 startDate 和 endDate 参数 (YYYY-MM-DD 格式) * 当 scope=providerCacheHitRate 时,可选 providerType=claude|claude-auth|codex|gemini|gemini-cli|openai-compatible + * 当 scope=provider 时,可选 includeModelStats=true|1,返回供应商下各模型的拆分数据 * * 需要认证,普通用户需要 allowGlobalUsageView 权限 * 实时计算 + Redis 乐观缓存(60 秒 TTL) @@ -75,6 +76,7 @@ export async function GET(request: NextRequest) { const startDate = searchParams.get("startDate"); const endDate = searchParams.get("endDate"); const providerTypeParam = searchParams.get("providerType"); + const includeModelStatsParam = searchParams.get("includeModelStats"); const userTagsParam = searchParams.get("userTags"); const userGroupsParam = searchParams.get("userGroups"); @@ -127,6 +129,12 @@ export async function GET(request: NextRequest) { providerType = providerTypeParam; } + const includeModelStats = + scope === "provider" && + (includeModelStatsParam === "1" || + includeModelStatsParam === "true" || + includeModelStatsParam === "yes"); + const parseListParam = (param: string | null): string[] | undefined => { if (!param) return undefined; const items = param @@ -150,7 +158,7 @@ export async function GET(request: NextRequest) { systemSettings.currencyDisplay, scope, dateRange, - { providerType, userTags, userGroups } + { providerType, userTags, userGroups, includeModelStats } ); // 格式化金额字段 @@ -165,6 +173,7 @@ export async function GET(request: NextRequest) { avgCostPerRequest?: number | null; avgCostPerMillionTokens?: number | null; cacheCreationCost?: number; + modelStats?: unknown[]; }; const providerFields = @@ -194,7 +203,36 @@ export async function GET(request: NextRequest) { } : {}; - return { ...base, ...providerFields, ...cacheFields }; + const modelStatsFormatted = + scope === "provider" && Array.isArray(typedEntry.modelStats) + ? typedEntry.modelStats.map((ms) => { + const stat = ms as { + totalCost: number; + avgCostPerRequest: number | null; + avgCostPerMillionTokens: number | null; + } & Record; + + return { + ...stat, + totalCostFormatted: formatCurrency(stat.totalCost, systemSettings.currencyDisplay), + avgCostPerRequestFormatted: + stat.avgCostPerRequest != null + ? formatCurrency(stat.avgCostPerRequest, systemSettings.currencyDisplay) + : null, + avgCostPerMillionTokensFormatted: + stat.avgCostPerMillionTokens != null + ? formatCurrency(stat.avgCostPerMillionTokens, systemSettings.currencyDisplay) + : null, + }; + }) + : undefined; + + return { + ...base, + ...providerFields, + ...cacheFields, + ...(modelStatsFormatted !== undefined ? { modelStats: modelStatsFormatted } : {}), + }; }); logger.info("Leaderboard API: Access granted", { @@ -205,6 +243,7 @@ export async function GET(request: NextRequest) { scope, dateRange, providerType, + includeModelStats, userTags, userGroups, entriesCount: data.length, diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 5bbdc3251..26abb74ac 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -46,6 +46,8 @@ export interface LeaderboardFilters { providerType?: ProviderType; userTags?: string[]; userGroups?: string[]; + /** 仅 scope=provider 生效:是否包含按模型拆分的数据(ProviderLeaderboardEntry.modelStats) */ + includeModelStats?: boolean; } /** @@ -62,6 +64,8 @@ function buildCacheKey( ): string { const now = new Date(); const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : ""; + const includeModelStatsSuffix = + scope === "provider" && filters?.includeModelStats ? ":includeModelStats" : ""; let userFilterSuffix = ""; if (scope === "user") { @@ -76,22 +80,22 @@ function buildCacheKey( if (period === "custom" && dateRange) { // leaderboard:{scope}:custom:2025-01-01_2025-01-15:USD - return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "daily") { // leaderboard:{scope}:daily:2025-01-15:USD const dateStr = formatInTimeZone(now, timezone, "yyyy-MM-dd"); - return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "weekly") { // leaderboard:{scope}:weekly:2025-W03:USD (ISO week) const weekStr = formatInTimeZone(now, timezone, "yyyy-'W'ww"); - return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "monthly") { // leaderboard:{scope}:monthly:2025-01:USD const monthStr = formatInTimeZone(now, timezone, "yyyy-MM"); - return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else { // allTime: leaderboard:{scope}:allTime:USD (no date component) - return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`; + return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } } @@ -115,7 +119,11 @@ async function queryDatabase( return await findCustomRangeLeaderboard(dateRange, userFilters); } if (scope === "provider") { - return await findCustomRangeProviderLeaderboard(dateRange, filters?.providerType); + return await findCustomRangeProviderLeaderboard( + dateRange, + filters?.providerType, + filters?.includeModelStats + ); } if (scope === "providerCacheHitRate") { return await findCustomRangeProviderCacheHitRateLeaderboard(dateRange, filters?.providerType); @@ -140,15 +148,30 @@ async function queryDatabase( if (scope === "provider") { switch (period) { case "daily": - return await findDailyProviderLeaderboard(filters?.providerType); + return await findDailyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); case "weekly": - return await findWeeklyProviderLeaderboard(filters?.providerType); + return await findWeeklyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); case "monthly": - return await findMonthlyProviderLeaderboard(filters?.providerType); + return await findMonthlyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); case "allTime": - return await findAllTimeProviderLeaderboard(filters?.providerType); + return await findAllTimeProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); default: - return await findDailyProviderLeaderboard(filters?.providerType); + return await findDailyProviderLeaderboard( + filters?.providerType, + filters?.includeModelStats + ); } } if (scope === "providerCacheHitRate") { @@ -298,11 +321,16 @@ export async function getLeaderboardWithCache( * * @param period - 排行榜周期 * @param currencyDisplay - 货币显示单位 + * @param scope - 榜单范围 + * @param dateRange - 自定义日期范围(仅 period=custom 时使用) + * @param filters - 过滤条件(会影响缓存键) */ export async function invalidateLeaderboardCache( period: LeaderboardPeriod, currencyDisplay: string, - scope: LeaderboardScope = "user" + scope: LeaderboardScope = "user", + dateRange?: DateRangeParams, + filters?: LeaderboardFilters ): Promise { const redis = getRedisClient(); if (!redis) { @@ -311,7 +339,7 @@ export async function invalidateLeaderboardCache( // Resolve timezone once per request const timezone = await resolveSystemTimezone(); - const cacheKey = buildCacheKey(period, currencyDisplay, timezone, scope); + const cacheKey = buildCacheKey(period, currencyDisplay, timezone, scope, dateRange, filters); try { await redis.del(cacheKey); diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 451e6c440..38d1a7971 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -8,6 +8,8 @@ import type { ProviderType } from "@/types/provider"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { getSystemSettings } from "./system-config"; +const clampRatio01 = (value: number | null | undefined) => Math.min(Math.max(value ?? 0, 0), 1); + /** * 排行榜条目类型 */ @@ -43,6 +45,27 @@ export interface ProviderLeaderboardEntry { 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 + /** + * 可选:按模型拆分 + * - undefined: 未请求 includeModelStats + * - []: 已请求 includeModelStats,但该 provider 下无可用模型统计 + */ + modelStats?: ModelProviderStat[]; +} + +/** + * 供应商消耗排行榜 - 模型级统计 + */ +export interface ModelProviderStat { + model: string; + totalRequests: number; + totalCost: number; + totalTokens: number; + successRate: number; // 0-1 + avgTtfbMs: number; // 毫秒 + avgTokensPerSecond: number; // tok/s + avgCostPerRequest: number | null; + avgCostPerMillionTokens: number | null; } /** @@ -284,43 +307,75 @@ export async function findCustomRangeLeaderboard( /** * 查询今日供应商消耗排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findDailyProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("daily", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "daily", + timezone, + undefined, + providerType, + includeModelStats + ); } /** * 查询本月供应商消耗排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findMonthlyProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("monthly", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "monthly", + timezone, + undefined, + providerType, + includeModelStats + ); } /** * 查询本周供应商消耗排行榜(不限制数量) + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findWeeklyProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("weekly", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "weekly", + timezone, + undefined, + providerType, + includeModelStats + ); } /** * 查询全部时间供应商消耗排行榜(不限制数量) + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findAllTimeProviderLeaderboard( - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("allTime", timezone, undefined, providerType); + return findProviderLeaderboardWithTimezone( + "allTime", + timezone, + undefined, + providerType, + includeModelStats + ); } /** @@ -385,12 +440,14 @@ export async function findAllTimeProviderCacheHitRateLeaderboard( /** * 通用供应商排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确) + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ async function findProviderLeaderboardWithTimezone( period: LeaderboardPeriod, timezone: string, dateRange?: DateRangeParams, - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const whereConditions = [ LEDGER_BILLING_CONDITION, @@ -398,41 +455,53 @@ async function findProviderLeaderboardWithTimezone( providerType ? eq(providers.providerType, providerType) : undefined, ]; + const totalRequestsExpr = sql`count(*)::double precision`; + const totalCostExpr = sql`COALESCE(sum(${usageLedger.costUsd}), 0)`; + const totalTokensExpr = sql`COALESCE( + sum( + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) + )::double precision, + 0::double precision + )`; + const successRateExpr = sql`COALESCE( + count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision + / NULLIF(count(*)::double precision, 0), + 0::double precision + )`; + const avgTtfbMsExpr = sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`; + const avgTokensPerSecondExpr = sql`COALESCE( + avg( + CASE + WHEN ${usageLedger.outputTokens} > 0 + AND ${usageLedger.durationMs} IS NOT NULL + AND ${usageLedger.ttfbMs} IS NOT NULL + AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} + AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 + THEN (${usageLedger.outputTokens}::double precision) + / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) + END + )::double precision, + 0::double precision + )`; + + const computeAvgCosts = (totalCost: number, totalRequests: number, totalTokens: number) => ({ + avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null, + avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null, + }); + const rankings = await db .select({ providerId: usageLedger.finalProviderId, providerName: providers.name, - totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, - totalTokens: sql`COALESCE( - sum( - ${usageLedger.inputTokens} + - ${usageLedger.outputTokens} + - COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + - COALESCE(${usageLedger.cacheReadInputTokens}, 0) - )::double precision, - 0::double precision - )`, - successRate: sql`COALESCE( - count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision - / NULLIF(count(*)::double precision, 0), - 0::double precision - )`, - avgTtfbMs: sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`, - avgTokensPerSecond: sql`COALESCE( - avg( - CASE - WHEN ${usageLedger.outputTokens} > 0 - AND ${usageLedger.durationMs} IS NOT NULL - AND ${usageLedger.ttfbMs} IS NOT NULL - AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} - AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 - THEN (${usageLedger.outputTokens}::double precision) - / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) - END - )::double precision, - 0::double precision - )`, + totalRequests: totalRequestsExpr, + totalCost: totalCostExpr, + totalTokens: totalTokensExpr, + successRate: successRateExpr, + avgTtfbMs: avgTtfbMsExpr, + avgTokensPerSecond: avgTokensPerSecondExpr, }) .from(usageLedger) .innerJoin( @@ -445,23 +514,82 @@ async function findProviderLeaderboardWithTimezone( .groupBy(usageLedger.finalProviderId, providers.name) .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); - return rankings.map((entry) => { + const baseEntries: ProviderLeaderboardEntry[] = rankings.map((entry) => { const totalCost = parseFloat(entry.totalCost); const totalRequests = entry.totalRequests; const totalTokens = entry.totalTokens; + const avgCosts = computeAvgCosts(totalCost, totalRequests, totalTokens); return { providerId: entry.providerId, providerName: entry.providerName, totalRequests, totalCost, totalTokens, - successRate: entry.successRate ?? 0, + successRate: clampRatio01(entry.successRate), avgTtfbMs: entry.avgTtfbMs ?? 0, avgTokensPerSecond: entry.avgTokensPerSecond ?? 0, - avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : null, - avgCostPerMillionTokens: totalTokens > 0 ? (totalCost * 1_000_000) / totalTokens : null, + ...avgCosts, }; }); + + if (!includeModelStats) return baseEntries; + + // Model breakdown per provider + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + const rawModelField = + billingModelSource === "original" + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; + + const modelRows = await db + .select({ + providerId: usageLedger.finalProviderId, + model: modelField, + totalRequests: totalRequestsExpr, + totalCost: totalCostExpr, + totalTokens: totalTokensExpr, + successRate: successRateExpr, + avgTtfbMs: avgTtfbMsExpr, + avgTokensPerSecond: avgTokensPerSecondExpr, + }) + .from(usageLedger) + .innerJoin( + providers, + and(sql`${usageLedger.finalProviderId} = ${providers.id}`, isNull(providers.deletedAt)) + ) + .where( + and(...whereConditions.filter((c): c is NonNullable<(typeof whereConditions)[number]> => !!c)) + ) + .groupBy(usageLedger.finalProviderId, modelField) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`), desc(sql`count(*)`)); + + const modelStatsByProvider = new Map(); + for (const row of modelRows) { + if (!row.model) continue; + const totalCost = parseFloat(row.totalCost); + const totalRequests = row.totalRequests; + const totalTokens = row.totalTokens; + const avgCosts = computeAvgCosts(totalCost, totalRequests, totalTokens); + const stats = modelStatsByProvider.get(row.providerId) ?? []; + stats.push({ + model: row.model, + totalRequests, + totalCost, + totalTokens, + successRate: clampRatio01(row.successRate), + avgTtfbMs: row.avgTtfbMs ?? 0, + avgTokensPerSecond: row.avgTokensPerSecond ?? 0, + ...avgCosts, + }); + modelStatsByProvider.set(row.providerId, stats); + } + + return baseEntries.map((entry) => ({ + ...entry, + modelStats: modelStatsByProvider.get(entry.providerId) ?? [], + })); } /** @@ -529,10 +657,11 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( // Model-level cache hit breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; - const modelField = + const rawModelField = billingModelSource === "original" ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; const modelTotalInput = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; const modelCacheRead = sql`COALESCE(sum(COALESCE(${usageLedger.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; @@ -564,14 +693,14 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( // Group model stats by providerId const modelStatsByProvider = new Map(); for (const row of modelRows) { - if (!row.model || row.model.trim() === "") continue; + if (!row.model) 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), + cacheHitRate: clampRatio01(row.cacheHitRate), }); modelStatsByProvider.set(row.providerId, stats); } @@ -585,20 +714,28 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( cacheCreationCost: parseFloat(entry.cacheCreationCost), totalInputTokens: entry.totalInputTokens, totalTokens: entry.totalInputTokens, // deprecated, for backward compatibility - cacheHitRate: Math.min(Math.max(entry.cacheHitRate ?? 0, 0), 1), + cacheHitRate: clampRatio01(entry.cacheHitRate), modelStats: modelStatsByProvider.get(entry.providerId) ?? [], })); } /** * 查询自定义日期范围供应商消耗排行榜 + * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ export async function findCustomRangeProviderLeaderboard( dateRange: DateRangeParams, - providerType?: ProviderType + providerType?: ProviderType, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findProviderLeaderboardWithTimezone("custom", timezone, dateRange, providerType); + return findProviderLeaderboardWithTimezone( + "custom", + timezone, + dateRange, + providerType, + includeModelStats + ); } /** @@ -704,7 +841,7 @@ async function findModelLeaderboardWithTimezone( totalRequests: entry.totalRequests, totalCost: parseFloat(entry.totalCost), totalTokens: entry.totalTokens, - successRate: entry.successRate ?? 0, + successRate: clampRatio01(entry.successRate), })); } diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index a4d9385a8..255018760 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -197,5 +197,94 @@ describe("GET /api/leaderboard", () => { expect(entry.modelStats[0]).toHaveProperty("model", "claude-3-opus"); expect(entry.modelStats[0]).toHaveProperty("cacheHitRate", 0.53); }); + + it("passes includeModelStats to cache and formats provider modelStats entries", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + providerId: 1, + providerName: "test-provider", + totalRequests: 10, + totalCost: 1.5, + totalTokens: 1000, + successRate: 1, + avgTtfbMs: 100, + avgTokensPerSecond: 20, + avgCostPerRequest: 0.15, + avgCostPerMillionTokens: 1500, + modelStats: [ + { + model: "model-a", + totalRequests: 6, + totalCost: 1.0, + totalTokens: 600, + successRate: 1, + avgTtfbMs: 110, + avgTokensPerSecond: 25, + avgCostPerRequest: 0.1667, + avgCostPerMillionTokens: 1666.7, + }, + ], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=provider&period=daily&includeModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + + const callArgs = mocks.getLeaderboardWithCache.mock.calls[0]; + const options = callArgs[4]; + expect(options.includeModelStats).toBe(true); + + expect(body).toHaveLength(1); + const entry = body[0]; + expect(entry).toHaveProperty("modelStats"); + expect(entry.modelStats).toHaveLength(1); + expect(entry.modelStats[0]).toHaveProperty("totalCostFormatted"); + expect(entry.modelStats[0]).toHaveProperty("avgCostPerRequestFormatted"); + expect(entry.modelStats[0]).toHaveProperty("avgCostPerMillionTokensFormatted"); + }); + + it("returns empty modelStats array when includeModelStats is requested but provider has no model data", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + providerId: 1, + providerName: "empty-models-provider", + totalRequests: 10, + totalCost: 1.0, + totalTokens: 1000, + successRate: 1, + avgTtfbMs: 100, + avgTokensPerSecond: 20, + avgCostPerRequest: 0.1, + avgCostPerMillionTokens: 1000, + modelStats: [], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=provider&period=daily&includeModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + + const callArgs = mocks.getLeaderboardWithCache.mock.calls[0]; + const options = callArgs[4]; + expect(options.includeModelStats).toBe(true); + + expect(body).toHaveLength(1); + expect(body[0]).toHaveProperty("modelStats"); + expect(Array.isArray(body[0].modelStats)).toBe(true); + expect(body[0].modelStats).toHaveLength(0); + }); }); }); diff --git a/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx new file mode 100644 index 000000000..fdee59243 --- /dev/null +++ b/tests/unit/dashboard/leaderboard-table-expandable-rows.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment happy-dom + */ +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type ColumnDef, + LeaderboardTable, +} from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-table"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +type ChildRow = { + model: string; + totalRequests: number; +}; + +type ParentRow = { + providerId: number; + providerName: string; + totalRequests: number; + modelStats: ChildRow[]; +}; + +type Row = ParentRow | ChildRow; + +describe("LeaderboardTable expandable rows", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + function renderSimple(node: ReactNode) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root!.render(node)); + return { container, root }; + } + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it("renders sub rows inline (no nested table) and toggles on click", () => { + const data: ParentRow[] = [ + { + providerId: 1, + providerName: "Provider A", + totalRequests: 10, + modelStats: [ + { model: "model-x", totalRequests: 6 }, + { model: "model-y", totalRequests: 4 }, + ], + }, + ]; + + const columns: ColumnDef[] = [ + { + header: "name", + cell: (row) => ("providerName" in row ? row.providerName : row.model), + }, + { + header: "requests", + className: "text-right", + cell: (row) => String(row.totalRequests), + }, + ]; + + const { container } = renderSimple( + + data={data} + period="daily" + columns={columns} + getRowKey={(row) => row.providerId} + getSubRows={(row) => row.modelStats} + getSubRowKey={(subRow) => subRow.model} + /> + ); + + const findCellByText = (text: string) => + Array.from(container.querySelectorAll("td")).find((td) => td.textContent?.trim() === text) ?? + null; + + expect(findCellByText("Provider A")).toBeTruthy(); + expect(findCellByText("model-x")).toBeNull(); + + const expandButton = container.querySelector( + 'button[aria-label="expandModelStats"]' + ) as HTMLButtonElement | null; + expect(expandButton).toBeTruthy(); + expect(expandButton!.getAttribute("aria-expanded")).toBe("false"); + + act(() => { + expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(expandButton!.getAttribute("aria-expanded")).toBe("true"); + + const modelCell = findCellByText("model-x"); + expect(modelCell).toBeTruthy(); + + const modelRow = modelCell!.closest("tr"); + expect(modelRow).toBeTruthy(); + expect(modelRow!.className).toContain("bg-muted/30"); + + act(() => { + expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(expandButton!.getAttribute("aria-expanded")).toBe("false"); + expect(findCellByText("model-x")).toBeNull(); + }); +}); diff --git a/tests/unit/repository/leaderboard-provider-metrics.test.ts b/tests/unit/repository/leaderboard-provider-metrics.test.ts index a39bb489f..1455961b9 100644 --- a/tests/unit/repository/leaderboard-provider-metrics.test.ts +++ b/tests/unit/repository/leaderboard-provider-metrics.test.ts @@ -236,6 +236,105 @@ describe("Provider Leaderboard Average Cost Metrics", () => { }); }); +describe("Provider Leaderboard Model Breakdown", () => { + beforeEach(() => { + vi.resetModules(); + selectCallIndex = 0; + chainMocks = []; + mockSelect.mockClear(); + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + }); + + it("includes modelStats when includeModelStats=true and excludes empty model names", async () => { + chainMocks = [ + createChainMock([ + { + providerId: 1, + providerName: "provider-a", + totalRequests: 100, + totalCost: "10.0", + totalTokens: 1000, + successRate: 0.9, + avgTtfbMs: 200, + avgTokensPerSecond: 50, + }, + { + providerId: 2, + providerName: "provider-b", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 500, + successRate: 0.8, + avgTtfbMs: 300, + avgTokensPerSecond: 40, + }, + ]), + createChainMock([ + { + providerId: 1, + model: "model-a", + totalRequests: 60, + totalCost: "6.0", + totalTokens: 600, + successRate: 0.95, + avgTtfbMs: 120, + avgTokensPerSecond: 55, + }, + { + providerId: 1, + model: "model-b", + totalRequests: 40, + totalCost: "4.0", + totalTokens: 400, + successRate: 0.85, + avgTtfbMs: 180, + avgTokensPerSecond: 45, + }, + { + providerId: 2, + model: "", + totalRequests: 1, + totalCost: "0.1", + totalTokens: 10, + successRate: 0, + avgTtfbMs: 0, + avgTokensPerSecond: 0, + }, + { + providerId: 2, + model: "model-c", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 500, + successRate: 0.8, + avgTtfbMs: 300, + avgTokensPerSecond: 40, + }, + ]), + ]; + + const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyProviderLeaderboard(undefined, true); + + expect(result).toHaveLength(2); + + const p1 = result.find((r) => r.providerId === 1); + expect(p1).toBeDefined(); + expect(p1!.modelStats).toBeDefined(); + expect(p1!.modelStats).toHaveLength(2); + expect(p1!.modelStats![0].model).toBe("model-a"); + expect(p1!.modelStats![0].avgCostPerRequest).toBeCloseTo(6.0 / 60); + expect(p1!.modelStats![0].avgCostPerMillionTokens).toBeCloseTo((6.0 * 1_000_000) / 600); + + const p2 = result.find((r) => r.providerId === 2); + expect(p2).toBeDefined(); + // Empty model must be excluded + expect(p2!.modelStats).toHaveLength(1); + expect(p2!.modelStats![0].model).toBe("model-c"); + }); +}); + describe("Provider Cache Hit Rate Model Breakdown", () => { beforeEach(() => { vi.resetModules();