From 21015cf57d73bdf39bf98a5a372dfed614edf6c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:49:36 -0400 Subject: [PATCH] feat(cost): add usage spend insights (#3461) --- .../dashboard/CostDashboardPanel.test.tsx | 103 +++++-- .../dashboard/CostDashboardPanel.tsx | 270 +++++++++++++++++- app/src/hooks/useCostDashboard.ts | 118 +++++++- app/src/lib/i18n/ar.ts | 12 + app/src/lib/i18n/bn.ts | 12 + app/src/lib/i18n/de.ts | 14 + app/src/lib/i18n/en.ts | 13 + app/src/lib/i18n/es.ts | 13 + app/src/lib/i18n/fr.ts | 14 + app/src/lib/i18n/hi.ts | 13 + app/src/lib/i18n/id.ts | 13 + app/src/lib/i18n/it.ts | 13 + app/src/lib/i18n/ko.ts | 13 + app/src/lib/i18n/pl.ts | 13 + app/src/lib/i18n/pt.ts | 13 + app/src/lib/i18n/ru.ts | 13 + app/src/lib/i18n/zh-CN.ts | 12 + src/openhuman/cost/rpc.rs | 215 +++++++++++++- src/openhuman/cost/schemas.rs | 102 ++++++- src/openhuman/cost/tracker.rs | 36 +++ 20 files changed, 977 insertions(+), 48 deletions(-) diff --git a/app/src/components/dashboard/CostDashboardPanel.test.tsx b/app/src/components/dashboard/CostDashboardPanel.test.tsx index 69c1a5fc0..0e967ec7e 100644 --- a/app/src/components/dashboard/CostDashboardPanel.test.tsx +++ b/app/src/components/dashboard/CostDashboardPanel.test.tsx @@ -48,6 +48,38 @@ function renderPanel() { ); } +const usageLogPayload = { + records: [ + { + id: 'record-1', + timestamp: '2026-05-27T12:00:00Z', + session_id: 'session-abcdef', + model: 'anthropic/claude-sonnet-4', + provider: 'anthropic', + category: 'AI chat and reasoning', + input_tokens: 1000, + output_tokens: 500, + total_tokens: 1500, + cost_usd: 1.25, + }, + ], + by_category: [ + { + category: 'AI chat and reasoning', + cost_usd: 1.25, + total_tokens: 1500, + request_count: 1, + percent_of_total: 100, + }, + ], + total_cost_usd: 1.25, + total_tokens: 1500, + request_count: 1, + currency: 'USD', + days: 30, + limit: 250, +}; + describe('', () => { beforeEach(() => { mockedCall.mockReset(); @@ -58,42 +90,51 @@ describe('', () => { }); it('shows the loading state and then renders all sections', async () => { - mockedCall.mockResolvedValueOnce({ - days: Array.from({ length: 7 }, (_, i) => ({ - date: `2026-05-${21 + i}`, - cost_usd: i === 6 ? 1.25 : 0, - input_tokens: i === 6 ? 1000 : 0, - output_tokens: i === 6 ? 500 : 0, - total_tokens: i === 6 ? 1500 : 0, - request_count: i === 6 ? 1 : 0, - by_model: [], - })), - period_total_usd: 1.25, - monthly_pace_usd: 5.36, - budget_limit_monthly_usd: 100, - month_to_date_usd: 1.25, - budget_utilization: 0.0125, - budget_status: 'normal', - currency: 'USD', - warn_threshold: 0.8, - alert_threshold: 0.95, - enabled: true, - by_model: [ - { - model: 'anthropic/claude-sonnet-4', - cost_usd: 1.25, - total_tokens: 1500, - request_count: 1, - provider: 'anthropic', - percent_of_total: 100, - }, - ], - }); + mockedCall + .mockResolvedValueOnce({ + days: Array.from({ length: 7 }, (_, i) => ({ + date: `2026-05-${21 + i}`, + cost_usd: i === 6 ? 1.25 : 0, + input_tokens: i === 6 ? 1000 : 0, + output_tokens: i === 6 ? 500 : 0, + total_tokens: i === 6 ? 1500 : 0, + request_count: i === 6 ? 1 : 0, + by_model: [], + })), + period_total_usd: 1.25, + monthly_pace_usd: 5.36, + budget_limit_monthly_usd: 100, + month_to_date_usd: 1.25, + budget_utilization: 0.0125, + budget_status: 'normal', + currency: 'USD', + warn_threshold: 0.8, + alert_threshold: 0.95, + enabled: true, + by_model: [ + { + model: 'anthropic/claude-sonnet-4', + cost_usd: 1.25, + total_tokens: 1500, + request_count: 1, + provider: 'anthropic', + percent_of_total: 100, + }, + ], + }) + .mockResolvedValueOnce(usageLogPayload); renderPanel(); await waitFor(() => expect(screen.getByTestId('cost-dashboard-summary')).toBeInTheDocument()); expect(screen.getByTestId('cost-dashboard-cost-chart')).toBeInTheDocument(); expect(screen.getByTestId('cost-dashboard-token-chart')).toBeInTheDocument(); expect(screen.getByTestId('cost-dashboard-model-table')).toBeInTheDocument(); + expect(screen.getByTestId('cost-dashboard-category-distribution')).toBeInTheDocument(); + expect(screen.getByTestId('cost-dashboard-usage-log')).toHaveTextContent( + 'AI chat and reasoning' + ); + expect(mockedCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'openhuman.cost_get_usage_log' }) + ); }); it('shows the disabled hint when the payload reports enabled=false', async () => { diff --git a/app/src/components/dashboard/CostDashboardPanel.tsx b/app/src/components/dashboard/CostDashboardPanel.tsx index 0811fd8cc..2a41b99a6 100644 --- a/app/src/components/dashboard/CostDashboardPanel.tsx +++ b/app/src/components/dashboard/CostDashboardPanel.tsx @@ -1,13 +1,18 @@ -import { useEffect, useMemo, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; -import { useCostDashboard } from '../../hooks/useCostDashboard'; +import { + type CostUsageCategoryStats, + type CostUsageRecord, + useCostDashboard, + useCostUsageLog, +} from '../../hooks/useCostDashboard'; import { useT } from '../../lib/i18n/I18nContext'; import SettingsHeader from '../settings/components/SettingsHeader'; import { useSettingsNavigation } from '../settings/hooks/useSettingsNavigation'; import BudgetSummary from './BudgetSummary'; import CostBarChart from './CostBarChart'; import DashboardSkeleton from './DashboardSkeleton'; -import { relativeTime } from './formatCurrency'; +import { formatCurrency, formatTokens, relativeTime } from './formatCurrency'; import ModelCostTable from './ModelCostTable'; import TokenUsageChart from './TokenUsageChart'; @@ -15,6 +20,14 @@ const CostDashboardPanel = () => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const { data, isLoading, isFetching, error, lastUpdated, refetch } = useCostDashboard(); + const { + data: usageLog, + isLoading: usageLogLoading, + isFetching: usageLogFetching, + error: usageLogError, + lastUpdated: usageLogUpdated, + refetch: refetchUsageLog, + } = useCostUsageLog({ days: 30, limit: 250 }); const hasAnyCost = useMemo( () => (data ? data.days.some(day => day.cost_usd > 0) : false), @@ -43,25 +56,27 @@ const CostDashboardPanel = () => { {t('settings.costDashboard.subtitle')}

- {lastUpdated !== null && ( + {(lastUpdated !== null || usageLogUpdated !== null) && ( - {`${t('settings.costDashboard.updated')} ${relativeTime(lastUpdated, t)}`} + {`${t('settings.costDashboard.updated')} ${relativeTime(Math.max(lastUpdated ?? 0, usageLogUpdated ?? 0), t)}`} )}
@@ -75,6 +90,14 @@ const CostDashboardPanel = () => { {error} )} + {usageLogError && ( +
+ {usageLogError} +
+ )} {data && !data.enabled && (
{ +
+
+

+ {t('settings.costDashboard.categoryDistribution')} +

+

+ {t('settings.costDashboard.categoryDistributionHint')} +

+
+ {usageLog ? ( + + ) : usageLogLoading ? ( +
+ {t('settings.costDashboard.loading')} +
+ ) : null} +
+
+
+
+

+ {t('settings.costDashboard.usageLog')} +

+

+ {usageLog + ? t('settings.costDashboard.usageLogHint') + .replace('{days}', String(usageLog.days)) + .replace('{limit}', String(usageLog.limit)) + : t('settings.costDashboard.usageLogHint') + .replace('{days}', '30') + .replace('{limit}', '250')} +

+
+ {usageLog && ( + + {t('settings.costDashboard.logTotal') + .replace('{requests}', String(usageLog.request_count)) + .replace( + '{cost}', + formatCurrency(usageLog.total_cost_usd, usageLog.currency) + )} + + )} +
+ {usageLog ? ( + + ) : usageLogLoading ? ( +
+ {t('settings.costDashboard.loading')} +
+ ) : null} +
{!hasAnyCost && (
{ ); }; +const CATEGORY_COLORS = [ + 'bg-ocean-500', + 'bg-sage-500', + 'bg-amber-500', + 'bg-coral-500', + 'bg-stone-500 dark:bg-neutral-400', +]; + +const CategoryDistribution = ({ + categories, + currency, +}: { + categories: CostUsageCategoryStats[]; + currency: string; +}) => { + const { t } = useT(); + if (categories.length === 0) { + return ( +
+ {t('settings.costDashboard.noCategories')} +
+ ); + } + + return ( +
+
+ {categories.map((category, index) => ( +
+ ))} +
+
+ {categories.map((category, index) => ( +
+
+
+ + + {category.category} + +
+ + {formatCurrency(category.cost_usd, currency)} + +
+
+ {`${category.percent_of_total.toFixed(1)}%`} + + {t('settings.costDashboard.categoryMeta') + .replace('{requests}', String(category.request_count)) + .replace('{tokens}', formatTokens(category.total_tokens))} + +
+
+ ))} +
+
+ ); +}; + +const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; currency: string }) => { + const { t } = useT(); + if (records.length === 0) { + return ( +
+ {t('settings.costDashboard.noUsageLog')} +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {records.map(record => ( + + + + + + + + + + ))} + +
{t('settings.costDashboard.when')}{t('settings.costDashboard.category')}{t('settings.costDashboard.model')}{t('settings.costDashboard.inputTokens')}{t('settings.costDashboard.outputTokens')}{t('settings.costDashboard.cost')}{t('settings.costDashboard.session')}
+
+ {formatDateTime(record.timestamp)} +
+
+ + {record.category} + + +
+ {record.model} +
+
+ {record.provider ?? t('settings.costDashboard.unknownProvider')} +
+
{formatTokens(record.input_tokens)}{formatTokens(record.output_tokens)} + + {formatCurrency(record.cost_usd, currency)} + + + + {shortId(record.session_id)} + +
+
+ ); +}; + +interface CellProps { + children: ReactNode; + align?: 'left' | 'right'; +} + +const Th = ({ children, align = 'left' }: CellProps) => ( + + {children} + +); + +const Td = ({ children, align = 'left' }: CellProps) => ( + + {children} + +); + +function formatDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(date); +} + +function shortId(value: string): string { + return value.length > 8 ? value.slice(0, 8) : value; +} + interface IconProps { className?: string; } diff --git a/app/src/hooks/useCostDashboard.ts b/app/src/hooks/useCostDashboard.ts index 9c63eb131..876ec5931 100644 --- a/app/src/hooks/useCostDashboard.ts +++ b/app/src/hooks/useCostDashboard.ts @@ -38,6 +38,38 @@ export interface CostDashboardPayload { by_model: CostDashboardModelStats[]; } +export interface CostUsageRecord { + id: string; + timestamp: string; + session_id: string; + model: string; + provider: string | null; + category: string; + input_tokens: number; + output_tokens: number; + total_tokens: number; + cost_usd: number; +} + +export interface CostUsageCategoryStats { + category: string; + cost_usd: number; + total_tokens: number; + request_count: number; + percent_of_total: number; +} + +export interface CostUsageLogPayload { + records: CostUsageRecord[]; + by_category: CostUsageCategoryStats[]; + total_cost_usd: number; + total_tokens: number; + request_count: number; + currency: string; + days: number; + limit: number; +} + interface RpcEnvelope { result?: T; logs?: string[]; @@ -64,6 +96,26 @@ export interface UseCostDashboardResult { refetch: () => Promise; } +export interface UseCostUsageLogOptions extends UseCostDashboardOptions { + days?: number; + limit?: number; +} + +export interface UseCostUsageLogResult { + data: CostUsageLogPayload | null; + isLoading: boolean; + isFetching: boolean; + error: string | null; + lastUpdated: number | null; + refetch: () => Promise; +} + +function unwrapRpcPayload(response: RpcEnvelope | T): T { + return response && typeof response === 'object' && 'result' in response && response.result + ? (response.result as T) + : (response as T); +} + /** * Fetches the 7-day cost dashboard payload from the core via JSON-RPC and * polls every `refreshMs` (default 10s) so today's bar and summary metrics @@ -86,10 +138,7 @@ export function useCostDashboard(options: UseCostDashboardOptions = {}): UseCost params: {}, }); if (cancelledRef.current) return; - const payload = - response && typeof response === 'object' && 'result' in response && response.result - ? (response.result as CostDashboardPayload) - : (response as CostDashboardPayload); + const payload = unwrapRpcPayload(response); setData(payload); setError(null); setLastUpdated(Date.now()); @@ -135,3 +184,64 @@ export function useCostDashboard(options: UseCostDashboardOptions = {}): UseCost return { data, isLoading, isFetching, error, lastUpdated, refetch }; } + +/** + * Fetches detailed persisted cost rows and inferred category distribution. + */ +export function useCostUsageLog(options: UseCostUsageLogOptions = {}): UseCostUsageLogResult { + const { refreshMs = DEFAULT_REFRESH_MS, paused = false, days = 30, limit = 250 } = options; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isFetching, setIsFetching] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const cancelledRef = useRef(false); + + const fetchOnce = useCallback(async () => { + setIsFetching(true); + try { + const response = await callCoreRpc | CostUsageLogPayload>({ + method: 'openhuman.cost_get_usage_log', + params: { days, limit }, + }); + if (cancelledRef.current) return; + setData(unwrapRpcPayload(response)); + setError(null); + setLastUpdated(Date.now()); + } catch (err) { + if (cancelledRef.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelledRef.current) { + setIsLoading(false); + setIsFetching(false); + } + } + }, [days, limit]); + + const refetch = useCallback(async () => { + await fetchOnce(); + }, [fetchOnce]); + + useEffect(() => { + cancelledRef.current = false; + void fetchOnce(); + if (paused) { + return () => { + cancelledRef.current = true; + }; + } + const interval = window.setInterval( + () => { + void fetchOnce(); + }, + Math.max(1000, refreshMs) + ); + return () => { + cancelledRef.current = true; + window.clearInterval(interval); + }; + }, [fetchOnce, refreshMs, paused]); + + return { data, isLoading, isFetching, error, lastUpdated, refetch }; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 07a87ad9d..bca6356ef 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -820,6 +820,18 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': '"يوماً مربوطاً في "إكسكساكس', 'settings.costDashboard.stackedNote': 'الناتج + الناتج', 'settings.costDashboard.modelBreakdownHint': 'تم تجميعها خلال السبعة أيام الماضية', + 'settings.costDashboard.categoryDistribution': 'الإنفاق حسب الفئة', + 'settings.costDashboard.categoryDistributionHint': + 'مستنتج من سجلات حديثة لمكالمات الدردشة والتضمينات والصوت والصور وإعادة الترتيب.', + 'settings.costDashboard.noCategories': 'لم يتم تسجيل إنفاق حسب الفئة بعد.', + 'settings.costDashboard.categoryMeta': '{requests} طلب • {tokens} رمز', + 'settings.costDashboard.usageLog': 'سجل الاستخدام', + 'settings.costDashboard.usageLogHint': 'أحدث السجلات من آخر {days} يومًا، بحد أقصى {limit} صفًا.', + 'settings.costDashboard.logTotal': '{requests} طلب • {cost}', + 'settings.costDashboard.noUsageLog': 'لم يتم العثور على سجلات استخدام لهذه الفترة.', + 'settings.costDashboard.when': 'الوقت', + 'settings.costDashboard.category': 'الفئة', + 'settings.costDashboard.session': 'الجلسة', 'settings.costDashboard.noDataHint': 'إرسال رسالة وكيل - الاستخدام المكسور من النداء القادم للمزود سينشر المخطط في غضون حوالي 10 ثوان.', 'settings.search.title': 'محرك البحث', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index d80241e1a..d2cf135e5 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -831,6 +831,18 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'xqxkx-এ অতিবাহিত দিন', 'settings.costDashboard.stackedNote': 'ইনপুট + আউটপুট স্ট্যাক', 'settings.costDashboard.modelBreakdownHint': 'গত ৭ দিনে পৃথকীকরণ।', + 'settings.costDashboard.categoryDistribution': 'বিভাগ অনুযায়ী খরচ', + 'settings.costDashboard.categoryDistributionHint': + 'সাম্প্রতিক চ্যাট, এমবেডিং, ভয়েস, ছবি এবং রির্যাঙ্কিং কল রেকর্ড থেকে অনুমান করা।', + 'settings.costDashboard.noCategories': 'এখনও কোনো বিভাগভিত্তিক খরচ নেই।', + 'settings.costDashboard.categoryMeta': '{requests} অনুরোধ • {tokens} টোকেন', + 'settings.costDashboard.usageLog': 'ব্যবহার লগ', + 'settings.costDashboard.usageLogHint': 'গত {days} দিনের নতুন রেকর্ড, সর্বোচ্চ {limit} সারি।', + 'settings.costDashboard.logTotal': '{requests} অনুরোধ • {cost}', + 'settings.costDashboard.noUsageLog': 'এই সময়ের জন্য কোনো ব্যবহার রেকর্ড পাওয়া যায়নি।', + 'settings.costDashboard.when': 'কখন', + 'settings.costDashboard.category': 'বিভাগ', + 'settings.costDashboard.session': 'সেশন', 'settings.costDashboard.noDataHint': 'এর ফলে, আপনার সঙ্গে যোগাযোগ করুন ।', 'settings.search.title': 'সার্চ ইঞ্জিন', 'settings.search.menuDesc': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index e39e62a19..c9e1e89e4 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -857,6 +857,20 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Tage in UTC', 'settings.costDashboard.stackedNote': 'Eingang + Ausgang gestapelt', 'settings.costDashboard.modelBreakdownHint': 'Aggregiert über die letzten 7 Tage.', + 'settings.costDashboard.categoryDistribution': 'Ausgaben nach Kategorie', + 'settings.costDashboard.categoryDistributionHint': + 'Aus aktuellen Nutzungsdatensätzen für Chat, Embeddings, Sprache, Bild und Reranking abgeleitet.', + 'settings.costDashboard.noCategories': 'Noch keine Kategorieausgaben erfasst.', + 'settings.costDashboard.categoryMeta': '{requests} Anfragen • {tokens} Token', + 'settings.costDashboard.usageLog': 'Nutzungsprotokoll', + 'settings.costDashboard.usageLogHint': + 'Neueste Datensätze der letzten {days} Tage, begrenzt auf {limit} Zeilen.', + 'settings.costDashboard.logTotal': '{requests} Anfragen • {cost}', + 'settings.costDashboard.noUsageLog': + 'Für diesen Zeitraum wurden keine Nutzungsdatensätze gefunden.', + 'settings.costDashboard.when': 'Wann', + 'settings.costDashboard.category': 'Kategorie', + 'settings.costDashboard.session': 'Sitzung', 'settings.costDashboard.noDataHint': 'Senden Sie eine Agentennachricht — Die Tokenverwendung des nächsten Anbieteranrufs füllt das Diagramm innerhalb von ~10 Sekunden aus.', 'settings.search.title': 'Suchmaschine', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 1b4dd44f9..04dec1a56 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1158,6 +1158,19 @@ const en: TranslationMap = { 'settings.costDashboard.utcNote': 'Days bucketed in UTC', 'settings.costDashboard.stackedNote': 'Input + output stacked', 'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.', + 'settings.costDashboard.categoryDistribution': 'Spend by category', + 'settings.costDashboard.categoryDistributionHint': + 'Inferred from recent usage records across chat, embeddings, voice, image, and reranking calls.', + 'settings.costDashboard.noCategories': 'No category spend recorded yet.', + 'settings.costDashboard.categoryMeta': '{requests} requests • {tokens} tokens', + 'settings.costDashboard.usageLog': 'Usage log', + 'settings.costDashboard.usageLogHint': + 'Newest records from the last {days} days, capped at {limit} rows.', + 'settings.costDashboard.logTotal': '{requests} requests • {cost}', + 'settings.costDashboard.noUsageLog': 'No usage records found for this period.', + 'settings.costDashboard.when': 'When', + 'settings.costDashboard.category': 'Category', + 'settings.costDashboard.session': 'Session', 'settings.costDashboard.noDataHint': 'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.', 'settings.search.title': 'Search engine', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 984a2d008..5dbd6392c 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -856,6 +856,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Días agrupados en UTC', 'settings.costDashboard.stackedNote': 'Entrada + salida apiladas', 'settings.costDashboard.modelBreakdownHint': 'Agregado a lo largo de los últimos 7 días.', + 'settings.costDashboard.categoryDistribution': 'Gasto por categoría', + 'settings.costDashboard.categoryDistributionHint': + 'Inferido de registros recientes de chat, embeddings, voz, imagen y reranking.', + 'settings.costDashboard.noCategories': 'Aún no hay gasto por categoría registrado.', + 'settings.costDashboard.categoryMeta': '{requests} solicitudes • {tokens} tokens', + 'settings.costDashboard.usageLog': 'Registro de uso', + 'settings.costDashboard.usageLogHint': + 'Registros más recientes de los últimos {days} días, con límite de {limit} filas.', + 'settings.costDashboard.logTotal': '{requests} solicitudes • {cost}', + 'settings.costDashboard.noUsageLog': 'No se encontraron registros de uso para este periodo.', + 'settings.costDashboard.when': 'Cuándo', + 'settings.costDashboard.category': 'Categoría', + 'settings.costDashboard.session': 'Sesión', 'settings.costDashboard.noDataHint': 'Envía un mensaje de agente: el uso de tokens de la próxima llamada al proveedor se mostrará en el gráfico en aproximadamente 10 segundos.', 'settings.search.title': 'motor de búsqueda', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4539b6c73..927aa1760 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -856,6 +856,20 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Jours regroupés en UTC', 'settings.costDashboard.stackedNote': 'Entrée + sortie empilées', 'settings.costDashboard.modelBreakdownHint': 'Agrégé sur les 7 derniers jours.', + 'settings.costDashboard.categoryDistribution': 'Dépenses par catégorie', + 'settings.costDashboard.categoryDistributionHint': + 'Déduit des enregistrements récents de chat, embeddings, voix, image et reranking.', + 'settings.costDashboard.noCategories': 'Aucune dépense par catégorie enregistrée pour le moment.', + 'settings.costDashboard.categoryMeta': '{requests} requêtes • {tokens} jetons', + 'settings.costDashboard.usageLog': "Journal d'utilisation", + 'settings.costDashboard.usageLogHint': + 'Enregistrements les plus récents des {days} derniers jours, limités à {limit} lignes.', + 'settings.costDashboard.logTotal': '{requests} requêtes • {cost}', + 'settings.costDashboard.noUsageLog': + "Aucun enregistrement d'utilisation trouvé pour cette période.", + 'settings.costDashboard.when': 'Quand', + 'settings.costDashboard.category': 'Catégorie', + 'settings.costDashboard.session': 'Session', 'settings.costDashboard.noDataHint': "Envoyez un message agent — l'utilisation des jetons lors du prochain appel au fournisseur remplira le graphique en environ 10 secondes.", 'settings.search.title': 'Moteur de recherche', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7ad19cdf8..13cca76a8 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -832,6 +832,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'UTC में दिन की बाल्टी', 'settings.costDashboard.stackedNote': 'इनपुट + आउटपुट स्टैक्ड', 'settings.costDashboard.modelBreakdownHint': 'पिछले 7 दिनों में एकत्र हुआ।', + 'settings.costDashboard.categoryDistribution': 'श्रेणी के अनुसार खर्च', + 'settings.costDashboard.categoryDistributionHint': + 'हाल के चैट, एम्बेडिंग, वॉइस, इमेज और री-रैंकिंग कॉल रिकॉर्ड से अनुमानित।', + 'settings.costDashboard.noCategories': 'अभी तक कोई श्रेणी खर्च दर्ज नहीं है।', + 'settings.costDashboard.categoryMeta': '{requests} अनुरोध • {tokens} टोकन', + 'settings.costDashboard.usageLog': 'उपयोग लॉग', + 'settings.costDashboard.usageLogHint': + 'पिछले {days} दिनों के नवीनतम रिकॉर्ड, {limit} पंक्तियों तक सीमित।', + 'settings.costDashboard.logTotal': '{requests} अनुरोध • {cost}', + 'settings.costDashboard.noUsageLog': 'इस अवधि के लिए कोई उपयोग रिकॉर्ड नहीं मिला।', + 'settings.costDashboard.when': 'कब', + 'settings.costDashboard.category': 'श्रेणी', + 'settings.costDashboard.session': 'सत्र', 'settings.costDashboard.noDataHint': 'एक एजेंट संदेश भेजें - अगले प्रदाता कॉल से टोकन का उपयोग ~ 10 सेकंड के भीतर चार्ट को पॉप्युलेट करेगा।', 'settings.search.title': 'खोज इंजन', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 745c540bd..5b8edee13 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -836,6 +836,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Hari libur di UTC', 'settings.costDashboard.stackedNote': 'Masukan + keluaran ditumpuk', 'settings.costDashboard.modelBreakdownHint': 'Diperburuk 7 hari terakhir.', + 'settings.costDashboard.categoryDistribution': 'Pengeluaran per kategori', + 'settings.costDashboard.categoryDistributionHint': + 'Disimpulkan dari catatan terbaru untuk chat, embedding, suara, gambar, dan reranking.', + 'settings.costDashboard.noCategories': 'Belum ada pengeluaran kategori yang dicatat.', + 'settings.costDashboard.categoryMeta': '{requests} permintaan • {tokens} token', + 'settings.costDashboard.usageLog': 'Log penggunaan', + 'settings.costDashboard.usageLogHint': + 'Catatan terbaru dari {days} hari terakhir, dibatasi {limit} baris.', + 'settings.costDashboard.logTotal': '{requests} permintaan • {cost}', + 'settings.costDashboard.noUsageLog': 'Tidak ada catatan penggunaan untuk periode ini.', + 'settings.costDashboard.when': 'Waktu', + 'settings.costDashboard.category': 'Kategori', + 'settings.costDashboard.session': 'Sesi', 'settings.costDashboard.noDataHint': 'Kirim pesan agen - penggunaan token dari panggilan penyedia berikutnya akan mengisi bagan dalam waktu ~10.', 'settings.search.title': 'Mesin pencari', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 7e1326508..c0a1d0c5d 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -850,6 +850,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Giorni raggruppati in UTC', 'settings.costDashboard.stackedNote': 'Ingresso + uscita impilati', 'settings.costDashboard.modelBreakdownHint': 'Aggregato negli ultimi 7 giorni.', + 'settings.costDashboard.categoryDistribution': 'Spesa per categoria', + 'settings.costDashboard.categoryDistributionHint': + 'Dedotta dai record recenti di chat, embedding, voce, immagini e reranking.', + 'settings.costDashboard.noCategories': 'Nessuna spesa per categoria registrata.', + 'settings.costDashboard.categoryMeta': '{requests} richieste • {tokens} token', + 'settings.costDashboard.usageLog': 'Registro utilizzo', + 'settings.costDashboard.usageLogHint': + 'Record più recenti degli ultimi {days} giorni, limitati a {limit} righe.', + 'settings.costDashboard.logTotal': '{requests} richieste • {cost}', + 'settings.costDashboard.noUsageLog': 'Nessun record di utilizzo trovato per questo periodo.', + 'settings.costDashboard.when': 'Quando', + 'settings.costDashboard.category': 'Categoria', + 'settings.costDashboard.session': 'Sessione', 'settings.costDashboard.noDataHint': "Invia un messaggio all'agente: l'utilizzo dei token dalla prossima chiamata al provider popolerà il grafico entro circa 10 secondi.", 'settings.search.title': 'Motore di ricerca', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 7fac6c5de..dbb1acac3 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -828,6 +828,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': '일자는 UTC 기준으로 묶입니다', 'settings.costDashboard.stackedNote': '입력 + 출력 누적', 'settings.costDashboard.modelBreakdownHint': '지난 7일 전체 집계입니다.', + 'settings.costDashboard.categoryDistribution': '카테고리별 지출', + 'settings.costDashboard.categoryDistributionHint': + '최근 채팅, 임베딩, 음성, 이미지, 재순위화 호출 기록에서 추정했습니다.', + 'settings.costDashboard.noCategories': '아직 기록된 카테고리별 지출이 없습니다.', + 'settings.costDashboard.categoryMeta': '{requests}개 요청 • {tokens}개 토큰', + 'settings.costDashboard.usageLog': '사용 로그', + 'settings.costDashboard.usageLogHint': + '최근 {days}일의 최신 기록이며 최대 {limit}행까지 표시합니다.', + 'settings.costDashboard.logTotal': '{requests}개 요청 • {cost}', + 'settings.costDashboard.noUsageLog': '이 기간의 사용 기록이 없습니다.', + 'settings.costDashboard.when': '시간', + 'settings.costDashboard.category': '카테고리', + 'settings.costDashboard.session': '세션', 'settings.costDashboard.noDataHint': '에이전트 메시지를 보내세요. 다음 제공업체 호출의 토큰 사용량이 약 10초 안에 차트에 표시됩니다.', 'settings.search.title': '검색 엔진', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index d16fa3067..120d118d1 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -847,6 +847,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Dni grupowane według UTC', 'settings.costDashboard.stackedNote': 'Wejście + wyjście skumulowane', 'settings.costDashboard.modelBreakdownHint': 'Zagregowane z ostatnich 7 dni.', + 'settings.costDashboard.categoryDistribution': 'Wydatki według kategorii', + 'settings.costDashboard.categoryDistributionHint': + 'Wywnioskowane z ostatnich rekordów czatu, embeddingów, głosu, obrazu i rerankingu.', + 'settings.costDashboard.noCategories': 'Nie zapisano jeszcze wydatków według kategorii.', + 'settings.costDashboard.categoryMeta': '{requests} żądań • {tokens} tokenów', + 'settings.costDashboard.usageLog': 'Dziennik użycia', + 'settings.costDashboard.usageLogHint': + 'Najnowsze rekordy z ostatnich {days} dni, limit {limit} wierszy.', + 'settings.costDashboard.logTotal': '{requests} żądań • {cost}', + 'settings.costDashboard.noUsageLog': 'Brak rekordów użycia dla tego okresu.', + 'settings.costDashboard.when': 'Kiedy', + 'settings.costDashboard.category': 'Kategoria', + 'settings.costDashboard.session': 'Sesja', 'settings.costDashboard.noDataHint': 'Wyślij wiadomość do agenta — zużycie tokenów z następnego wywołania dostawcy pojawi się na wykresie w ciągu około 10 sekund.', 'settings.search.title': 'Wyszukiwarka', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 9fc408cd8..7fca8773b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -856,6 +856,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Dias agrupados em UTC', 'settings.costDashboard.stackedNote': 'Entrada + saída empilhadas', 'settings.costDashboard.modelBreakdownHint': 'Agregado ao longo dos últimos 7 dias.', + 'settings.costDashboard.categoryDistribution': 'Gastos por categoria', + 'settings.costDashboard.categoryDistributionHint': + 'Inferido de registros recentes de chat, embeddings, voz, imagem e reranking.', + 'settings.costDashboard.noCategories': 'Ainda não há gastos por categoria registrados.', + 'settings.costDashboard.categoryMeta': '{requests} solicitações • {tokens} tokens', + 'settings.costDashboard.usageLog': 'Registro de uso', + 'settings.costDashboard.usageLogHint': + 'Registros mais recentes dos últimos {days} dias, limitados a {limit} linhas.', + 'settings.costDashboard.logTotal': '{requests} solicitações • {cost}', + 'settings.costDashboard.noUsageLog': 'Nenhum registro de uso encontrado para este período.', + 'settings.costDashboard.when': 'Quando', + 'settings.costDashboard.category': 'Categoria', + 'settings.costDashboard.session': 'Sessão', 'settings.costDashboard.noDataHint': 'Envie uma mensagem de agente — o uso de tokens na próxima chamada do provedor preencherá o gráfico em cerca de 10 segundos.', 'settings.search.title': 'Mecanismo de pesquisa', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 89378ec8b..cf7b3eef3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -841,6 +841,19 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': 'Дни, разбитые по UTC', 'settings.costDashboard.stackedNote': 'Вход + выход сложены', 'settings.costDashboard.modelBreakdownHint': 'Совокупно за последние 7 дней.', + 'settings.costDashboard.categoryDistribution': 'Расходы по категориям', + 'settings.costDashboard.categoryDistributionHint': + 'Определено по последним записям вызовов чата, эмбеддингов, голоса, изображений и реранкинга.', + 'settings.costDashboard.noCategories': 'Расходы по категориям пока не записаны.', + 'settings.costDashboard.categoryMeta': '{requests} запросов • {tokens} токенов', + 'settings.costDashboard.usageLog': 'Журнал использования', + 'settings.costDashboard.usageLogHint': + 'Новые записи за последние {days} дней, не более {limit} строк.', + 'settings.costDashboard.logTotal': '{requests} запросов • {cost}', + 'settings.costDashboard.noUsageLog': 'За этот период записи использования не найдены.', + 'settings.costDashboard.when': 'Когда', + 'settings.costDashboard.category': 'Категория', + 'settings.costDashboard.session': 'Сессия', 'settings.costDashboard.noDataHint': 'Отправьте сообщение агенту — использование токена при следующем вызове провайдера заполнит диаграмму в течение примерно 10 секунд.', 'settings.search.title': 'Поисковая система', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d5866ae2c..4ef89c683 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -790,6 +790,18 @@ const messages: TranslationMap = { 'settings.costDashboard.utcNote': '按 UTC 日期分组', 'settings.costDashboard.stackedNote': '输入 + 输出堆叠', 'settings.costDashboard.modelBreakdownHint': '汇总过去 7 天。', + 'settings.costDashboard.categoryDistribution': '按类别的花费', + 'settings.costDashboard.categoryDistributionHint': + '根据最近的聊天、嵌入、语音、图像和重排调用记录推断。', + 'settings.costDashboard.noCategories': '尚未记录类别花费。', + 'settings.costDashboard.categoryMeta': '{requests} 次请求 • {tokens} 个 token', + 'settings.costDashboard.usageLog': '使用日志', + 'settings.costDashboard.usageLogHint': '最近 {days} 天的最新记录,最多显示 {limit} 行。', + 'settings.costDashboard.logTotal': '{requests} 次请求 • {cost}', + 'settings.costDashboard.noUsageLog': '此期间没有找到使用记录。', + 'settings.costDashboard.when': '时间', + 'settings.costDashboard.category': '类别', + 'settings.costDashboard.session': '会话', 'settings.costDashboard.noDataHint': '发送一条智能体消息后,下一次提供商调用的 token 用量会在约 10 秒内填充图表。', 'settings.search.title': '搜索引擎', diff --git a/src/openhuman/cost/rpc.rs b/src/openhuman/cost/rpc.rs index a1438cdf0..1d5ffb4d4 100644 --- a/src/openhuman/cost/rpc.rs +++ b/src/openhuman/cost/rpc.rs @@ -12,6 +12,7 @@ use anyhow::{anyhow, Context, Result}; use parking_lot::Mutex; use serde::Serialize; use serde_json::Value; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -21,7 +22,9 @@ use crate::rpc::RpcOutcome; use super::global::try_global; use super::tracker::CostTracker; -use super::types::{BudgetStatus, CostDashboard, CostSummary, DailyCostEntry, ModelStats}; +use super::types::{ + BudgetStatus, CostDashboard, CostRecord, CostSummary, DailyCostEntry, ModelStats, +}; #[derive(Debug, Clone, Serialize)] pub struct DailyCostEntryDto { @@ -70,10 +73,71 @@ pub struct CostSummaryDto { pub by_model: Vec, } +#[derive(Debug, Clone, Serialize)] +pub struct UsageLogRecordDto { + pub id: String, + pub timestamp: String, + pub session_id: String, + pub model: String, + pub provider: Option, + pub category: String, + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CategoryStatsDto { + pub category: String, + pub cost_usd: f64, + pub total_tokens: u64, + pub request_count: usize, + pub percent_of_total: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UsageLogDto { + pub records: Vec, + pub by_category: Vec, + pub total_cost_usd: f64, + pub total_tokens: u64, + pub request_count: usize, + pub currency: String, + pub days: u32, + pub limit: usize, +} + fn provider_for(model: &str) -> Option { model.split_once('/').map(|(prov, _)| prov.to_string()) } +fn category_for(model: &str) -> String { + let lower = model.to_lowercase(); + if lower.contains("embed") || lower.contains("text-embedding") || lower.contains("voyage") { + "Embeddings".to_string() + } else if lower.contains("whisper") + || lower.contains("tts") + || lower.contains("stt") + || lower.contains("voice") + || lower.contains("audio") + || lower.contains("nova-") + { + "Voice and audio".to_string() + } else if lower.contains("image") + || lower.contains("dall-e") + || lower.contains("gpt-image") + || lower.contains("flux") + || lower.contains("sdxl") + { + "Image generation".to_string() + } else if lower.contains("rerank") { + "Reranking".to_string() + } else { + "AI chat and reasoning".to_string() + } +} + fn model_stats_to_dto(stats: &ModelStats, total_cost: f64) -> ModelStatsDto { let percent_of_total = if total_cost > 0.0 { (stats.cost_usd / total_cost) * 100.0 @@ -90,6 +154,75 @@ fn model_stats_to_dto(stats: &ModelStats, total_cost: f64) -> ModelStatsDto { } } +fn usage_record_to_dto(record: &CostRecord) -> UsageLogRecordDto { + UsageLogRecordDto { + id: record.id.clone(), + timestamp: record.usage.timestamp.to_rfc3339(), + session_id: record.session_id.clone(), + model: record.usage.model.clone(), + provider: provider_for(&record.usage.model), + category: category_for(&record.usage.model), + input_tokens: record.usage.input_tokens, + output_tokens: record.usage.output_tokens, + total_tokens: record.usage.total_tokens, + cost_usd: record.usage.cost_usd, + } +} + +fn usage_log_to_dto( + records: Vec, + currency: String, + days: u32, + limit: usize, +) -> UsageLogDto { + let total_cost_usd: f64 = records.iter().map(|record| record.usage.cost_usd).sum(); + let total_tokens: u64 = records.iter().map(|record| record.usage.total_tokens).sum(); + let request_count = records.len(); + let mut by_category: HashMap = HashMap::new(); + + for record in &records { + let category = category_for(&record.usage.model); + let entry = by_category + .entry(category.clone()) + .or_insert_with(|| CategoryStatsDto { + category, + cost_usd: 0.0, + total_tokens: 0, + request_count: 0, + percent_of_total: 0.0, + }); + entry.cost_usd += record.usage.cost_usd; + entry.total_tokens = entry.total_tokens.saturating_add(record.usage.total_tokens); + entry.request_count += 1; + } + + let mut by_category: Vec = by_category.into_values().collect(); + for category in &mut by_category { + category.percent_of_total = if total_cost_usd > 0.0 { + (category.cost_usd / total_cost_usd) * 100.0 + } else { + 0.0 + }; + } + by_category.sort_by(|a, b| { + b.cost_usd + .partial_cmp(&a.cost_usd) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.category.cmp(&b.category)) + }); + + UsageLogDto { + records: records.iter().map(usage_record_to_dto).collect(), + by_category, + total_cost_usd, + total_tokens, + request_count, + currency, + days, + limit, + } +} + fn daily_entry_to_dto(entry: &DailyCostEntry) -> DailyCostEntryDto { let mut by_model: Vec = entry .by_model @@ -300,6 +433,32 @@ pub fn summary(config: &Config) -> Result> { Ok(RpcOutcome::new(value, Vec::new())) } +/// Return a recent, bounded usage log plus spend distribution by category. +pub fn usage_log(config: &Config, days: u32, limit: usize) -> Result> { + log::debug!(target: "cost_rpc", "[cost_rpc] usage_log.entry days={days} limit={limit}"); + let tracker = resolve_tracker(config).inspect_err(|err| { + log::warn!(target: "cost_rpc", "[cost_rpc] usage_log.resolve_failed err={err:#}"); + })?; + let clamped_days = days.clamp(1, 366); + let clamped_limit = limit.clamp(1, 1000); + let records = tracker + .get_recent_records(clamped_days, clamped_limit) + .inspect_err(|err| { + log::warn!(target: "cost_rpc", "[cost_rpc] usage_log.query_failed err={err:#}"); + }) + .context("cost usage log query failed")?; + let request_count = records.len(); + let dto = usage_log_to_dto( + records, + config.cost.dashboard.currency.clone(), + clamped_days, + clamped_limit, + ); + let value = serde_json::to_value(dto).context("cost usage log serialize failed")?; + log::debug!(target: "cost_rpc", "[cost_rpc] usage_log.exit records={request_count}"); + Ok(RpcOutcome::new(value, Vec::new())) +} + #[cfg(test)] mod tests { use super::*; @@ -349,6 +508,18 @@ mod tests { assert_eq!(provider_for("bare-model"), None); } + #[test] + fn category_for_classifies_common_usage_families() { + assert_eq!(category_for("voyage/voyage-3"), "Embeddings"); + assert_eq!(category_for("openai/whisper-1"), "Voice and audio"); + assert_eq!(category_for("openai/gpt-image-1"), "Image generation"); + assert_eq!(category_for("cohere/rerank-english"), "Reranking"); + assert_eq!( + category_for("anthropic/claude-sonnet-4"), + "AI chat and reasoning" + ); + } + #[test] fn model_stats_dto_percent_zero_when_total_zero() { let stats = make_model_stats("a/b", 0.0); @@ -426,6 +597,27 @@ mod tests { assert_eq!(dto.by_model[1].model, "low"); } + #[test] + fn usage_log_dto_sorts_categories_and_preserves_records() { + let mut chat = CostRecord::new( + "session-a", + TokenUsage::new("anthropic/claude-sonnet-4", 1000, 500, 0.0, 0.0), + ); + chat.usage.cost_usd = 3.0; + let mut embeddings = CostRecord::new( + "session-b", + TokenUsage::new("voyage/voyage-3", 2000, 0, 0.0, 0.0), + ); + embeddings.usage.cost_usd = 1.0; + + let dto = usage_log_to_dto(vec![chat, embeddings], "USD".to_string(), 30, 100); + assert_eq!(dto.records.len(), 2); + assert_eq!(dto.by_category.len(), 2); + assert_eq!(dto.by_category[0].category, "AI chat and reasoning"); + assert!((dto.by_category[0].percent_of_total - 75.0).abs() < f64::EPSILON); + assert_eq!(dto.total_tokens, 3500); + } + #[test] fn dashboard_rpc_returns_value_against_tempdir_workspace() { let _lock = tracker_test_lock(); @@ -461,6 +653,27 @@ mod tests { assert!(obj.contains_key("by_model")); } + #[test] + fn usage_log_rpc_returns_records_and_category_breakdown() { + let _lock = tracker_test_lock(); + if try_global().is_some() { + return; + } + *FALLBACK_TRACKER.lock() = None; + let (_tmp, cfg) = tempdir_config(); + let tracker = resolve_tracker(&cfg).unwrap(); + let mut usage = TokenUsage::new("anthropic/claude-sonnet-4", 1000, 500, 0.0, 0.0); + usage.cost_usd = 1.25; + usage.timestamp = Utc::now(); + tracker.record_usage_unconditional(usage).unwrap(); + + let outcome = usage_log(&cfg, 30, 100).expect("usage log should resolve"); + let obj = outcome.value.as_object().unwrap(); + assert_eq!(obj["request_count"], 1); + assert_eq!(obj["records"].as_array().unwrap().len(), 1); + assert_eq!(obj["by_category"].as_array().unwrap().len(), 1); + } + #[test] fn resolve_tracker_caches_fallback_across_calls() { let _lock = tracker_test_lock(); diff --git a/src/openhuman/cost/schemas.rs b/src/openhuman/cost/schemas.rs index 68e3f92af..28ebb0990 100644 --- a/src/openhuman/cost/schemas.rs +++ b/src/openhuman/cost/schemas.rs @@ -17,11 +17,29 @@ struct DailyHistoryParams { days: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsageLogParams { + #[serde(default = "default_usage_days")] + days: u32, + #[serde(default = "default_usage_limit")] + limit: usize, +} + +fn default_usage_days() -> u32 { + 30 +} + +fn default_usage_limit() -> usize { + 250 +} + pub fn all_controller_schemas() -> Vec { vec![ schema_for("cost_get_dashboard"), schema_for("cost_get_daily_history"), schema_for("cost_get_summary"), + schema_for("cost_get_usage_log"), ] } @@ -39,6 +57,10 @@ pub fn all_registered_controllers() -> Vec { schema: schema_for("cost_get_summary"), handler: handle_cost_get_summary, }, + RegisteredController { + schema: schema_for("cost_get_usage_log"), + handler: handle_cost_get_usage_log, + }, ] } @@ -82,6 +104,32 @@ fn schema_for(function: &str) -> ControllerSchema { "Aggregated cost & token usage for the current session and active period.", )], }, + "cost_get_usage_log" => ControllerSchema { + namespace: "cost", + function: "get_usage_log", + description: "Fetch a bounded recent cost usage log with per-record rows and spend \ + distribution by inferred category.", + inputs: vec![ + FieldSchema { + name: "days", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: + "Number of trailing days to include (default 30, clamped to [1, 366]).", + required: false, + }, + FieldSchema { + name: "limit", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: + "Maximum number of records to return (default 250, clamped to [1, 1000]).", + required: false, + }, + ], + outputs: vec![json_output( + "usage_log", + "Usage records plus category totals, newest records first.", + )], + }, _ => ControllerSchema { namespace: "cost", function: "unknown", @@ -173,6 +221,42 @@ fn handle_cost_get_summary(_params: Map) -> ControllerFuture { }) } +fn handle_cost_get_usage_log(params: Map) -> ControllerFuture { + Box::pin(async move { + let cid = new_correlation_id(); + log::debug!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_usage_log.entry"); + let config = config_rpc::load_config_with_timeout().await.inspect_err(|err| { + log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_usage_log.config_load_failed err={err}"); + })?; + let payload = if params.is_empty() { + UsageLogParams { + days: default_usage_days(), + limit: default_usage_limit(), + } + } else { + serde_json::from_value::(Value::Object(params)).map_err(|e| { + let s = format!("invalid params: {e}"); + log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_usage_log.bad_params err={s}"); + s + })? + }; + let outcome = cost_rpc::usage_log(&config, payload.days, payload.limit).map_err(|e| { + let s = e.to_string(); + log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_usage_log.error err={s}"); + s + })?; + let json = to_json(outcome); + log::debug!( + target: "cost_rpc", + "[cost_rpc][{cid}] cost_get_usage_log.exit days={} limit={} ok={}", + payload.days, + payload.limit, + json.is_ok() + ); + json + }) +} + fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } @@ -194,23 +278,25 @@ mod tests { fn all_controller_schemas_lists_three_functions() { let schemas = all_controller_schemas(); let names: Vec<&'static str> = schemas.iter().map(|s| s.function).collect(); - assert_eq!(schemas.len(), 3); + assert_eq!(schemas.len(), 4); assert!(names.contains(&"get_dashboard")); assert!(names.contains(&"get_daily_history")); assert!(names.contains(&"get_summary")); + assert!(names.contains(&"get_usage_log")); for schema in &schemas { assert_eq!(schema.namespace, "cost"); } } #[test] - fn all_registered_controllers_has_three_handlers_matching_schemas() { + fn all_registered_controllers_has_handlers_matching_schemas() { let registered = all_registered_controllers(); - assert_eq!(registered.len(), 3); + assert_eq!(registered.len(), 4); let schema_fns: Vec<&'static str> = registered.iter().map(|r| r.schema.function).collect(); assert!(schema_fns.contains(&"get_dashboard")); assert!(schema_fns.contains(&"get_daily_history")); assert!(schema_fns.contains(&"get_summary")); + assert!(schema_fns.contains(&"get_usage_log")); } #[test] @@ -238,6 +324,16 @@ mod tests { assert_eq!(s.outputs[0].name, "summary"); } + #[test] + fn schema_for_usage_log_has_days_and_limit_inputs() { + let s = schema_for("cost_get_usage_log"); + assert_eq!(s.function, "get_usage_log"); + assert_eq!(s.inputs.len(), 2); + assert_eq!(s.inputs[0].name, "days"); + assert_eq!(s.inputs[1].name, "limit"); + assert_eq!(s.outputs[0].name, "usage_log"); + } + #[test] fn schema_for_unknown_returns_error_shape() { let s = schema_for("cost_get_nonexistent"); diff --git a/src/openhuman/cost/tracker.rs b/src/openhuman/cost/tracker.rs index d42c7847e..bd5fe5543 100644 --- a/src/openhuman/cost/tracker.rs +++ b/src/openhuman/cost/tracker.rs @@ -249,6 +249,37 @@ impl CostTracker { Ok(out) } + /// Return recent persisted usage records, newest first. + /// + /// `days` is clamped to `[1, 366]` and `limit` to `[1, 1000]` to keep + /// dashboard calls bounded while still allowing a detailed audit log. + pub fn get_recent_records(&self, days: u32, limit: usize) -> Result> { + let span = days.clamp(1, 366) as i64; + let limit = limit.clamp(1, 1000); + let now = Utc::now(); + let earliest = now + .checked_sub_signed(Duration::days(span - 1)) + .ok_or_else(|| anyhow!("Usage log range underflowed"))?; + + let mut records: Vec = Vec::new(); + let storage = self.lock_storage(); + storage.for_each_record(|record| { + if record.usage.timestamp < earliest || record.usage.timestamp > now { + return; + } + records.push(record); + })?; + + records.sort_by(|a, b| { + b.usage + .timestamp + .cmp(&a.usage.timestamp) + .then_with(|| b.id.cmp(&a.id)) + }); + records.truncate(limit); + Ok(records) + } + /// Build the full dashboard payload: 7-day history, period total, /// projected monthly pace (daily avg × 30), and budget utilisation /// derived from the configured monthly limit and warn/alert thresholds. @@ -497,6 +528,11 @@ impl CostStorage { /// Add a new record. fn add_record(&mut self, record: CostRecord) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + let mut file = OpenOptions::new() .create(true) .append(true)