feat: add real-time conversation cost tracking with proper token counting

- Add comprehensive ModelPricing service with 100+ models and historical pricing
- Create real-time ConversationCost component that displays in chat header
- Use actual token counts from model APIs instead of client-side estimation
- Fix BaseClient.js to preserve tokenCount in response messages
- Add tokenCount, usage, and tokens fields to message schema
- Update Header component to include ConversationCost display
- Support OpenAI, Anthropic, Google, and other major model providers
- Include color-coded cost display based on amount
- Add 32 unit tests for pricing calculation logic

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
constanttime 2025-08-17 20:13:49 +05:30
parent 543b617e1c
commit 3edf6fdf6b
9 changed files with 2041 additions and 1 deletions

View file

@ -0,0 +1,63 @@
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Constants } from 'librechat-data-provider';
import { useQuery } from '@tanstack/react-query';
type CostDisplay = {
totalCost: string;
totalCostRaw: number;
primaryModel: string;
totalTokens: number;
lastUpdated: string | number | Date;
conversationId?: string;
};
export default function ConversationCost() {
const { t } = useTranslation();
const { conversationId } = useParams();
const { data } = useQuery<CostDisplay | null>({
queryKey: ['conversationCost', conversationId],
enabled: Boolean(conversationId && conversationId !== Constants.NEW_CONVO),
queryFn: async () => {
const res = await fetch(`/api/convos/${conversationId}/cost`, { credentials: 'include' });
if (!res.ok) {
return null;
}
return res.json();
},
staleTime: 5_000,
refetchOnWindowFocus: false,
});
const colorClass = useMemo(() => {
const cost = data?.totalCostRaw ?? 0;
if (cost < 0.01) return 'text-green-600 dark:text-green-400';
if (cost < 0.1) return 'text-yellow-600 dark:text-yellow-400';
if (cost < 1) return 'text-orange-600 dark:text-orange-400';
return 'text-red-600 dark:text-red-400';
}, [data?.totalCostRaw]);
if (!conversationId || conversationId === Constants.NEW_CONVO) {
return null;
}
if (!data || data.totalCostRaw === 0) {
return (
<div className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-400" title={t('com_ui_conversation_cost')}>
<span>💰</span>
<span>$0.00</span>
</div>
);
}
const tooltipText = `${t('com_ui_conversation_cost')}: ${data.totalCost} | ${t('com_ui_primary_model')}: ${data.primaryModel} | ${t('com_ui_total_tokens')}: ${data.totalTokens.toLocaleString()} | ${t('com_ui_last_updated')}: ${new Date(data.lastUpdated).toLocaleTimeString()}`;
return (
<div className="flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors hover:bg-surface-hover" title={tooltipText}>
<span className="text-text-tertiary">💰</span>
<span className={`font-medium ${colorClass}`}>{data.totalCost}</span>
</div>
);
}

View file

@ -9,6 +9,7 @@ import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import ConversationCost from './ConversationCost';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
@ -62,6 +63,7 @@ export default function Header() {
{hasAccessToMultiConvo === true && <AddMultiConvo />}
{isSmallScreen && (
<>
<ConversationCost />
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
@ -72,6 +74,7 @@ export default function Header() {
</div>
{!isSmallScreen && (
<div className="flex items-center gap-2">
<ConversationCost />
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>

View file

@ -1242,4 +1242,9 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You"
,
"com_ui_conversation_cost": "Conversation cost",
"com_ui_last_updated": "Last updated",
"com_ui_primary_model": "Model",
"com_ui_total_tokens": "Tokens"
}