Merge pull request #65 from KaustubhPatange/main
feat: add cost calculation metric
This commit is contained in:
commit
58c979fa98
15 changed files with 5415 additions and 6 deletions
|
|
@ -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"
|
||||
|
|
|
|||
4195
resources/pricing.json
Normal file
4195
resources/pricing.json
Normal file
File diff suppressed because it is too large
Load diff
113
scripts/fetch-pricing-data.ts
Normal file
113
scripts/fetch-pricing-data.ts
Normal 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();
|
||||
|
|
@ -461,6 +461,7 @@ export const EMPTY_METRICS: SessionMetrics = {
|
|||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
messageCount: 0,
|
||||
costUsd: 0,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -244,12 +351,52 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
|
|||
}
|
||||
}
|
||||
|
||||
// Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals)
|
||||
let costUsd = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.usage) {
|
||||
inputTokens += msg.usage.input_tokens ?? 0;
|
||||
outputTokens += msg.usage.output_tokens ?? 0;
|
||||
cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
|
||||
cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0;
|
||||
const msgInputTokens = msg.usage.input_tokens ?? 0;
|
||||
const msgOutputTokens = msg.usage.output_tokens ?? 0;
|
||||
const msgCacheReadTokens = msg.usage.cache_read_input_tokens ?? 0;
|
||||
const msgCacheCreationTokens = msg.usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
inputTokens += msgInputTokens;
|
||||
outputTokens += msgOutputTokens;
|
||||
cacheReadTokens += msgCacheReadTokens;
|
||||
cacheCreationTokens += msgCacheCreationTokens;
|
||||
|
||||
// Calculate cost for this message if we have pricing data
|
||||
if (msg.model && !modelName) {
|
||||
modelName = msg.model;
|
||||
}
|
||||
|
||||
if (msg.model) {
|
||||
const pricing = getPricing(msg.model);
|
||||
if (pricing) {
|
||||
const inputCost = calculateTieredCost(
|
||||
msgInputTokens,
|
||||
pricing.input_cost_per_token,
|
||||
pricing.input_cost_per_token_above_200k_tokens
|
||||
);
|
||||
const outputCost = calculateTieredCost(
|
||||
msgOutputTokens,
|
||||
pricing.output_cost_per_token,
|
||||
pricing.output_cost_per_token_above_200k_tokens
|
||||
);
|
||||
const cacheCreationCost = calculateTieredCost(
|
||||
msgCacheCreationTokens,
|
||||
pricing.cache_creation_input_token_cost ?? 0,
|
||||
pricing.cache_creation_input_token_cost_above_200k_tokens
|
||||
);
|
||||
const cacheReadCost = calculateTieredCost(
|
||||
msgCacheReadTokens,
|
||||
pricing.cache_read_input_token_cost ?? 0,
|
||||
pricing.cache_read_input_token_cost_above_200k_tokens
|
||||
);
|
||||
costUsd += inputCost + outputCost + cacheCreationCost + cacheReadCost;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +408,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
|
|||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
messageCount: messages.length,
|
||||
costUsd: costUsd > 0 ? costUsd : undefined,
|
||||
costUsd,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -871,6 +871,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
onNavigateToTool={handleNavigateToTool}
|
||||
onNavigateToUserGroup={handleNavigateToUserGroup}
|
||||
totalSessionTokens={lastAiGroupTotalTokens}
|
||||
sessionMetrics={sessionDetail?.metrics}
|
||||
phaseInfo={sessionPhaseInfo ?? undefined}
|
||||
selectedPhase={selectedContextPhase}
|
||||
onPhaseChange={setSelectedContextPhase}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { formatCostUsd } from '@shared/utils/costFormatting';
|
||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||
|
||||
import { formatTokens } from '../utils/formatting';
|
||||
|
|
@ -20,11 +21,13 @@ import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
|||
|
||||
import type { ContextViewMode } from '../types';
|
||||
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||
import type { SessionMetrics } from '@shared/types';
|
||||
|
||||
interface SessionContextHeaderProps {
|
||||
injectionCount: number;
|
||||
totalTokens: number;
|
||||
totalSessionTokens?: number;
|
||||
sessionMetrics?: SessionMetrics;
|
||||
onClose?: () => void;
|
||||
phaseInfo?: ContextPhaseInfo;
|
||||
selectedPhase: number | null;
|
||||
|
|
@ -37,6 +40,7 @@ export const SessionContextHeader = ({
|
|||
injectionCount,
|
||||
totalTokens,
|
||||
totalSessionTokens,
|
||||
sessionMetrics,
|
||||
onClose,
|
||||
phaseInfo,
|
||||
selectedPhase,
|
||||
|
|
@ -115,6 +119,24 @@ export const SessionContextHeader = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Metrics Breakdown */}
|
||||
{sessionMetrics && (
|
||||
<div
|
||||
className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 pt-2 text-[10px]"
|
||||
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
|
||||
>
|
||||
{/* Cost */}
|
||||
{sessionMetrics.costUsd !== undefined && sessionMetrics.costUsd > 0 && (
|
||||
<div className="col-span-2">
|
||||
<span style={{ color: COLOR_TEXT_MUTED }}>Session Cost: </span>
|
||||
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
{formatCostUsd(sessionMetrics.costUsd)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase selector - only shown when compactions exist */}
|
||||
{phaseInfo && phaseInfo.phases.length > 1 && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const SessionContextPanel = ({
|
|||
onNavigateToTool,
|
||||
onNavigateToUserGroup,
|
||||
totalSessionTokens,
|
||||
sessionMetrics,
|
||||
phaseInfo,
|
||||
selectedPhase,
|
||||
onPhaseChange,
|
||||
|
|
@ -190,6 +191,7 @@ export const SessionContextPanel = ({
|
|||
injectionCount={injections.length}
|
||||
totalTokens={totalTokens}
|
||||
totalSessionTokens={totalSessionTokens}
|
||||
sessionMetrics={sessionMetrics}
|
||||
onClose={onClose}
|
||||
phaseInfo={phaseInfo}
|
||||
selectedPhase={selectedPhase}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import type { ClaudeMdSource } from '@renderer/types/claudeMd';
|
||||
import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||
import type { SessionMetrics } from '@shared/types';
|
||||
|
||||
// =============================================================================
|
||||
// Props Interface
|
||||
|
|
@ -24,6 +25,8 @@ export interface SessionContextPanelProps {
|
|||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||
/** Total session tokens (input + output + cache) for comparison */
|
||||
totalSessionTokens?: number;
|
||||
/** Full session metrics (input, output, cache tokens, cost) */
|
||||
sessionMetrics?: SessionMetrics;
|
||||
/** Phase information for phase selector */
|
||||
phaseInfo?: ContextPhaseInfo;
|
||||
/** Currently selected phase (null = current/latest) */
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
45
src/shared/utils/costFormatting.ts
Normal file
45
src/shared/utils/costFormatting.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
600
test/main/utils/costCalculation.test.ts
Normal file
600
test/main/utils/costCalculation.test.ts
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
/**
|
||||
* Tests for cost calculation in jsonl.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import { calculateMetrics } from '@main/utils/jsonl';
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('fs');
|
||||
|
||||
describe('Cost Calculation', () => {
|
||||
// Sample pricing data matching Claude models
|
||||
const mockPricingData = {
|
||||
'claude-3-5-sonnet-20241022': {
|
||||
input_cost_per_token: 0.000003,
|
||||
output_cost_per_token: 0.000015,
|
||||
cache_creation_input_token_cost: 0.00000375,
|
||||
cache_read_input_token_cost: 0.0000003,
|
||||
input_cost_per_token_above_200k_tokens: 0.000006,
|
||||
output_cost_per_token_above_200k_tokens: 0.00003,
|
||||
cache_creation_input_token_cost_above_200k_tokens: 0.0000075,
|
||||
cache_read_input_token_cost_above_200k_tokens: 0.0000006,
|
||||
},
|
||||
'claude-3-opus-20240229': {
|
||||
input_cost_per_token: 0.000015,
|
||||
output_cost_per_token: 0.000075,
|
||||
cache_creation_input_token_cost: 0.00001875,
|
||||
cache_read_input_token_cost: 0.0000015,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset modules to clear pricing cache
|
||||
vi.resetModules();
|
||||
|
||||
// Mock fs.readFileSync to return our test pricing data
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockPricingData));
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('Basic Cost Calculation', () => {
|
||||
it('should calculate cost for simple token usage', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Expected: (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105
|
||||
expect(metrics.costUsd).toBeCloseTo(0.0105, 6);
|
||||
});
|
||||
|
||||
it('should calculate cost with cache tokens', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_creation_input_tokens: 200,
|
||||
cache_read_input_tokens: 300,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Input: 1000 * 0.000003 = 0.003
|
||||
// Output: 500 * 0.000015 = 0.0075
|
||||
// Cache creation: 200 * 0.00000375 = 0.00075
|
||||
// Cache read: 300 * 0.0000003 = 0.00009
|
||||
// Total: 0.01134
|
||||
expect(metrics.costUsd).toBeCloseTo(0.01134, 6);
|
||||
});
|
||||
|
||||
it('should return 0 cost when no model is specified', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 cost when model pricing not found', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'unknown-model',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tiered Pricing', () => {
|
||||
it('should use base rates for tokens below 200k threshold', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 100_000,
|
||||
output_tokens: 50_000,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Input: 100000 * 0.000003 = 0.3
|
||||
// Output: 50000 * 0.000015 = 0.75
|
||||
// Total: 1.05
|
||||
expect(metrics.costUsd).toBeCloseTo(1.05, 6);
|
||||
});
|
||||
|
||||
it('should use tiered rates for input tokens above 200k threshold', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 250_000,
|
||||
output_tokens: 1_000,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Input: (200000 * 0.000003) + (50000 * 0.000006) = 0.6 + 0.3 = 0.9
|
||||
// Output: 1000 * 0.000015 = 0.015
|
||||
// Total: 0.915
|
||||
expect(metrics.costUsd).toBeCloseTo(0.915, 6);
|
||||
});
|
||||
|
||||
it('should use tiered rates for output tokens above 200k threshold', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1_000,
|
||||
output_tokens: 250_000,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Input: 1000 * 0.000003 = 0.003
|
||||
// Output: (200000 * 0.000015) + (50000 * 0.00003) = 3.0 + 1.5 = 4.5
|
||||
// Total: 4.503
|
||||
expect(metrics.costUsd).toBeCloseTo(4.503, 6);
|
||||
});
|
||||
|
||||
it('should use tiered rates for cache tokens above 200k threshold', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1_000,
|
||||
output_tokens: 1_000,
|
||||
cache_creation_input_tokens: 250_000,
|
||||
cache_read_input_tokens: 250_000,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Input: 1000 * 0.000003 = 0.003
|
||||
// Output: 1000 * 0.000015 = 0.015
|
||||
// Cache creation: (200000 * 0.00000375) + (50000 * 0.0000075) = 0.75 + 0.375 = 1.125
|
||||
// Cache read: (200000 * 0.0000003) + (50000 * 0.0000006) = 0.06 + 0.03 = 0.09
|
||||
// Total: 1.233
|
||||
expect(metrics.costUsd).toBeCloseTo(1.233, 6);
|
||||
});
|
||||
|
||||
it('should handle model without tiered pricing', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-opus-20240229',
|
||||
usage: {
|
||||
input_tokens: 250_000,
|
||||
output_tokens: 250_000,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// No tiered rates, so use base rates even above 200k
|
||||
// Input: 250000 * 0.000015 = 3.75
|
||||
// Output: 250000 * 0.000075 = 18.75
|
||||
// Total: 22.5
|
||||
expect(metrics.costUsd).toBeCloseTo(22.5, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Messages', () => {
|
||||
it('should aggregate costs across multiple messages', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-2',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 2000,
|
||||
output_tokens: 1000,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Message 1: (1000 * 0.000003) + (500 * 0.000015) = 0.0105
|
||||
// Message 2: (2000 * 0.000003) + (1000 * 0.000015) = 0.021
|
||||
// Total: 0.0315
|
||||
expect(metrics.costUsd).toBeCloseTo(0.0315, 6);
|
||||
});
|
||||
|
||||
it("should calculate cost per-message using each message's model", () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-2',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-opus-20240229', // Different model
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Each message uses its own model's pricing
|
||||
// Message 1 (sonnet): (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105
|
||||
// Message 2 (opus): (1000 * 0.000015) + (500 * 0.000075) = 0.015 + 0.0375 = 0.0525
|
||||
// Total cost: 0.0105 + 0.0525 = 0.063
|
||||
expect(metrics.costUsd).toBeCloseTo(0.063, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero tokens', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle messages without usage data', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty messages array', () => {
|
||||
const messages: ParsedMessage[] = [];
|
||||
const metrics = calculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle pricing data load failure gracefully', async () => {
|
||||
// Suppress expected console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Reset modules to clear the pricing cache
|
||||
vi.resetModules();
|
||||
|
||||
// Mock fs to throw error BEFORE importing calculateMetrics
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
// Re-import calculateMetrics to get fresh instance with cleared cache
|
||||
const { calculateMetrics: freshCalculateMetrics } = await import('@main/utils/jsonl');
|
||||
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = freshCalculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBe(0);
|
||||
|
||||
// Verify that console.error was called (error was logged)
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Name Lookup', () => {
|
||||
it('should find model with exact match', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should find model with case-insensitive match', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'CLAUDE-3-5-SONNET-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
expect(metrics.costUsd).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Per-Message Tiering', () => {
|
||||
it('should apply tiered pricing per-message, not to aggregated totals', () => {
|
||||
// Scenario: Many messages each with cache_read tokens < 200k,
|
||||
// but aggregated total > 200k
|
||||
// Each message should use base rates, not tiered rates
|
||||
const messages: ParsedMessage[] = [];
|
||||
|
||||
// Create 10 messages, each with 50k cache_read tokens (500k total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
messages.push({
|
||||
type: 'assistant',
|
||||
uuid: `msg-${i}`,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 50000,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Per-message tiering: Each message uses base rate (< 200k threshold)
|
||||
// Each message: 50,000 * 0.0000003 = $0.015
|
||||
// Total: 10 * $0.015 = $0.15
|
||||
const expectedCost = 10 * 50000 * 0.0000003;
|
||||
expect(metrics.costUsd).toBeCloseTo(expectedCost, 6);
|
||||
|
||||
// Verify this is NOT using tiered rate on aggregated total
|
||||
// If incorrectly aggregated: (200k * 0.0000003) + (300k * 0.0000006) = $0.24
|
||||
const incorrectAggregatedCost = 0.24;
|
||||
expect(metrics.costUsd).not.toBeCloseTo(incorrectAggregatedCost, 2);
|
||||
});
|
||||
|
||||
it('should apply tiered rates when individual messages exceed 200k', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 300000, // Exceeds 200k threshold
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Single message with 300k cache_read tokens
|
||||
// First 200k: 200,000 * 0.0000003 = $0.06
|
||||
// Remaining 100k: 100,000 * 0.0000006 = $0.06
|
||||
// Total: $0.12
|
||||
const expectedCost = 200000 * 0.0000003 + 100000 * 0.0000006;
|
||||
expect(metrics.costUsd).toBeCloseTo(expectedCost, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with Other Metrics', () => {
|
||||
it('should include cost alongside other session metrics', () => {
|
||||
const messages: ParsedMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-1',
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// Check that all expected metrics are present
|
||||
expect(metrics).toHaveProperty('totalTokens');
|
||||
expect(metrics).toHaveProperty('inputTokens');
|
||||
expect(metrics).toHaveProperty('outputTokens');
|
||||
expect(metrics).toHaveProperty('costUsd');
|
||||
expect(metrics.totalTokens).toBe(1500);
|
||||
expect(metrics.inputTokens).toBe(1000);
|
||||
expect(metrics.outputTokens).toBe(500);
|
||||
expect(metrics.costUsd).toBeCloseTo(0.0105, 6);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/shared/utils/costFormatting.test.ts
Normal file
228
test/shared/utils/costFormatting.test.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Tests for cost formatting utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatCostUsd, formatCostCompact } from '@shared/utils/costFormatting';
|
||||
|
||||
describe('Cost Formatting', () => {
|
||||
describe('formatCostUsd', () => {
|
||||
describe('Zero values', () => {
|
||||
it('should format zero as $0.00', () => {
|
||||
expect(formatCostUsd(0)).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('should format negative zero as $0.00', () => {
|
||||
expect(formatCostUsd(-0)).toBe('$0.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard amounts (>= $0.01)', () => {
|
||||
it('should format 1 cent with 2 decimal places', () => {
|
||||
expect(formatCostUsd(0.01)).toBe('$0.01');
|
||||
});
|
||||
|
||||
it('should format 1 dollar with 2 decimal places', () => {
|
||||
expect(formatCostUsd(1.0)).toBe('$1.00');
|
||||
});
|
||||
|
||||
it('should format dollars and cents', () => {
|
||||
expect(formatCostUsd(1.23)).toBe('$1.23');
|
||||
});
|
||||
|
||||
it('should format large amounts', () => {
|
||||
expect(formatCostUsd(999.99)).toBe('$999.99');
|
||||
expect(formatCostUsd(1234.56)).toBe('$1234.56');
|
||||
});
|
||||
|
||||
it('should round to 2 decimal places for amounts >= 1 cent', () => {
|
||||
expect(formatCostUsd(1.234)).toBe('$1.23');
|
||||
expect(formatCostUsd(1.235)).toBe('$1.24'); // Rounds up
|
||||
expect(formatCostUsd(1.999)).toBe('$2.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub-cent amounts ($0.001 - $0.01)', () => {
|
||||
it('should format 1 tenth of a cent with 3 decimal places', () => {
|
||||
expect(formatCostUsd(0.001)).toBe('$0.001');
|
||||
});
|
||||
|
||||
it('should format sub-cent amounts with 3 decimal places', () => {
|
||||
expect(formatCostUsd(0.005)).toBe('$0.005');
|
||||
expect(formatCostUsd(0.009)).toBe('$0.009');
|
||||
});
|
||||
|
||||
it('should round to 3 decimal places for sub-cent amounts', () => {
|
||||
expect(formatCostUsd(0.0012)).toBe('$0.001');
|
||||
expect(formatCostUsd(0.0015)).toBe('$0.002'); // Rounds up
|
||||
expect(formatCostUsd(0.0099)).toBe('$0.010');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very small amounts (< $0.001)', () => {
|
||||
it('should format tiny amounts with 4 decimal places', () => {
|
||||
expect(formatCostUsd(0.0001)).toBe('$0.0001');
|
||||
expect(formatCostUsd(0.0005)).toBe('$0.0005');
|
||||
expect(formatCostUsd(0.0009)).toBe('$0.0009');
|
||||
});
|
||||
|
||||
it('should round to 4 decimal places for tiny amounts', () => {
|
||||
expect(formatCostUsd(0.00012)).toBe('$0.0001');
|
||||
expect(formatCostUsd(0.00016)).toBe('$0.0002'); // Rounds up
|
||||
expect(formatCostUsd(0.00099)).toBe('$0.0010');
|
||||
});
|
||||
|
||||
it('should handle very tiny amounts', () => {
|
||||
expect(formatCostUsd(0.000001)).toBe('$0.0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle negative amounts with 4 decimal places', () => {
|
||||
// Negative numbers don't match >= comparisons, so they use 4 decimals
|
||||
expect(formatCostUsd(-1.23)).toBe('$-1.2300');
|
||||
expect(formatCostUsd(-0.001)).toBe('$-0.0010');
|
||||
expect(formatCostUsd(-0.0001)).toBe('$-0.0001');
|
||||
});
|
||||
|
||||
it('should handle very large amounts', () => {
|
||||
expect(formatCostUsd(1000000)).toBe('$1000000.00');
|
||||
});
|
||||
|
||||
it('should handle precision boundaries', () => {
|
||||
// Boundary between 2 and 3 decimal places
|
||||
expect(formatCostUsd(0.01)).toBe('$0.01');
|
||||
expect(formatCostUsd(0.00999)).toBe('$0.010'); // Just below threshold, uses 3 decimals
|
||||
|
||||
// Boundary between 3 and 4 decimal places
|
||||
expect(formatCostUsd(0.001)).toBe('$0.001');
|
||||
expect(formatCostUsd(0.00099)).toBe('$0.0010'); // Just below threshold, uses 4 decimals
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world API cost examples', () => {
|
||||
it('should format typical Claude API costs', () => {
|
||||
// 1M input tokens at $3.00/M
|
||||
expect(formatCostUsd(3.0)).toBe('$3.00');
|
||||
|
||||
// 100k input tokens at $3.00/M
|
||||
expect(formatCostUsd(0.3)).toBe('$0.30');
|
||||
|
||||
// 10k cache read tokens at $0.30/M
|
||||
expect(formatCostUsd(0.003)).toBe('$0.003');
|
||||
|
||||
// 1k cache read tokens at $0.30/M
|
||||
expect(formatCostUsd(0.0003)).toBe('$0.0003');
|
||||
});
|
||||
|
||||
it('should format session totals', () => {
|
||||
// Small session
|
||||
expect(formatCostUsd(0.15)).toBe('$0.15');
|
||||
|
||||
// Medium session
|
||||
expect(formatCostUsd(5.67)).toBe('$5.67');
|
||||
|
||||
// Large session
|
||||
expect(formatCostUsd(29.57)).toBe('$29.57');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCostCompact', () => {
|
||||
describe('Zero values', () => {
|
||||
it('should format zero as 0.00', () => {
|
||||
expect(formatCostCompact(0)).toBe('0.00');
|
||||
});
|
||||
|
||||
it('should format negative zero as 0.00', () => {
|
||||
expect(formatCostCompact(-0)).toBe('0.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard amounts (>= $0.01)', () => {
|
||||
it('should format amounts without $ prefix', () => {
|
||||
expect(formatCostCompact(0.01)).toBe('0.01');
|
||||
expect(formatCostCompact(1.0)).toBe('1.00');
|
||||
expect(formatCostCompact(1.23)).toBe('1.23');
|
||||
});
|
||||
|
||||
it('should format large amounts', () => {
|
||||
expect(formatCostCompact(999.99)).toBe('999.99');
|
||||
expect(formatCostCompact(1234.56)).toBe('1234.56');
|
||||
});
|
||||
|
||||
it('should round to 2 decimal places', () => {
|
||||
expect(formatCostCompact(1.234)).toBe('1.23');
|
||||
expect(formatCostCompact(1.235)).toBe('1.24'); // Rounds up
|
||||
expect(formatCostCompact(1.999)).toBe('2.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub-cent amounts ($0.001 - $0.01)', () => {
|
||||
it('should format sub-cent amounts with 3 decimal places', () => {
|
||||
expect(formatCostCompact(0.001)).toBe('0.001');
|
||||
expect(formatCostCompact(0.005)).toBe('0.005');
|
||||
expect(formatCostCompact(0.009)).toBe('0.009');
|
||||
});
|
||||
|
||||
it('should round to 3 decimal places', () => {
|
||||
expect(formatCostCompact(0.0012)).toBe('0.001');
|
||||
expect(formatCostCompact(0.0015)).toBe('0.002'); // Rounds up
|
||||
expect(formatCostCompact(0.0099)).toBe('0.010');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very small amounts (< $0.001)', () => {
|
||||
it('should format tiny amounts with 4 decimal places', () => {
|
||||
expect(formatCostCompact(0.0001)).toBe('0.0001');
|
||||
expect(formatCostCompact(0.0005)).toBe('0.0005');
|
||||
expect(formatCostCompact(0.0009)).toBe('0.0009');
|
||||
});
|
||||
|
||||
it('should round to 4 decimal places', () => {
|
||||
expect(formatCostCompact(0.00012)).toBe('0.0001');
|
||||
expect(formatCostCompact(0.00016)).toBe('0.0002'); // Rounds up
|
||||
expect(formatCostCompact(0.00099)).toBe('0.0010');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle negative amounts with 4 decimal places', () => {
|
||||
// Negative numbers don't match >= comparisons, so they use 4 decimals
|
||||
expect(formatCostCompact(-1.23)).toBe('-1.2300');
|
||||
expect(formatCostCompact(-0.001)).toBe('-0.0010');
|
||||
expect(formatCostCompact(-0.0001)).toBe('-0.0001');
|
||||
});
|
||||
|
||||
it('should handle very large amounts', () => {
|
||||
expect(formatCostCompact(1000000)).toBe('1000000.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comparison with formatCostUsd', () => {
|
||||
it('should match formatCostUsd except for $ prefix', () => {
|
||||
const testCases = [0, 0.0001, 0.001, 0.01, 1.23, 999.99];
|
||||
|
||||
testCases.forEach((cost) => {
|
||||
const withPrefix = formatCostUsd(cost);
|
||||
const compact = formatCostCompact(cost);
|
||||
|
||||
// Compact should equal the USD format without the $
|
||||
expect(compact).toBe(withPrefix.substring(1));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Badge display use cases', () => {
|
||||
it('should format for badge display', () => {
|
||||
// Small per-message costs
|
||||
expect(formatCostCompact(0.0015)).toBe('0.002');
|
||||
expect(formatCostCompact(0.01)).toBe('0.01');
|
||||
|
||||
// Session totals in badges
|
||||
expect(formatCostCompact(2.5)).toBe('2.50');
|
||||
expect(formatCostCompact(15.0)).toBe('15.00');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue