45d59be201
Co-authored-by: Douwe Osinga <douwe@squareup.com>
233 lines
7.8 KiB
TypeScript
233 lines
7.8 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useModelAndProvider } from '../ModelAndProviderContext';
|
|
import { CoinIcon } from '../icons';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
|
import { fetchCanonicalModelInfo } from '../../utils/canonical';
|
|
import type { ModelInfoData } from '../../api';
|
|
|
|
interface CostTrackerProps {
|
|
inputTokens?: number;
|
|
outputTokens?: number;
|
|
sessionCosts?: {
|
|
[key: string]: {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
totalCost: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: CostTrackerProps) {
|
|
const { currentModel, currentProvider } = useModelAndProvider();
|
|
const [costInfo, setCostInfo] = useState<ModelInfoData | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [showPricing, setShowPricing] = useState(true);
|
|
const [pricingFailed, setPricingFailed] = useState(false);
|
|
|
|
// Check if pricing is enabled
|
|
useEffect(() => {
|
|
const loadPricingSetting = async () => {
|
|
const enabled = await window.electron.getSetting('showPricing');
|
|
setShowPricing(enabled);
|
|
};
|
|
|
|
loadPricingSetting();
|
|
|
|
const handlePricingChange = () => {
|
|
loadPricingSetting();
|
|
};
|
|
|
|
window.addEventListener('showPricingChanged', handlePricingChange);
|
|
return () => window.removeEventListener('showPricingChanged', handlePricingChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const loadCostInfo = async () => {
|
|
if (!currentModel || !currentProvider) {
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const costData = await fetchCanonicalModelInfo(currentProvider, currentModel);
|
|
if (costData) {
|
|
setCostInfo(costData);
|
|
setPricingFailed(false);
|
|
} else {
|
|
setPricingFailed(true);
|
|
setCostInfo(null);
|
|
}
|
|
} catch {
|
|
setPricingFailed(true);
|
|
setCostInfo(null);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadCostInfo();
|
|
}, [currentModel, currentProvider]);
|
|
|
|
// Return null early if pricing is disabled
|
|
if (!showPricing) {
|
|
return null;
|
|
}
|
|
|
|
const calculateCost = (): number => {
|
|
// If we have session costs, calculate the total across all models
|
|
if (sessionCosts) {
|
|
let totalCost = 0;
|
|
|
|
// Add up all historical costs from different models
|
|
Object.values(sessionCosts).forEach((modelCost) => {
|
|
totalCost += modelCost.totalCost;
|
|
});
|
|
|
|
// Add current model cost if we have pricing info
|
|
if (
|
|
costInfo &&
|
|
(costInfo.input_token_cost !== undefined || costInfo.output_token_cost !== undefined)
|
|
) {
|
|
const currentInputCost = (inputTokens * (costInfo.input_token_cost || 0)) / 1_000_000;
|
|
const currentOutputCost = (outputTokens * (costInfo.output_token_cost || 0)) / 1_000_000;
|
|
totalCost += currentInputCost + currentOutputCost;
|
|
}
|
|
|
|
return totalCost;
|
|
}
|
|
|
|
// Fallback to simple calculation for current model only
|
|
if (
|
|
!costInfo ||
|
|
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined)
|
|
) {
|
|
return 0;
|
|
}
|
|
|
|
const inputCost = (inputTokens * (costInfo.input_token_cost || 0)) / 1_000_000;
|
|
const outputCost = (outputTokens * (costInfo.output_token_cost || 0)) / 1_000_000;
|
|
const total = inputCost + outputCost;
|
|
|
|
return total;
|
|
};
|
|
|
|
const formatCost = (cost: number): string => {
|
|
// Always show 4 decimal places for consistency
|
|
return cost.toFixed(4);
|
|
};
|
|
|
|
// Show loading state or when we don't have model/provider info
|
|
if (!currentModel || !currentProvider) {
|
|
return null;
|
|
}
|
|
|
|
// If still loading, show a placeholder
|
|
if (isLoading) {
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-center h-full text-text-muted translate-y-[1px]">
|
|
<span className="text-xs font-mono">...</span>
|
|
</div>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// If no cost info found, try to return a default
|
|
if (
|
|
!costInfo ||
|
|
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined)
|
|
) {
|
|
const freeProviders = ['ollama', 'local', 'localhost'];
|
|
if (freeProviders.includes(currentProvider.toLowerCase())) {
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-center h-full text-text-default/70 transition-colors cursor-default translate-y-[1px]">
|
|
<span className="text-xs font-mono">
|
|
{inputTokens.toLocaleString()}↑ {outputTokens.toLocaleString()}↓
|
|
</span>
|
|
</div>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Otherwise show as unavailable
|
|
const getUnavailableTooltip = () => {
|
|
if (pricingFailed) {
|
|
return `Pricing data unavailable for ${currentModel}`;
|
|
}
|
|
return `Cost data not available for ${currentModel} (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
|
<CoinIcon className="mr-1" size={16} />
|
|
<span className="text-xs font-mono">0.0000</span>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{getUnavailableTooltip()}</TooltipContent>
|
|
</Tooltip>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
const totalCost = calculateCost();
|
|
|
|
// Build tooltip content
|
|
const getTooltipContent = (): string => {
|
|
// Handle error states first
|
|
if (pricingFailed) {
|
|
return `Pricing data unavailable for ${currentProvider}/${currentModel}`;
|
|
}
|
|
|
|
// Handle session costs
|
|
if (sessionCosts && Object.keys(sessionCosts).length > 0) {
|
|
// Show session breakdown
|
|
let tooltip = 'Session cost breakdown:\n';
|
|
|
|
Object.entries(sessionCosts).forEach(([modelKey, cost]) => {
|
|
const costStr = `${costInfo?.currency || '$'}${cost.totalCost.toFixed(6)}`;
|
|
tooltip += `${modelKey}: ${costStr} (${cost.inputTokens.toLocaleString()} in, ${cost.outputTokens.toLocaleString()} out)\n`;
|
|
});
|
|
|
|
// Add current model if it has costs
|
|
if (costInfo && (inputTokens > 0 || outputTokens > 0)) {
|
|
const currentCost =
|
|
(inputTokens * (costInfo.input_token_cost || 0) +
|
|
outputTokens * (costInfo.output_token_cost || 0)) /
|
|
1_000_000;
|
|
if (currentCost > 0) {
|
|
tooltip += `${currentProvider}/${currentModel} (current): ${costInfo.currency || '$'}${currentCost.toFixed(6)} (${inputTokens.toLocaleString()} in, ${outputTokens.toLocaleString()} out)\n`;
|
|
}
|
|
}
|
|
|
|
tooltip += `\nTotal session cost: ${costInfo?.currency || '$'}${totalCost.toFixed(6)}`;
|
|
return tooltip;
|
|
}
|
|
|
|
// Default tooltip for single model
|
|
return `Input: ${inputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}) | Output: ${outputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)})`;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
|
<CoinIcon className="mr-1" size={16} />
|
|
<span className="text-xs font-mono">{formatCost(totalCost)}</span>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{getTooltipContent()}</TooltipContent>
|
|
</Tooltip>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
</>
|
|
);
|
|
}
|