Skip to content

Commit 4fc542c

Browse files
authored
feat: [ENG-3809] Stats page for authors, providers, models (#5437)
* page for authors, providers, models * udpate pull req tempalte * add back * use jawn client
1 parent becedc7 commit 4fc542c

File tree

26 files changed

+3346
-943
lines changed

26 files changed

+3346
-943
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,21 @@ What part of Helicone does this affect?
2020
- [ ] Performance improvement
2121
- [ ] Refactoring
2222

23-
## Testing
24-
- [ ] Added/updated unit tests
25-
- [ ] Added/updated integration tests
26-
- [ ] Tested locally
27-
- [ ] Verified in staging environment
28-
- [ ] E2E tests pass (if applicable)
29-
30-
## Technical Considerations
31-
- [ ] Database migrations included (if needed)
32-
- [ ] API changes documented
33-
- [ ] Breaking changes noted
34-
- [ ] Performance impact assessed
35-
- [ ] Security implications reviewed
36-
37-
## Dependencies
38-
- [ ] No external dependencies added
39-
- [ ] Dependencies added and documented
40-
- [ ] Environment variables added/modified
41-
4223
## Deployment Notes
4324
- [ ] No special deployment steps required
4425
- [ ] Database migrations need to run
4526
- [ ] Environment variable changes required
4627
- [ ] Coordination with other teams needed
4728

48-
## Context
49-
Why are you making this change?
50-
5129
## Screenshots / Demos
5230
| **Before** | **After** |
5331
|-------------|-----------|
5432
| | |
5533

56-
## Misc. Review Notes
34+
## Extra Notes
35+
Any additional context, considerations, or notes for reviewers.
36+
37+
## Context
38+
Why are you making this change?
39+
40+
## Screenshots / Demos

bifrost/app/model/[modelName]/ModelDetailPage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { components } from "@/lib/clients/jawnTypes/public";
2626
import { StandardParameter } from "@helicone-package/cost/models/types";
2727
import { capitalizeModality } from "@/lib/constants/modalities";
2828
import { ModelSearchDropdown } from "@/components/models/ModelSearchDropdown";
29+
import { ModelUsageSection } from "./ModelUsageSection";
2930

3031
type ModelRegistryItem = components["schemas"]["ModelRegistryItem"];
3132

@@ -639,6 +640,13 @@ completion = client.chat.completions.create(
639640
</div>
640641
</div>
641642

643+
{/* Usage Stats Section */}
644+
<div className="bg-white dark:bg-gray-900">
645+
<div className="max-w-6xl mx-auto px-4">
646+
<ModelUsageSection modelId={model.id} />
647+
</div>
648+
</div>
649+
642650
{/* Quick Start Section */}
643651
<div className="bg-white dark:bg-gray-900">
644652
<div className="max-w-6xl mx-auto px-4 py-6">
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { useState, useEffect, useMemo } from "react";
5+
import { ArrowUpRight } from "lucide-react";
6+
import { SingleUsageChart, SingleUsageDataPoint } from "@/app/stats/components/SingleUsageChart";
7+
import { Skeleton } from "@/components/ui/skeleton";
8+
import { getJawnClient } from "@/lib/clients/jawn";
9+
10+
interface ModelStatsResponse {
11+
model: string;
12+
totalTokens: number;
13+
timeSeries: SingleUsageDataPoint[];
14+
}
15+
16+
interface ModelUsageSectionProps {
17+
modelId: string;
18+
}
19+
20+
export function ModelUsageSection({ modelId }: ModelUsageSectionProps) {
21+
const jawnClient = useMemo(() => getJawnClient(), []);
22+
const [data, setData] = useState<ModelStatsResponse | null>(null);
23+
const [isLoading, setIsLoading] = useState(true);
24+
25+
useEffect(() => {
26+
const fetchModelStats = async () => {
27+
try {
28+
setIsLoading(true);
29+
const response = await jawnClient.GET(
30+
"/v1/public/stats/models/{model}",
31+
{
32+
params: { path: { model: modelId }, query: { timeframe: "1y" } },
33+
}
34+
);
35+
36+
if (response.data?.data) {
37+
setData(response.data.data as ModelStatsResponse);
38+
}
39+
} catch (error) {
40+
console.error("Failed to load model stats:", error);
41+
} finally {
42+
setIsLoading(false);
43+
}
44+
};
45+
46+
fetchModelStats();
47+
}, [modelId, jawnClient]);
48+
49+
if (isLoading) {
50+
return (
51+
<div className="pt-6">
52+
<Skeleton className="h-6 w-48 mb-4" />
53+
<Skeleton className="h-[200px] w-full" />
54+
</div>
55+
);
56+
}
57+
58+
if (!data?.timeSeries?.length) {
59+
return null;
60+
}
61+
62+
return (
63+
<div className="pt-6">
64+
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
65+
Usage
66+
</h2>
67+
<div className="flex items-center justify-between mb-4">
68+
<p className="text-sm text-gray-500 dark:text-gray-400">
69+
Token usage over the last year
70+
</p>
71+
<Link
72+
href="/stats"
73+
className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
74+
>
75+
View all stats
76+
<ArrowUpRight className="h-3 w-3" />
77+
</Link>
78+
</div>
79+
<div className="ml-2 mr-4">
80+
<SingleUsageChart
81+
data={data.timeSeries}
82+
isLoading={false}
83+
timeframe="1y"
84+
height={200}
85+
/>
86+
</div>
87+
</div>
88+
);
89+
}

bifrost/app/model/[modelName]/page.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ModelDetailPage } from "./ModelDetailPage";
44
import { Layout } from "@/app/components/Layout";
55
import { getJawnClient } from "@/lib/clients/jawn";
66
import { components } from "@/lib/clients/jawnTypes/public";
7+
import QueryProvider from "@/app/stats/QueryProvider";
78

89
type ModelRegistryItem = components["schemas"]["ModelRegistryItem"];
910

@@ -89,24 +90,26 @@ export default async function ModelPage({
8990

9091
return (
9192
<Layout noNavbarMargin={true}>
92-
<Suspense
93-
fallback={
94-
<div className="flex flex-col gap-4 w-full max-w-6xl mx-auto px-4 py-8">
95-
<div className="h-8 w-64 bg-muted rounded animate-pulse" />
96-
<div className="h-4 w-96 bg-muted rounded animate-pulse" />
97-
<div className="grid gap-4 mt-8">
98-
{[...Array(5)].map((_, i) => (
99-
<div
100-
key={i}
101-
className="h-20 bg-muted rounded animate-pulse"
102-
/>
103-
))}
93+
<QueryProvider>
94+
<Suspense
95+
fallback={
96+
<div className="flex flex-col gap-4 w-full max-w-6xl mx-auto px-4 py-8">
97+
<div className="h-8 w-64 bg-muted rounded animate-pulse" />
98+
<div className="h-4 w-96 bg-muted rounded animate-pulse" />
99+
<div className="grid gap-4 mt-8">
100+
{[...Array(5)].map((_, i) => (
101+
<div
102+
key={i}
103+
className="h-20 bg-muted rounded animate-pulse"
104+
/>
105+
))}
106+
</div>
104107
</div>
105-
</div>
106-
}
107-
>
108-
<ModelDetailPage initialModel={model} modelName={decodedModelName} />
109-
</Suspense>
108+
}
109+
>
110+
<ModelDetailPage initialModel={model} modelName={decodedModelName} />
111+
</Suspense>
112+
</QueryProvider>
110113
</Layout>
111114
);
112115
}
Lines changed: 24 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
"use client";
22

3-
import { Skeleton } from "@/components/ui/skeleton";
4-
import { CHART_COLOR_PALETTE } from "@/lib/chartColors";
5-
import { ChevronUp, ChevronDown } from "lucide-react";
3+
import { Leaderboard, LeaderboardItem } from "./components/Leaderboard";
64
import { formatTokens } from "@/utils/formatters";
75

86
interface LeaderboardEntry {
@@ -19,151 +17,34 @@ interface MarketShareLeaderboardProps {
1917
isLoading: boolean;
2018
}
2119

22-
function RankChangeIndicator({ rankChange }: { rankChange: number | null }) {
23-
if (rankChange === null || rankChange === 0 || !isFinite(rankChange)) {
24-
return null;
25-
}
26-
27-
if (rankChange > 0) {
28-
return (
29-
<span className="flex items-center text-green-600 dark:text-green-400">
30-
<ChevronUp className="h-4 w-4" />
31-
<span className="text-xs tabular-nums">{rankChange}</span>
32-
</span>
33-
);
34-
}
35-
36-
return (
37-
<span className="flex items-center text-red-600 dark:text-red-400">
38-
<ChevronDown className="h-4 w-4" />
39-
<span className="text-xs tabular-nums">{Math.abs(rankChange)}</span>
40-
</span>
41-
);
42-
}
43-
44-
function MarketShareChangeIndicator({ change }: { change: number | null }) {
45-
if (change === null || !isFinite(change) || isNaN(change)) {
46-
return null;
47-
}
48-
49-
if (Math.abs(change) < 0.05) {
50-
return <span className="text-xs text-gray-400 tabular-nums">0.0%</span>;
51-
}
52-
53-
const isPositive = change > 0;
54-
const displayValue = Math.abs(change).toFixed(1);
55-
56-
return (
57-
<span
58-
className={`text-xs tabular-nums ${
59-
isPositive
60-
? "text-green-600 dark:text-green-400"
61-
: "text-red-600 dark:text-red-400"
62-
}`}
63-
>
64-
{isPositive ? "+" : "-"}
65-
{displayValue}%
66-
</span>
67-
);
68-
}
69-
70-
function LeaderboardItem({
71-
entry,
72-
colorIndex,
73-
}: {
74-
entry: LeaderboardEntry;
75-
colorIndex: number;
76-
}) {
77-
const isOther = entry.author.toLowerCase() === "others";
78-
const color = CHART_COLOR_PALETTE[colorIndex % CHART_COLOR_PALETTE.length];
79-
const marketShare = isFinite(entry.marketShare) ? entry.marketShare : 0;
80-
const totalTokens = isFinite(entry.totalTokens) ? entry.totalTokens : 0;
81-
82-
return (
83-
<div className="flex items-start gap-3 py-3">
84-
<span className="text-sm text-gray-500 dark:text-gray-400 w-6 text-right tabular-nums">
85-
{entry.rank}.
86-
</span>
87-
<div
88-
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
89-
style={{ backgroundColor: color }}
90-
/>
91-
<div className="flex-1 min-w-0">
92-
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
93-
{entry.author}
94-
</span>
95-
</div>
96-
<div className="w-10 flex justify-end">
97-
{!isOther && <RankChangeIndicator rankChange={entry.rankChange} />}
98-
</div>
99-
<div className="text-right min-w-[70px]">
100-
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 tabular-nums">
101-
{marketShare.toFixed(1)}%
102-
</div>
103-
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
104-
{formatTokens(totalTokens)}
105-
</div>
106-
<MarketShareChangeIndicator change={entry.marketShareChange} />
107-
</div>
108-
</div>
109-
);
110-
}
111-
112-
function LoadingSkeleton() {
113-
return (
114-
<div className="grid grid-cols-2 gap-x-8">
115-
{[...Array(10)].map((_, i) => (
116-
<div key={i} className="flex items-center gap-3 py-3">
117-
<Skeleton className="w-6 h-4" />
118-
<Skeleton className="w-3 h-3 rounded-full" />
119-
<Skeleton className="flex-1 h-4" />
120-
<Skeleton className="w-10 h-4" />
121-
<div className="text-right">
122-
<Skeleton className="w-12 h-4 mb-1" />
123-
<Skeleton className="w-10 h-3 mb-1" />
124-
<Skeleton className="w-8 h-3" />
125-
</div>
126-
</div>
127-
))}
128-
</div>
129-
);
130-
}
131-
13220
export function MarketShareLeaderboard({
13321
data,
13422
isLoading,
13523
}: MarketShareLeaderboardProps) {
136-
if (isLoading) {
137-
return <LoadingSkeleton />;
138-
}
139-
140-
if (data.length === 0) {
141-
return (
142-
<div className="flex h-[200px] items-center justify-center text-gray-500 dark:text-gray-400">
143-
No data available
144-
</div>
145-
);
146-
}
147-
148-
const leftColumn = data.slice(0, 5);
149-
const rightColumn = data.slice(5, 10);
24+
const items: LeaderboardItem[] = data.map((entry) => {
25+
const isOther = entry.author.toLowerCase() === "others";
26+
const marketShare = isFinite(entry.marketShare) ? entry.marketShare : 0;
27+
const totalTokens = isFinite(entry.totalTokens) ? entry.totalTokens : 0;
28+
29+
return {
30+
rank: entry.rank,
31+
name: entry.author,
32+
href: isOther ? undefined : `/stats/authors/${entry.author}`,
33+
primaryValue: `${marketShare.toFixed(1)}%`,
34+
secondaryValue: formatTokens(totalTokens),
35+
change: isOther ? undefined : { type: "rank", value: entry.rankChange },
36+
secondaryChange: { type: "share", value: entry.marketShareChange },
37+
isOther,
38+
};
39+
});
15040

15141
return (
152-
<div className="grid grid-cols-2 gap-x-8">
153-
<div className="divide-y divide-gray-100 dark:divide-gray-800">
154-
{leftColumn.map((entry, index) => (
155-
<LeaderboardItem key={entry.author} entry={entry} colorIndex={index} />
156-
))}
157-
</div>
158-
<div className="divide-y divide-gray-100 dark:divide-gray-800">
159-
{rightColumn.map((entry, index) => (
160-
<LeaderboardItem
161-
key={entry.author}
162-
entry={entry}
163-
colorIndex={index + 5}
164-
/>
165-
))}
166-
</div>
167-
</div>
42+
<Leaderboard
43+
data={items}
44+
isLoading={isLoading}
45+
showColorDots={true}
46+
showAllToggle={false}
47+
layout="split"
48+
/>
16849
);
16950
}

0 commit comments

Comments
 (0)