Skip to content

Commit 3a2ec6b

Browse files
committed
Calculate expected costs without PRU limit
1 parent 85a5d5a commit 3a2ec6b

2 files changed

Lines changed: 161 additions & 10 deletions

File tree

src/App.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ import {
4747
filterDataByMonth,
4848
getUserAnalysisData,
4949
getUserBehaviorData,
50-
EXCESS_REQUEST_COST
50+
EXCESS_REQUEST_COST,
51+
getExpectedExcessCost
5152
} from "@/lib/utils";
5253
import { MonthSelector } from "@/components/MonthSelector";
5354
import { UserSearch } from "@/components/UserSearch";
@@ -351,6 +352,7 @@ type BehaviorScatterChartProps = {
351352
behaviorData: BehaviorScatterPoint[];
352353
};
353354

355+
// Isolated the graph due to latency issues. Allows toggling segments without reprocessing data or re-rendering other graphs.
354356
const BehaviorScatterChart = React.memo(function BehaviorScatterChart({
355357
behaviorData,
356358
}: BehaviorScatterChartProps) {
@@ -502,6 +504,7 @@ function App() {
502504
const [projectedUsersData, setProjectedUsersData] = useState<ProjectedUserData[]>([]);
503505
const [selectedSearchUser, setSelectedSearchUser] = useState<string | null>(null);
504506
const [userAnalysisData, setUserAnalysisData] = useState<UserAnalysisData | null>(null);
507+
const [expectedExcessCost, setExpectedExcessCost] = useState<number>(0);
505508
const fileInputRef = useRef<HTMLInputElement>(null);
506509

507510
// Recalculate users exceeding quota when plan selection changes
@@ -515,6 +518,9 @@ function App() {
515518

516519
const projectedDetails = getProjectedUsersExceedingQuotaDetails(data, selectedPlan);
517520
setProjectedUsersData(projectedDetails);
521+
522+
const expectedCost = getExpectedExcessCost(data, selectedPlan);
523+
setExpectedExcessCost(expectedCost);
518524

519525
// Update user analysis data if a user is selected
520526
if (selectedSearchUser) {
@@ -633,6 +639,10 @@ function App() {
633639
// Get the last date available in the filtered CSV for the selected month
634640
const lastDate = getLastDateFromData(filteredData);
635641
setLastDateAvailable(lastDate);
642+
643+
// Get expected excess cost for the selected month
644+
const expectedCost = getExpectedExcessCost(filteredData, selectedPlan);
645+
setExpectedExcessCost(expectedCost);
636646

637647
// Reset selected power user when month changes
638648
setSelectedPowerUser(null);
@@ -1367,6 +1377,15 @@ function App() {
13671377
</span>
13681378
<ChevronRight className="icon" />
13691379
</div>
1380+
<div
1381+
className="flex items-center gap-2"
1382+
title="Projected total cost if the monthly quota limit did not exist, based on each user's usage rate and model cost multipliers"
1383+
>
1384+
<span className="text-sm text-muted-foreground">Expected Cost (no limit):</span>
1385+
<span className="text-lg font-bold text-orange-600">
1386+
${expectedExcessCost.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}
1387+
</span>
1388+
</div>
13701389
{powerUserSummary && (
13711390
<Sheet>
13721391
<SheetTrigger asChild>

src/lib/utils.ts

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { clsx, type ClassValue } from "clsx"
22
import { twMerge } from "tailwind-merge"
3+
import { defaultServerMainFields } from "vite";
34

45
export function cn(...inputs: ClassValue[]) {
56
return twMerge(clsx(inputs))
@@ -196,8 +197,10 @@ export function getModelUsageSummary(data: CopilotUsageData[]): ModelUsageSummar
196197

197198
data.forEach(item => {
198199
if (!summary[item.model]) {
199-
const multiplier = MODEL_MULTIPLIERS[item.model] || 1;
200-
const displayName = DEFAULT_MODELS.includes(item.model) ? 'Default' : item.model;
200+
const multiplier = getModelMultiplier(item.model);
201+
const displayName = isDefaultModel(item.model) ? 'Default' : item.model;
202+
203+
console.log(`Processing model: ${item.model}; Multiplier: ${multiplier}; Display Name: ${displayName}`);
201204

202205
summary[item.model] = {
203206
model: item.model,
@@ -333,26 +336,58 @@ export const PLAN_MONTHLY_LIMITS = {
333336
} as const;
334337

335338
// Model multipliers based on GitHub documentation (for paid plans)
339+
// Uses display names as they appear in GitHub Copilot exports.
336340
export const MODEL_MULTIPLIERS: Record<string, number> = {
337-
// Default models (0x multiplier for paid plans)
341+
// Current default models (0x multiplier for paid plans)
342+
'GPT-4.1': 0,
343+
'GPT-4o': 0,
344+
'GPT-5 mini': 0,
345+
'Raptor mini': 0,
346+
347+
// OpenAI models
348+
'GPT-5.1': 1,
349+
'GPT-5.1-Codex': 1,
350+
'GPT-5.1-Codex-Mini': 0.33,
351+
'GPT-5.1-Codex-Max': 1,
352+
'GPT-5.2': 1,
353+
'GPT-5.2-Codex': 1,
354+
'GPT-5.3-Codex': 1,
355+
'GPT-5.4': 1,
356+
'GPT-5.4 mini': 0.33,
357+
358+
// Anthropic models
359+
'Claude Haiku 4.5': 0.33,
360+
'Claude Sonnet 4': 1,
361+
'Claude Sonnet 4.5': 1,
362+
'Claude Sonnet 4.6': 1,
363+
'Claude Opus 4.5': 3,
364+
'Claude Opus 4.6': 3,
365+
'Claude Opus 4.6 (fast mode) (preview)': 30,
366+
367+
// Google models
368+
'Gemini 2.5 Pro': 1,
369+
'Gemini 3 Flash': 0.33,
370+
'Gemini 3 Pro': 1,
371+
'Gemini 3.1 Pro': 1,
372+
373+
// xAI and other models
374+
'Grok Code Fast 1': 0.25,
375+
'Goldeneye': 1,
376+
377+
// Backward compatibility for older exports
338378
'gpt-4o-2024-11-20': 0,
339379
'gpt-4.1-2025-04-14': 0,
340380
'gpt-4o': 0,
341381
'gpt-4.1': 0,
342-
// GPT-4.5 models
343382
'gpt-4.5': 50,
344-
// Vision models
345383
'gpt-4.1-vision': 0,
346-
// Claude models
347384
'claude-sonnet-3.5': 1,
348385
'claude-sonnet-3.7': 1,
349386
'claude-sonnet-3.7-thinking': 1.25,
350387
'claude-sonnet-4': 1,
351388
'claude-opus-4': 10,
352-
// Gemini models
353389
'gemini-2.0-flash': 0.25,
354390
'gemini-2.5-pro': 1,
355-
// O-series models
356391
'o1': 10,
357392
'o3': 1,
358393
'o3-mini': 0.33,
@@ -363,7 +398,19 @@ export const MODEL_MULTIPLIERS: Record<string, number> = {
363398
};
364399

365400
// Default models that should be grouped
366-
export const DEFAULT_MODELS = ['gpt-4o-2024-11-20', 'gpt-4.1-2025-04-14'];
401+
export const DEFAULT_MODELS = ['GPT-4o', 'GPT-4.1', 'gpt-4o-2024-11-20', 'gpt-4.1-2025-04-14'];
402+
403+
function normalizeModelName(model: string): string {
404+
return model.replace(/^Auto:\s*/, '').trim();
405+
}
406+
407+
function getModelMultiplier(model: string): number {
408+
return MODEL_MULTIPLIERS[normalizeModelName(model)] ?? 1;
409+
}
410+
411+
function isDefaultModel(model: string): boolean {
412+
return DEFAULT_MODELS.includes(normalizeModelName(model));
413+
}
367414

368415
// Cost per excess request (in USD) for premium requests
369416
export const EXCESS_REQUEST_COST = 0.04; // $0.04 per excess request
@@ -788,6 +835,91 @@ export function getProjectedUsersExceedingQuotaDetails(data: CopilotUsageData[],
788835
return projectedUsers.sort((a, b) => b.projectedMonthlyTotal - a.projectedMonthlyTotal);
789836
}
790837

838+
/**
839+
* Calculate the total expected excess cost if there were no monthly quota limit.
840+
* For each user who has reached the plan limit:
841+
* 1. Find the day their cumulative requests hit the limit (budget exhaustion day).
842+
* 2. Compute daily average requests per model, excluding the last usage day (to avoid partial-day skew).
843+
* 3. Project those requests over the remaining days after the exhaustion day.
844+
* 4. Apply each model's cost multiplier and sum the cost at $0.04/PRU.
845+
*/
846+
export function getExpectedExcessCost(data: CopilotUsageData[], plan: string = COPILOT_PLANS.BUSINESS): number {
847+
if (!data.length) return 0;
848+
849+
const planLimit = (PLAN_MONTHLY_LIMITS as Record<string, number>)[plan] ?? PLAN_MONTHLY_LIMITS[COPILOT_PLANS.BUSINESS];
850+
851+
const lastDate = getLastDateFromData(data);
852+
if (!lastDate) return 0;
853+
854+
const lastDateObj = new Date(lastDate);
855+
const totalDaysInMonth = new Date(lastDateObj.getFullYear(), lastDateObj.getMonth() + 1, 0).getDate();
856+
857+
// Group all items by user
858+
const userDataMap: Record<string, CopilotUsageData[]> = {};
859+
data.forEach(item => {
860+
if (!userDataMap[item.user]) userDataMap[item.user] = [];
861+
userDataMap[item.user].push(item);
862+
});
863+
864+
let totalExpectedCost = 0;
865+
866+
Object.values(userDataMap).forEach(userItems => {
867+
// Only process users who have reached the plan limit
868+
const totalRequests = userItems.reduce((sum, item) => sum + item.requestsUsed, 0);
869+
if (totalRequests < planLimit) return;
870+
871+
// Group by date (YYYY-MM-DD), sorted ascending
872+
const byDate: Record<string, CopilotUsageData[]> = {};
873+
userItems.forEach(item => {
874+
const date = item.timestamp.toISOString().split('T')[0];
875+
if (!byDate[date]) byDate[date] = [];
876+
byDate[date].push(item);
877+
});
878+
const sortedDates = Object.keys(byDate).sort();
879+
880+
// Find the day cumulative requests first reach the plan limit
881+
let cumulative = 0;
882+
let budgetExhaustionDayOfMonth: number | null = null;
883+
for (const date of sortedDates) {
884+
const dayTotal = byDate[date].reduce((sum, item) => sum + item.requestsUsed, 0);
885+
cumulative += dayTotal;
886+
if (cumulative >= planLimit) {
887+
budgetExhaustionDayOfMonth = new Date(date).getDate();
888+
break;
889+
}
890+
}
891+
if (budgetExhaustionDayOfMonth === null) return;
892+
893+
const remainingDays = totalDaysInMonth - budgetExhaustionDayOfMonth;
894+
if (remainingDays <= 0) return;
895+
896+
// Compute daily average per model, excluding the last usage day (which may be partial)
897+
const lastUsageDate = sortedDates[sortedDates.length - 1];
898+
const datesForAverage = sortedDates.filter(d => d !== lastUsageDate);
899+
if (datesForAverage.length === 0) return;
900+
901+
const modelTotals: Record<string, number> = {};
902+
datesForAverage.forEach(date => {
903+
byDate[date].forEach(item => {
904+
modelTotals[item.model] = (modelTotals[item.model] || 0) + item.requestsUsed;
905+
});
906+
});
907+
908+
const numDays = datesForAverage.length;
909+
910+
// Project extra requests per model over remaining days and apply cost multiplier
911+
Object.entries(modelTotals).forEach(([model, total]) => {
912+
const multiplier = getModelMultiplier(model);
913+
if (multiplier === 0) return; // Free models have no cost
914+
const dailyAvg = total / numDays;
915+
const projectedExtra = dailyAvg * remainingDays;
916+
totalExpectedCost += projectedExtra * multiplier * EXCESS_REQUEST_COST;
917+
});
918+
});
919+
920+
return totalExpectedCost;
921+
}
922+
791923
// Re-export month utilities for backward compatibility
792924
export { getAvailableMonths, filterDataByMonth, getMonthCoverage } from './month-utils';
793925
export type { MonthOption } from '../types/month';

0 commit comments

Comments
 (0)