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 && (
{
+
+
+ {usageLog ? (
+
+ ) : usageLogLoading ? (
+
+ {t('settings.costDashboard.loading')}
+
+ ) : null}
+
+
+
+ {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 (
+
+
+
+
+ | {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')} |
+
+
+
+ {records.map(record => (
+
+ |
+
+ {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)