From a039fdd5738167a9ff26f8d317d6f20b0b6e4253 Mon Sep 17 00:00:00 2001 From: KaustubhPatange Date: Sun, 22 Feb 2026 14:22:42 +0530 Subject: [PATCH] feat: add cost calculation metric --- .gitignore | 4 +- package.json | 7 + scripts/fetch-pricing-data.ts | 113 ++++++++++++++ src/main/utils/jsonl.ts | 143 +++++++++++++++++- src/renderer/components/chat/AIChatGroup.tsx | 4 + .../components/common/TokenUsageDisplay.tsx | 17 +++ src/shared/types/api.ts | 24 +++ src/shared/utils/costFormatting.ts | 45 ++++++ 8 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 scripts/fetch-pricing-data.ts create mode 100644 src/shared/utils/costFormatting.ts diff --git a/.gitignore b/.gitignore index 28223886..22230447 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ temp/ eslint-fix/ -remotion/* \ No newline at end of file +remotion/* + +resources/pricing.json diff --git a/package.json b/package.json index ee829eca..3cc5aa04 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/fetch-pricing-data.ts b/scripts/fetch-pricing-data.ts new file mode 100644 index 00000000..703a0e21 --- /dev/null +++ b/scripts/fetch-pricing-data.ts @@ -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> { + 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; + console.log(`Fetched pricing for ${Object.keys(data).length} models`); + + // Filter to Claude models only and validate entries + const claudeModels: Record = {}; + 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 { + 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(); diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index eaf618f0..323bcf8d 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -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 | 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 { + 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; + 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, }; } diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx index 7bf24642..fb522b3b 100644 --- a/src/renderer/components/chat/AIChatGroup.tsx +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -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} /> )} diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx index d38690f7..70fb4887 100644 --- a/src/renderer/components/common/TokenUsageDisplay.tsx +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -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): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; const formattedTotal = formatTokens(totalTokens); @@ -513,6 +517,19 @@ export const TokenUsageDisplay = ({ + {/* Cost (USD) - if available */} + {costUsd !== undefined && costUsd > 0 && ( +
+ Cost (USD) + + {formatCostUsd(costUsd)} + +
+ )} + {/* Visible Context Breakdown - expandable section */} {contextStats && (contextStats.totalEstimatedTokens > 0 || diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index c81dbd8b..351cf3d1 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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 // ============================================================================= diff --git a/src/shared/utils/costFormatting.ts b/src/shared/utils/costFormatting.ts new file mode 100644 index 00000000..3cbedd44 --- /dev/null +++ b/src/shared/utils/costFormatting.ts @@ -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); + } +}