feat: add cost calculation metric

This commit is contained in:
KaustubhPatange 2026-02-22 14:22:42 +05:30
parent 6f59ccdf38
commit a039fdd573
8 changed files with 354 additions and 3 deletions

4
.gitignore vendored
View file

@ -46,4 +46,6 @@ temp/
eslint-fix/
remotion/*
remotion/*
resources/pricing.json

View file

@ -19,6 +19,7 @@
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "electron-vite dev",
"prebuild": "tsx scripts/fetch-pricing-data.ts",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",
"dist:mac": "electron-builder --mac --publish always",
@ -127,6 +128,12 @@
"asarUnpack": [
"out/renderer/**"
],
"extraResources": [
{
"from": "resources/pricing.json",
"to": "pricing.json"
}
],
"npmRebuild": false,
"extraMetadata": {
"main": "dist-electron/main/index.cjs"

View file

@ -0,0 +1,113 @@
#!/usr/bin/env tsx
/**
* Fetch latest model pricing from LiteLLM and save to renderer assets.
* Filters to Claude models only to reduce bundle size.
* Runs automatically during prebuild.
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LITELLM_PRICING_URL =
'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
const OUTPUT_PATH = path.join(__dirname, '..', 'resources', 'pricing.json');
const FETCH_TIMEOUT = 10000; // 10 seconds
interface ModelPricing {
input_cost_per_token: number;
output_cost_per_token: number;
cache_creation_input_token_cost?: number;
cache_read_input_token_cost?: number;
input_cost_per_token_above_200k_tokens?: number;
output_cost_per_token_above_200k_tokens?: number;
cache_creation_input_token_cost_above_200k_tokens?: number;
cache_read_input_token_cost_above_200k_tokens?: number;
[key: string]: unknown;
}
function isValidModelPricing(entry: unknown): entry is ModelPricing {
return (
typeof entry === 'object' &&
entry !== null &&
'input_cost_per_token' in entry &&
'output_cost_per_token' in entry &&
typeof (entry as ModelPricing).input_cost_per_token === 'number' &&
typeof (entry as ModelPricing).output_cost_per_token === 'number'
);
}
function isClaudeModel(modelName: string): boolean {
const lower = modelName.toLowerCase();
return lower.includes('claude');
}
async function fetchPricingData(): Promise<Record<string, ModelPricing>> {
console.log('Fetching pricing data from LiteLLM...');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
const response = await fetch(LITELLM_PRICING_URL, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as Record<string, unknown>;
console.log(`Fetched pricing for ${Object.keys(data).length} models`);
// Filter to Claude models only and validate entries
const claudeModels: Record<string, ModelPricing> = {};
for (const [modelName, entry] of Object.entries(data)) {
if (isClaudeModel(modelName) && isValidModelPricing(entry)) {
claudeModels[modelName] = entry;
}
}
console.log(`Filtered to ${Object.keys(claudeModels).length} Claude models`);
return claudeModels;
} catch (error) {
clearTimeout(timeout);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Fetch timeout after 10 seconds');
}
throw error;
}
}
async function main(): Promise<void> {
try {
console.log('Fetching pricing data for models...');
const pricing = await fetchPricingData();
// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_PATH);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write formatted JSON
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(pricing, null, 2), 'utf-8');
// Calculate file size
const stats = fs.statSync(OUTPUT_PATH);
const sizeKB = (stats.size / 1024).toFixed(2);
console.log(`✓ Wrote pricing data to ${OUTPUT_PATH}`);
console.log(` Bundle size: ${sizeKB} KB`);
} catch (error) {
console.error('Failed to fetch pricing data:', error);
console.error('Build will continue with existing pricing.json if available');
// Don't fail the build - allow using existing pricing.json
process.exit(0);
}
}
main();

View file

@ -212,6 +212,113 @@ function parseMessageType(type?: string): MessageType | null {
}
}
// =============================================================================
// Cost Calculation
// =============================================================================
import * as fs from 'fs';
import * as path from 'path';
interface ModelPricing {
input_cost_per_token: number;
output_cost_per_token: number;
cache_creation_input_token_cost?: number;
cache_read_input_token_cost?: number;
input_cost_per_token_above_200k_tokens?: number;
output_cost_per_token_above_200k_tokens?: number;
cache_creation_input_token_cost_above_200k_tokens?: number;
cache_read_input_token_cost_above_200k_tokens?: number;
[key: string]: unknown;
}
const TIER_THRESHOLD = 200_000;
// Cache pricing data in memory (loaded once on first use)
let pricingCache: Record<string, unknown> | null = null;
/**
* Load pricing data from resources directory.
* Uses electron-vite resource directory pattern:
* - Development: resources/pricing.json (project root)
* - Production: process.resourcesPath/pricing.json
*/
function loadPricingData(): Record<string, unknown> {
if (pricingCache !== null) {
return pricingCache;
}
try {
// Determine if we're in development or production
const isDev = process.env.NODE_ENV === 'development' || !process.resourcesPath;
let pricingPath: string;
if (isDev) {
// Development: Compiled code is in dist-electron/main/
// __dirname = /path/to/project/dist-electron/main
// Need to go up 2 levels to reach project root, then into resources/
pricingPath = path.join(__dirname, '..', '..', 'resources', 'pricing.json');
} else {
// Production: pricing.json in app's resources directory
pricingPath = path.join(process.resourcesPath, 'pricing.json');
}
const data = fs.readFileSync(pricingPath, 'utf-8');
pricingCache = JSON.parse(data) as Record<string, unknown>;
return pricingCache;
} catch (error) {
console.error('Failed to load pricing data:', error);
// Return empty object if pricing data can't be loaded
pricingCache = {};
return pricingCache;
}
}
function calculateTieredCost(tokens: number, baseRate: number, tieredRate?: number): number {
if (tokens <= 0) return 0;
if (!tieredRate || tokens <= TIER_THRESHOLD) {
return tokens * baseRate;
}
const costBelow = TIER_THRESHOLD * baseRate;
const costAbove = (tokens - TIER_THRESHOLD) * tieredRate;
return costBelow + costAbove;
}
function getPricing(modelName: string): ModelPricing | null {
const pricing = loadPricingData();
const tryGet = (key: string): ModelPricing | null => {
const entry = pricing[key];
if (
entry &&
typeof entry === 'object' &&
'input_cost_per_token' in entry &&
'output_cost_per_token' in entry
) {
return entry as ModelPricing;
}
return null;
};
// Try exact match
const exact = tryGet(modelName);
if (exact) return exact;
// Try lowercase
const lowerName = modelName.toLowerCase();
const lower = tryGet(lowerName);
if (lower) return lower;
// Try case-insensitive search
for (const key of Object.keys(pricing)) {
if (key.toLowerCase() === lowerName) {
const match = tryGet(key);
if (match) return match;
}
}
return null;
}
// =============================================================================
// Metrics Calculation
// =============================================================================
@ -228,7 +335,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
const costUsd = 0;
let modelName: string | undefined;
// Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions)
const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t));
@ -251,6 +358,38 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0;
}
if (!modelName && msg.model) {
modelName = msg.model;
}
}
// Calculate cost
let costUsd = 0;
if (modelName) {
const pricing = getPricing(modelName);
if (pricing) {
const inputCost = calculateTieredCost(
inputTokens,
pricing.input_cost_per_token,
pricing.input_cost_per_token_above_200k_tokens
);
const outputCost = calculateTieredCost(
outputTokens,
pricing.output_cost_per_token,
pricing.output_cost_per_token_above_200k_tokens
);
const cacheCreationCost = calculateTieredCost(
cacheCreationTokens,
pricing.cache_creation_input_token_cost ?? 0,
pricing.cache_creation_input_token_cost_above_200k_tokens
);
const cacheReadCost = calculateTieredCost(
cacheReadTokens,
pricing.cache_read_input_token_cost ?? 0,
pricing.cache_read_input_token_cost_above_200k_tokens
);
costUsd = inputCost + outputCost + cacheCreationCost + cacheReadCost;
}
}
return {
@ -261,7 +400,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
cacheReadTokens,
cacheCreationTokens,
messageCount: messages.length,
costUsd: costUsd > 0 ? costUsd : undefined,
costUsd,
};
}

View file

@ -245,6 +245,9 @@ const AIChatGroupInner = ({
return null;
}, [aiGroup.responses]);
// Get the total cost
const costUSD = aiGroup.metrics.costUsd;
// Calculate thinking and text output tokens from assistant message content blocks
// These are estimated from the actual content, providing breakdown of output token usage
const { thinkingTokens, textOutputTokens } = useMemo(() => {
@ -470,6 +473,7 @@ const AIChatGroupInner = ({
contextStats={contextStats}
phaseNumber={phaseNumber}
totalPhases={totalPhases}
costUsd={costUSD}
/>
)}

View file

@ -11,6 +11,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { formatCostUsd } from '@shared/utils/costFormatting';
import { getModelColorClass } from '@shared/utils/modelParser';
import {
formatTokensCompact as formatTokens,
@ -49,6 +50,8 @@ interface TokenUsageDisplayProps {
phaseNumber?: number;
/** Total number of phases in the session */
totalPhases?: number;
/** Optional USD cost for this usage */
costUsd?: number;
}
/**
@ -255,6 +258,7 @@ export const TokenUsageDisplay = ({
contextStats,
phaseNumber,
totalPhases,
costUsd,
}: Readonly<TokenUsageDisplayProps>): React.JSX.Element => {
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
const formattedTotal = formatTokens(totalTokens);
@ -513,6 +517,19 @@ export const TokenUsageDisplay = ({
</span>
</div>
{/* Cost (USD) - if available */}
{costUsd !== undefined && costUsd > 0 && (
<div className="mt-1 flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_SECONDARY }}>Cost (USD)</span>
<span
className="tabular-nums"
style={{ color: 'var(--color-text-primary, var(--color-text))' }}
>
{formatCostUsd(costUsd)}
</span>
</div>
)}
{/* Visible Context Breakdown - expandable section */}
{contextStats &&
(contextStats.totalEstimatedTokens > 0 ||

View file

@ -29,6 +29,30 @@ import type {
SubagentDetail,
} from '@main/types';
// =============================================================================
// Cost Calculation Types
// =============================================================================
/**
* Detailed cost breakdown by token type for a session or chunk
*/
export interface CostBreakdown {
/** Cost for input tokens */
inputCost: number;
/** Cost for output tokens */
outputCost: number;
/** Cost for cache creation tokens */
cacheCreationCost: number;
/** Cost for cache read tokens */
cacheReadCost: number;
/** Total cost (sum of all components) */
totalCost: number;
/** Model name used for calculation */
model: string;
/** Source of the cost data */
source: 'calculated' | 'precalculated' | 'unavailable';
}
// =============================================================================
// Agent Config
// =============================================================================

View file

@ -0,0 +1,45 @@
/**
* Cost formatting utilities
*/
/**
* Format USD cost with appropriate precision
* - $0.001 or more: 2 decimal places ($1.23)
* - Less than $0.001: 3-4 decimal places for precision ($0.0012)
* - Zero: $0.00
*/
export function formatCostUsd(cost: number): string {
if (cost === 0) {
return '$0.00';
}
if (cost >= 0.01) {
// Standard currency format for amounts >= 1 cent
return `$${cost.toFixed(2)}`;
} else if (cost >= 0.001) {
// 3 decimal places for sub-cent amounts
return `$${cost.toFixed(3)}`;
} else {
// 4 decimal places for very small amounts
return `$${cost.toFixed(4)}`;
}
}
/**
* Format cost compactly for display in badges
* - Rounds to 2 decimal places
* - Omits $ prefix for brevity
*/
export function formatCostCompact(cost: number): string {
if (cost === 0) {
return '0.00';
}
if (cost >= 0.01) {
return cost.toFixed(2);
} else if (cost >= 0.001) {
return cost.toFixed(3);
} else {
return cost.toFixed(4);
}
}