|
|
@@ -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<ProviderLeaderboardEntry, "modelStats"> &
|
|
|
+ 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) => (
|
|
|
+ <div className="pl-6">
|
|
|
+ <span className="font-mono text-sm">{model}</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
const userColumns: ColumnDef<UserEntry>[] = [
|
|
|
{
|
|
|
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<ProviderEntry>[] = [
|
|
|
+ const providerColumns: ColumnDef<ProviderTableRow>[] = [
|
|
|
{
|
|
|
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<ProviderCacheHitRateEntry>[] = [
|
|
|
+ const providerCacheHitRateColumns: ColumnDef<ProviderCacheHitRateTableRow>[] = [
|
|
|
{
|
|
|
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 <span className={colorClass}>{rate.toFixed(1)}%</span>;
|
|
|
},
|
|
|
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<ModelEntry>[] = [
|
|
|
{
|
|
|
header: t("columns.model"),
|
|
|
- cell: (row) => <span className="font-mono text-sm">{(row as ModelEntry).model}</span>,
|
|
|
+ cell: (row) => <span className="font-mono text-sm">{row.model}</span>,
|
|
|
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<AnyEntry>[];
|
|
|
- case "provider":
|
|
|
- return providerColumns as ColumnDef<AnyEntry>[];
|
|
|
- case "providerCacheHitRate":
|
|
|
- return providerCacheHitRateColumns as ColumnDef<AnyEntry>[];
|
|
|
- case "model":
|
|
|
- return modelColumns as ColumnDef<AnyEntry>[];
|
|
|
- }
|
|
|
- })();
|
|
|
-
|
|
|
- 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 = () => (
|
|
|
+ <LeaderboardTable<UserEntry>
|
|
|
+ data={data as UserEntry[]}
|
|
|
+ period={period}
|
|
|
+ columns={userColumns}
|
|
|
+ getRowKey={(row) => row.userId}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ const renderProviderTable = () => (
|
|
|
+ <LeaderboardTable<ProviderEntry, ModelProviderStatClient>
|
|
|
+ data={data as ProviderEntry[]}
|
|
|
+ period={period}
|
|
|
+ columns={providerColumns}
|
|
|
+ getRowKey={(row) => row.providerId}
|
|
|
+ getSubRows={(row) => row.modelStats}
|
|
|
+ getSubRowKey={(subRow) => subRow.model}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ const renderProviderCacheHitRateTable = () => (
|
|
|
+ <LeaderboardTable<ProviderCacheHitRateEntry, ModelCacheHitStat>
|
|
|
+ data={data as ProviderCacheHitRateEntry[]}
|
|
|
+ period={period}
|
|
|
+ columns={providerCacheHitRateColumns}
|
|
|
+ getRowKey={(row) => row.providerId}
|
|
|
+ getSubRows={(row) => row.modelStats}
|
|
|
+ getSubRowKey={(subRow) => subRow.model}
|
|
|
+ />
|
|
|
+ );
|
|
|
+
|
|
|
+ const renderModelTable = () => (
|
|
|
+ <LeaderboardTable<ModelEntry>
|
|
|
+ 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) {
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
) : (
|
|
|
- <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
|
|
|
- }
|
|
|
- />
|
|
|
+ renderTable()
|
|
|
)}
|
|
|
</div>
|
|
|
</div>
|