Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion messages/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion messages/ja/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,12 @@
"successRate": "成功率(%)",
"avgResponseTime": "平均応答時間",
"avgTtfbMs": "平均TTFB",
"avgTokensPerSecond": "平均トークン/秒"
"avgTokensPerSecond": "平均トークン/秒",
"avgCostPerRequest": "平均リクエスト単価",
"avgCostPerMillionTokens": "100万トークンあたりコスト"
},
"expandModelStats": "モデル詳細を展開",
"collapseModelStats": "モデル詳細を折りたたむ",
"states": {
"loading": "読み込み中...",
"noData": "データなし",
Expand Down
6 changes: 5 additions & 1 deletion messages/ru/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,12 @@
"successRate": "Процент успеха",
"avgResponseTime": "Среднее время ответа",
"avgTtfbMs": "Средний TTFB",
"avgTokensPerSecond": "Средн. ток/с"
"avgTokensPerSecond": "Средн. ток/с",
"avgCostPerRequest": "Ср. стоимость/запрос",
"avgCostPerMillionTokens": "Ср. стоимость/1М токенов"
},
"expandModelStats": "Развернуть модели",
"collapseModelStats": "Свернуть модели",
"states": {
"loading": "Загрузка...",
"noData": "Нет данных",
Expand Down
6 changes: 5 additions & 1 deletion messages/zh-CN/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,12 @@
"successRate": "成功率",
"avgResponseTime": "平均响应时间",
"avgTtfbMs": "平均 TTFB",
"avgTokensPerSecond": "平均输出速率"
"avgTokensPerSecond": "平均输出速率",
"avgCostPerRequest": "平均单次请求成本",
"avgCostPerMillionTokens": "平均百万 Token 成本"
},
"expandModelStats": "展开模型详情",
"collapseModelStats": "收起模型详情",
"states": {
"loading": "加载中...",
"noData": "暂无数据",
Expand Down
6 changes: 5 additions & 1 deletion messages/zh-TW/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,12 @@
"successRate": "成功率(%)",
"avgResponseTime": "平均回覆時間",
"avgTtfbMs": "平均 TTFB(ms)",
"avgTokensPerSecond": "平均輸出速率"
"avgTokensPerSecond": "平均輸出速率",
"avgCostPerRequest": "平均每次請求成本",
"avgCostPerMillionTokens": "平均每百萬 Token 成本"
},
"expandModelStats": "展開模型詳情",
"collapseModelStats": "收起模型詳情",
"states": {
"loading": "載入中...",
"noData": "暫無資料",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,20 +41,36 @@ 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>({
data,
period,
columns,
getRowKey,
renderExpandedContent,
}: LeaderboardTableProps<T>) {
const t = useTranslations("dashboard.leaderboard");

// 排序状态
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;
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
DateRangeParams,
LeaderboardEntry,
LeaderboardPeriod,
ModelCacheHitStat,
ModelLeaderboardEntry,
ProviderCacheHitRateLeaderboardEntry,
ProviderLeaderboardEntry,
Expand All @@ -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;
Expand Down Expand Up @@ -163,7 +168,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
scope === "user"
? 5
: scope === "provider"
? 8
? 10
: scope === "providerCacheHitRate"
? 8
: scope === "model"
Expand Down Expand Up @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] [LOGIC-BUG] Sorting null avg-cost values as 0 makes "no data" providers look cheapest

Evidence (new code)

  • src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx:282
    getValue: (row) => (row as ProviderEntry).avgCostPerRequest ?? 0,
  • src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx:293
    getValue: (row) => (row as ProviderEntry).avgCostPerMillionTokens ?? 0,

Backend defines these as null when the denominator is 0 (e.g. avgCostPerRequest: number | null; // ... null when totalRequests === 0 in src/repository/leaderboard.ts). The UI also renders "-" when the value is null, so sorting them as 0 is misleading (first click is ascending).

Suggested fix: allow getValue to return null and make LeaderboardTable sort nulls last for both directions.

// src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx
export interface ColumnDef<T> {
  // ...
  getValue?: (row: T) => number | string | null | undefined;
}

// In the sort comparator (before number/string comparison):
if (valueA == null && valueB == null) return 0;
if (valueA == null) return 1;
if (valueB == null) return -1;

// src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
getValue: (row) => (row as ProviderEntry).avgCostPerRequest,
getValue: (row) => (row as ProviderEntry).avgCostPerMillionTokens,

},
{
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>[] = [
Expand Down Expand Up @@ -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">
Comment on lines +526 to +528
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expand label is inverted

The expanded section currently renders {t("expandModelStats")} even when the row is already expanded. This makes the UI text incorrect in the expanded state and the new collapseModelStats i18n key is never used. Use collapseModelStats when the row is expanded (and expandModelStats when collapsed), or remove the unused key.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
Line: 526:528

Comment:
**Expand label is inverted**

The expanded section currently renders `{t("expandModelStats")}` even when the row is already expanded. This makes the UI text incorrect in the expanded state and the new `collapseModelStats` i18n key is never used. Use `collapseModelStats` when the row is expanded (and `expandModelStats` when collapsed), or remove the unused key.


How can I resolve this? If you propose a fix, please make it concise.

{t("expandModelStats")}
</div>
Comment on lines +527 to +530
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

t("expandModelStats") 用作展开后的节标题语义不准确。

此处已经是展开后的内容区域,但使用了 expandModelStats("展开模型" / "Expand models")作为标题,这是一个动作描述而非内容标题。建议新增一个描述性的 i18n key(如 modelStatsTitle = "模型明细"),或者直接复用已有的 columns.model 等键来作为小节标题。collapseModelStats 键在当前实现中也未被使用。

🤖 Prompt for AI Agents
In `@src/app/`[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
around lines 527 - 530, Replace the action-style i18n key t("expandModelStats")
used as the expanded-section title with a descriptive content key (e.g. add
"modelStatsTitle" = "模型明细" to your locale files) or reuse an existing noun key
like t("columns.model"); update the component where t("expandModelStats") is
called (the t(...) inside the expanded content header in leaderboard-view.tsx)
to use the new/reused key, and remove or stop referencing the unused
"collapseModelStats" key if it’s not needed.

<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>
Expand Down
44 changes: 34 additions & 10 deletions src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Loading
Loading