feat: add session cost in session header
This commit is contained in:
parent
2bb95db596
commit
723760938e
8 changed files with 379 additions and 42 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -46,4 +46,4 @@ temp/
|
||||||
|
|
||||||
|
|
||||||
eslint-fix/
|
eslint-fix/
|
||||||
remotion/*
|
remotion/*
|
||||||
|
|
@ -351,47 +351,58 @@ 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) {
|
for (const msg of messages) {
|
||||||
if (msg.usage) {
|
if (msg.usage) {
|
||||||
inputTokens += msg.usage.input_tokens ?? 0;
|
const msgInputTokens = msg.usage.input_tokens ?? 0;
|
||||||
outputTokens += msg.usage.output_tokens ?? 0;
|
const msgOutputTokens = msg.usage.output_tokens ?? 0;
|
||||||
cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
|
const msgCacheReadTokens = msg.usage.cache_read_input_tokens ?? 0;
|
||||||
cacheCreationTokens += msg.usage.cache_creation_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!modelName && msg.model) {
|
if (!modelName && msg.model) {
|
||||||
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 {
|
return {
|
||||||
durationMs: maxTime - minTime,
|
durationMs: maxTime - minTime,
|
||||||
totalTokens: inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens,
|
totalTokens: inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens,
|
||||||
|
|
|
||||||
|
|
@ -824,6 +824,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
onNavigateToTool={handleNavigateToTool}
|
onNavigateToTool={handleNavigateToTool}
|
||||||
onNavigateToUserGroup={handleNavigateToUserGroup}
|
onNavigateToUserGroup={handleNavigateToUserGroup}
|
||||||
totalSessionTokens={lastAiGroupTotalTokens}
|
totalSessionTokens={lastAiGroupTotalTokens}
|
||||||
|
sessionMetrics={sessionDetail?.metrics}
|
||||||
phaseInfo={sessionPhaseInfo ?? undefined}
|
phaseInfo={sessionPhaseInfo ?? undefined}
|
||||||
selectedPhase={selectedContextPhase}
|
selectedPhase={selectedContextPhase}
|
||||||
onPhaseChange={setSelectedContextPhase}
|
onPhaseChange={setSelectedContextPhase}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
COLOR_TEXT_MUTED,
|
COLOR_TEXT_MUTED,
|
||||||
COLOR_TEXT_SECONDARY,
|
COLOR_TEXT_SECONDARY,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
|
import { formatCostUsd } from '@shared/utils/costFormatting';
|
||||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||||
|
|
||||||
import { formatTokens } from '../utils/formatting';
|
import { formatTokens } from '../utils/formatting';
|
||||||
|
|
@ -19,12 +20,14 @@ import { formatTokens } from '../utils/formatting';
|
||||||
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
||||||
|
|
||||||
import type { ContextViewMode } from '../types';
|
import type { ContextViewMode } from '../types';
|
||||||
|
import type { SessionMetrics } from '@main/types';
|
||||||
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||||
|
|
||||||
interface SessionContextHeaderProps {
|
interface SessionContextHeaderProps {
|
||||||
injectionCount: number;
|
injectionCount: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
totalSessionTokens?: number;
|
totalSessionTokens?: number;
|
||||||
|
sessionMetrics?: SessionMetrics;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
phaseInfo?: ContextPhaseInfo;
|
phaseInfo?: ContextPhaseInfo;
|
||||||
selectedPhase: number | null;
|
selectedPhase: number | null;
|
||||||
|
|
@ -37,6 +40,7 @@ export const SessionContextHeader = ({
|
||||||
injectionCount,
|
injectionCount,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
totalSessionTokens,
|
totalSessionTokens,
|
||||||
|
sessionMetrics,
|
||||||
onClose,
|
onClose,
|
||||||
phaseInfo,
|
phaseInfo,
|
||||||
selectedPhase,
|
selectedPhase,
|
||||||
|
|
@ -115,6 +119,24 @@ export const SessionContextHeader = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Phase selector - only shown when compactions exist */}
|
||||||
{phaseInfo && phaseInfo.phases.length > 1 && (
|
{phaseInfo && phaseInfo.phases.length > 1 && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export const SessionContextPanel = ({
|
||||||
onNavigateToTool,
|
onNavigateToTool,
|
||||||
onNavigateToUserGroup,
|
onNavigateToUserGroup,
|
||||||
totalSessionTokens,
|
totalSessionTokens,
|
||||||
|
sessionMetrics,
|
||||||
phaseInfo,
|
phaseInfo,
|
||||||
selectedPhase,
|
selectedPhase,
|
||||||
onPhaseChange,
|
onPhaseChange,
|
||||||
|
|
@ -190,6 +191,7 @@ export const SessionContextPanel = ({
|
||||||
injectionCount={injections.length}
|
injectionCount={injections.length}
|
||||||
totalTokens={totalTokens}
|
totalTokens={totalTokens}
|
||||||
totalSessionTokens={totalSessionTokens}
|
totalSessionTokens={totalSessionTokens}
|
||||||
|
sessionMetrics={sessionMetrics}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
phaseInfo={phaseInfo}
|
phaseInfo={phaseInfo}
|
||||||
selectedPhase={selectedPhase}
|
selectedPhase={selectedPhase}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
* Type definitions for SessionContextPanel components.
|
* Type definitions for SessionContextPanel components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ClaudeMdSource } from '@renderer/types/claudeMd';
|
|
||||||
import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection';
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Props Interface
|
// Props Interface
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
import type { SessionMetrics } from '@main/types';
|
||||||
|
import type { ClaudeMdSource } from '@renderer/types/claudeMd';
|
||||||
|
import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||||
|
|
||||||
export interface SessionContextPanelProps {
|
export interface SessionContextPanelProps {
|
||||||
/** All accumulated context injections */
|
/** All accumulated context injections */
|
||||||
|
|
@ -24,6 +24,8 @@ export interface SessionContextPanelProps {
|
||||||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||||
/** Total session tokens (input + output + cache) for comparison */
|
/** Total session tokens (input + output + cache) for comparison */
|
||||||
totalSessionTokens?: number;
|
totalSessionTokens?: number;
|
||||||
|
/** Full session metrics (input, output, cache tokens, cost) */
|
||||||
|
sessionMetrics?: SessionMetrics;
|
||||||
/** Phase information for phase selector */
|
/** Phase information for phase selector */
|
||||||
phaseInfo?: ContextPhaseInfo;
|
phaseInfo?: ContextPhaseInfo;
|
||||||
/** Currently selected phase (null = current/latest) */
|
/** Currently selected phase (null = current/latest) */
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,7 @@ describe('Cost Calculation', () => {
|
||||||
expect(metrics.costUsd).toBeCloseTo(0.0315, 6);
|
expect(metrics.costUsd).toBeCloseTo(0.0315, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use first model found when calculating aggregated cost', () => {
|
it("should calculate cost per-message using each message's model", () => {
|
||||||
const messages: ParsedMessage[] = [
|
const messages: ParsedMessage[] = [
|
||||||
{
|
{
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
|
|
@ -351,10 +351,11 @@ describe('Cost Calculation', () => {
|
||||||
|
|
||||||
const metrics = calculateMetrics(messages);
|
const metrics = calculateMetrics(messages);
|
||||||
|
|
||||||
// Uses first model (sonnet) pricing for all tokens
|
// Each message uses its own model's pricing
|
||||||
// Total tokens: 2000 input, 1000 output
|
// Message 1 (sonnet): (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105
|
||||||
// Cost: (2000 * 0.000003) + (1000 * 0.000015) = 0.006 + 0.015 = 0.021
|
// Message 2 (opus): (1000 * 0.000015) + (500 * 0.000075) = 0.015 + 0.0375 = 0.0525
|
||||||
expect(metrics.costUsd).toBeCloseTo(0.021, 6);
|
// Total cost: 0.0105 + 0.0525 = 0.063
|
||||||
|
expect(metrics.costUsd).toBeCloseTo(0.063, 6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -494,6 +495,76 @@ describe('Cost Calculation', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('Integration with Other Metrics', () => {
|
||||||
it('should include cost alongside other session metrics', () => {
|
it('should include cost alongside other session metrics', () => {
|
||||||
const messages: ParsedMessage[] = [
|
const messages: ParsedMessage[] = [
|
||||||
|
|
|
||||||
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