Skip to content

Commit d7f3041

Browse files
committed
feat: add estimated cost calculation for all providers
1 parent 57cd4ac commit d7f3041

File tree

5 files changed

+160
-52
lines changed

5 files changed

+160
-52
lines changed

src/image/template.tsx

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,30 @@ function RankingItemRow({ rank, name, logoUrl }: RankingItemRowProps) {
373373
}
374374

375375
function StatsGrid({ stats }: { stats: OpenCodeStats }) {
376-
const hasZen = stats.hasZenUsage;
376+
const totalCombinedCost = stats.zenCost + stats.estimatedCost;
377+
const isEstimated = stats.estimatedCost > 0;
378+
379+
const costLabel = isEstimated ? "Est. Cost" : "Total Cost";
380+
let costValue: React.ReactNode;
381+
382+
if (isEstimated && stats.zenCost > 0) {
383+
costValue = (
384+
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: spacing[1] }}>
385+
<span>{formatCost(totalCombinedCost)}</span>
386+
<span
387+
style={{
388+
fontSize: typography.size.lg,
389+
fontWeight: typography.weight.medium,
390+
color: colors.accent.primary,
391+
}}
392+
>
393+
{formatCost(stats.zenCost)} Zen
394+
</span>
395+
</div>
396+
);
397+
} else {
398+
costValue = formatCost(totalCombinedCost);
399+
}
377400

378401
return (
379402
<div
@@ -384,44 +407,30 @@ function StatsGrid({ stats }: { stats: OpenCodeStats }) {
384407
gap: spacing[5],
385408
}}
386409
>
387-
{hasZen ? (
388-
<div style={{ display: "flex", flexDirection: "column", gap: spacing[5] }}>
389-
<div style={{ display: "flex", gap: spacing[5] }}>
390-
<StatBox label="Sessions" value={formatNumber(stats.totalSessions)} />
391-
<StatBox label="Messages" value={formatNumber(stats.totalMessages)} />
392-
<StatBox label="Total Tokens" value={formatNumber(stats.totalTokens)} />
393-
</div>
394-
395-
<div style={{ display: "flex", gap: spacing[5] }}>
396-
<StatBox label="Projects" value={formatNumber(stats.totalProjects)} />
397-
<StatBox label="Streak" value={`${stats.maxStreak}d`} />
398-
<StatBox label="OpenCode Zen Cost" value={formatCost(stats.totalCost)} />
399-
</div>
410+
<div style={{ display: "flex", flexDirection: "column", gap: spacing[5] }}>
411+
<div style={{ display: "flex", gap: spacing[5] }}>
412+
<StatBox label="Sessions" value={formatNumber(stats.totalSessions)} />
413+
<StatBox label="Messages" value={formatNumber(stats.totalMessages)} />
414+
<StatBox label="Total Tokens" value={formatNumber(stats.totalTokens)} />
400415
</div>
401-
) : (
402-
<div style={{ display: "flex", flexDirection: "column", gap: spacing[5] }}>
403-
<div style={{ display: "flex", gap: spacing[5] }}>
404-
<StatBox label="Sessions" value={formatNumber(stats.totalSessions)} />
405-
<StatBox label="Messages" value={formatNumber(stats.totalMessages)} />
406-
<StatBox label="Tokens" value={formatNumber(stats.totalTokens)} />
407-
</div>
408-
409-
<div style={{ display: "flex", gap: spacing[5] }}>
410-
<StatBox label="Projects" value={formatNumber(stats.totalProjects)} />
411-
<StatBox label="Streak" value={`${stats.maxStreak}d`} />
412-
</div>
416+
417+
<div style={{ display: "flex", gap: spacing[5] }}>
418+
<StatBox label="Projects" value={formatNumber(stats.totalProjects)} />
419+
<StatBox label="Streak" value={`${stats.maxStreak}d`} />
420+
<StatBox label={costLabel} value={costValue} />
413421
</div>
414-
)}
422+
</div>
415423
</div>
416424
);
417425
}
418426

419427
interface StatBoxProps {
420428
label: string;
421-
value: string;
429+
value: string | React.ReactNode;
430+
subtitle?: string;
422431
}
423432

424-
function StatBox({ label, value }: StatBoxProps) {
433+
function StatBox({ label, value, subtitle }: StatBoxProps) {
425434
return (
426435
<div
427436
style={{
@@ -451,16 +460,33 @@ function StatBox({ label, value }: StatBoxProps) {
451460
{label}
452461
</span>
453462

454-
<span
455-
style={{
456-
fontSize: typography.size["2xl"],
457-
fontWeight: typography.weight.bold,
458-
color: colors.text.primary,
459-
lineHeight: typography.lineHeight.none,
460-
}}
461-
>
462-
{value}
463-
</span>
463+
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
464+
<div
465+
style={{
466+
fontSize: typography.size["2xl"],
467+
fontWeight: typography.weight.bold,
468+
color: colors.text.primary,
469+
lineHeight: typography.lineHeight.none,
470+
display: "flex",
471+
flexDirection: "column",
472+
alignItems: "center",
473+
}}
474+
>
475+
{value}
476+
</div>
477+
{subtitle && (
478+
<span
479+
style={{
480+
fontSize: typography.size.sm,
481+
fontWeight: typography.weight.regular,
482+
color: colors.text.muted,
483+
marginTop: spacing[1],
484+
}}
485+
>
486+
{subtitle}
487+
</span>
488+
)}
489+
</div>
464490
</div>
465491
);
466492
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ async function main() {
109109
`Total Tokens: ${formatNumber(stats.totalTokens)}`,
110110
`Projects: ${formatNumber(stats.totalProjects)}`,
111111
`Streak: ${stats.maxStreak} days`,
112-
stats.hasZenUsage && `Zen Cost: ${stats.totalCost.toFixed(2)}$`,
112+
stats.zenCost > 0 && `Zen Cost: $${stats.zenCost.toFixed(2)}`,
113+
stats.estimatedCost > 0 && `Est. Cost: ~$${stats.estimatedCost.toFixed(2)}`,
113114
stats.mostActiveDay && `Most Active: ${stats.mostActiveDay.formattedDate}`,
114115
];
115116

src/models.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
export interface ModelCost {
2+
input: number;
3+
output: number;
4+
cacheRead?: number;
5+
cacheWrite?: number;
6+
}
7+
18
interface ModelInfo {
29
id: string;
310
name: string;
411
provider: string;
12+
cost?: ModelCost;
513
}
614

715
interface ProviderInfo {
@@ -14,6 +22,21 @@ interface ModelsDevData {
1422
providers: Record<string, ProviderInfo>;
1523
}
1624

25+
interface ModelsDevModel {
26+
name?: string;
27+
cost?: {
28+
input?: number;
29+
output?: number;
30+
cache_read?: number;
31+
cache_write?: number;
32+
};
33+
}
34+
35+
interface ModelsDevProvider {
36+
name?: string;
37+
models?: Record<string, ModelsDevModel>;
38+
}
39+
1740
// Cache for the fetched data
1841
let cachedData: ModelsDevData | null = null;
1942

@@ -40,7 +63,7 @@ export async function fetchModelsData(): Promise<ModelsDevData> {
4063
for (const [providerId, providerData] of Object.entries(data)) {
4164
if (!providerData || typeof providerData !== "object") continue;
4265

43-
const pd = providerData as { name?: string; models?: Record<string, { name?: string }> };
66+
const pd = providerData as ModelsDevProvider;
4467

4568
if (pd.name) {
4669
providers[providerId] = {
@@ -52,11 +75,31 @@ export async function fetchModelsData(): Promise<ModelsDevData> {
5275
if (pd.models && typeof pd.models === "object") {
5376
for (const [modelId, modelData] of Object.entries(pd.models)) {
5477
if (modelData && typeof modelData === "object" && modelData.name) {
55-
models[modelId] = {
78+
const model: ModelInfo = {
5679
id: modelId,
5780
name: modelData.name,
5881
provider: providerId,
5982
};
83+
84+
// Extract pricing data if available
85+
if (modelData.cost && typeof modelData.cost === "object") {
86+
const costData = modelData.cost as {
87+
input?: number;
88+
output?: number;
89+
cache_read?: number;
90+
cache_write?: number;
91+
};
92+
if (typeof costData.input === "number" && typeof costData.output === "number") {
93+
model.cost = {
94+
input: costData.input,
95+
output: costData.output,
96+
cacheRead: costData.cache_read,
97+
cacheWrite: costData.cache_write,
98+
};
99+
}
100+
}
101+
102+
models[modelId] = model;
60103
}
61104
}
62105
}
@@ -110,6 +153,13 @@ export function getProviderLogoUrl(providerId: string): string {
110153
return `https://models.dev/logos/${providerId}.svg`;
111154
}
112155

156+
export function getModelPricing(modelId: string): ModelCost | undefined {
157+
if (!cachedData) {
158+
return undefined;
159+
}
160+
return cachedData.models[modelId]?.cost;
161+
}
162+
113163
function formatModelIdAsName(modelId: string): string {
114164
return modelId
115165
.split(/[-_]/)

src/stats.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { OpenCodeStats, ModelStats, ProviderStats, WeekdayActivity } from "./types";
22
import { collectMessages, collectProjects, collectSessions } from "./collector";
3-
import { fetchModelsData, getModelDisplayName, getModelProvider, getProviderDisplayName } from "./models";
3+
import { fetchModelsData, getModelDisplayName, getModelProvider, getProviderDisplayName, getModelPricing, type ModelCost } from "./models";
44

55
export async function calculateStats(year: number): Promise<OpenCodeStats> {
66
const [, allSessions, messages, projects] = await Promise.all([
@@ -32,8 +32,8 @@ export async function calculateStats(year: number): Promise<OpenCodeStats> {
3232

3333
let totalInputTokens = 0;
3434
let totalOutputTokens = 0;
35-
let totalCost = 0;
36-
let hasZenUsage = false;
35+
let zenCost = 0;
36+
let estimatedCost = 0;
3737
const modelCounts = new Map<string, number>();
3838
const providerCounts = new Map<string, number>();
3939
const dailyActivity = new Map<string, number>();
@@ -46,8 +46,14 @@ export async function calculateStats(year: number): Promise<OpenCodeStats> {
4646
}
4747

4848
if (message.providerID === "opencode" && message.cost) {
49-
totalCost += message.cost;
50-
hasZenUsage = true;
49+
zenCost += message.cost;
50+
}
51+
52+
if (message.providerID !== "opencode" && message.tokens && message.modelID) {
53+
const pricing = getModelPricing(message.modelID);
54+
if (pricing) {
55+
estimatedCost += calculateMessageCost(message.tokens, pricing);
56+
}
5157
}
5258

5359
if (message.role === "assistant") {
@@ -106,8 +112,8 @@ export async function calculateStats(year: number): Promise<OpenCodeStats> {
106112
totalInputTokens,
107113
totalOutputTokens,
108114
totalTokens,
109-
totalCost,
110-
hasZenUsage,
115+
zenCost,
116+
estimatedCost,
111117
topModels,
112118
topProviders,
113119
maxStreak,
@@ -119,6 +125,30 @@ export async function calculateStats(year: number): Promise<OpenCodeStats> {
119125
};
120126
}
121127

128+
interface TokenCounts {
129+
input: number;
130+
output: number;
131+
reasoning: number;
132+
cache: { read: number; write: number };
133+
}
134+
135+
function calculateMessageCost(tokens: TokenCounts, pricing: ModelCost): number {
136+
const MILLION = 1_000_000;
137+
138+
let cost = 0;
139+
cost += (tokens.input * pricing.input) / MILLION;
140+
cost += (tokens.output * pricing.output) / MILLION;
141+
142+
if (pricing.cacheRead && tokens.cache.read) {
143+
cost += (tokens.cache.read * pricing.cacheRead) / MILLION;
144+
}
145+
if (pricing.cacheWrite && tokens.cache.write) {
146+
cost += (tokens.cache.write * pricing.cacheWrite) / MILLION;
147+
}
148+
149+
return cost;
150+
}
151+
122152
function formatDateKey(date: Date): string {
123153
const year = date.getFullYear();
124154
const month = String(date.getMonth() + 1).padStart(2, "0");

src/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ export interface OpenCodeStats {
8787
totalOutputTokens: number;
8888
totalTokens: number;
8989

90-
// Cost (only from OpenCode/Zen provider)
91-
totalCost: number;
92-
hasZenUsage: boolean;
90+
// Cost (only from OpenCode Zen provider)
91+
zenCost: number;
92+
// Cost from all other providers combined
93+
estimatedCost: number;
9394

9495
// Models (sorted by usage)
9596
topModels: ModelStats[];

0 commit comments

Comments
 (0)