feat(cost): add usage spend insights (#3461)

This commit is contained in:
Steven Enamakel
2026-06-06 19:49:36 -04:00
committed by GitHub
parent 8afc49d840
commit 21015cf57d
20 changed files with 977 additions and 48 deletions
@@ -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('<CostDashboardPanel />', () => {
beforeEach(() => {
mockedCall.mockReset();
@@ -58,42 +90,51 @@ describe('<CostDashboardPanel />', () => {
});
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 () => {
@@ -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')}
</p>
<div className="flex items-center gap-2 shrink-0">
{lastUpdated !== null && (
{(lastUpdated !== null || usageLogUpdated !== null) && (
<span
data-testid="cost-dashboard-updated"
className="inline-flex items-center gap-1.5 text-[11px] text-stone-500 dark:text-neutral-400">
<span
aria-hidden
className={`inline-block h-1.5 w-1.5 rounded-full ${isFetching ? 'bg-ocean-500 animate-pulse' : 'bg-sage-500'}`}
className={`inline-block h-1.5 w-1.5 rounded-full ${isFetching || usageLogFetching ? 'bg-ocean-500 animate-pulse' : 'bg-sage-500'}`}
/>
{`${t('settings.costDashboard.updated')} ${relativeTime(lastUpdated, t)}`}
{`${t('settings.costDashboard.updated')} ${relativeTime(Math.max(lastUpdated ?? 0, usageLogUpdated ?? 0), t)}`}
</span>
)}
<button
type="button"
data-testid="cost-dashboard-refresh"
onClick={() => void refetch()}
disabled={isFetching}
onClick={() => void Promise.all([refetch(), refetchUsageLog()])}
disabled={isFetching || usageLogFetching}
aria-label={t('settings.costDashboard.refresh')}
className="inline-flex items-center gap-1 rounded-md border border-stone-200 dark:border-neutral-800 px-2 py-1 text-[11px] text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-50 transition-colors">
<RefreshIcon className={`h-3.5 w-3.5 ${isFetching ? 'animate-spin' : ''}`} />
<RefreshIcon
className={`h-3.5 w-3.5 ${isFetching || usageLogFetching ? 'animate-spin' : ''}`}
/>
<span>{t('settings.costDashboard.refresh')}</span>
</button>
</div>
@@ -75,6 +90,14 @@ const CostDashboardPanel = () => {
{error}
</div>
)}
{usageLogError && (
<div
role="alert"
className="rounded-md border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300"
data-testid="cost-dashboard-usage-error">
{usageLogError}
</div>
)}
{data && !data.enabled && (
<div
className="rounded-md border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300"
@@ -141,6 +164,65 @@ const CostDashboardPanel = () => {
</header>
<ModelCostTable models={data.by_model} currency={data.currency} />
</section>
<section
data-testid="cost-dashboard-category-distribution"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-3">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{t('settings.costDashboard.categoryDistribution')}
</h2>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.categoryDistributionHint')}
</p>
</header>
{usageLog ? (
<CategoryDistribution
categories={usageLog.by_category}
currency={usageLog.currency}
/>
) : usageLogLoading ? (
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.loading')}
</div>
) : null}
</section>
<section
data-testid="cost-dashboard-usage-log"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-3 flex items-baseline justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{t('settings.costDashboard.usageLog')}
</h2>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{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')}
</p>
</div>
{usageLog && (
<span className="shrink-0 text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.logTotal')
.replace('{requests}', String(usageLog.request_count))
.replace(
'{cost}',
formatCurrency(usageLog.total_cost_usd, usageLog.currency)
)}
</span>
)}
</header>
{usageLog ? (
<UsageLogTable records={usageLog.records} currency={usageLog.currency} />
) : usageLogLoading ? (
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.loading')}
</div>
) : null}
</section>
{!hasAnyCost && (
<div
data-testid="cost-dashboard-empty"
@@ -160,6 +242,176 @@ const CostDashboardPanel = () => {
);
};
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 (
<div className="text-xs text-stone-500 dark:text-neutral-400 italic py-2">
{t('settings.costDashboard.noCategories')}
</div>
);
}
return (
<div className="space-y-3">
<div
aria-hidden
className="flex h-3 w-full overflow-hidden rounded-full bg-stone-200 dark:bg-neutral-800">
{categories.map((category, index) => (
<div
key={category.category}
className={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
style={{ width: `${Math.max(0, Math.min(100, category.percent_of_total))}%` }}
/>
))}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{categories.map((category, index) => (
<div
key={category.category}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className={`h-2 w-2 rounded-full ${CATEGORY_COLORS[index % CATEGORY_COLORS.length]}`}
/>
<span className="truncate text-xs font-medium text-stone-800 dark:text-neutral-100">
{category.category}
</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums text-stone-900 dark:text-neutral-50">
{formatCurrency(category.cost_usd, currency)}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-stone-500 dark:text-neutral-400">
<span>{`${category.percent_of_total.toFixed(1)}%`}</span>
<span>
{t('settings.costDashboard.categoryMeta')
.replace('{requests}', String(category.request_count))
.replace('{tokens}', formatTokens(category.total_tokens))}
</span>
</div>
</div>
))}
</div>
</div>
);
};
const UsageLogTable = ({ records, currency }: { records: CostUsageRecord[]; currency: string }) => {
const { t } = useT();
if (records.length === 0) {
return (
<div className="text-xs text-stone-500 dark:text-neutral-400 italic py-2">
{t('settings.costDashboard.noUsageLog')}
</div>
);
}
return (
<div className="overflow-x-auto -mx-1">
<table className="w-full min-w-[760px] text-xs">
<thead>
<tr className="border-b border-stone-200 text-left text-[10px] uppercase tracking-wide text-stone-500 dark:border-neutral-800 dark:text-neutral-400">
<Th>{t('settings.costDashboard.when')}</Th>
<Th>{t('settings.costDashboard.category')}</Th>
<Th>{t('settings.costDashboard.model')}</Th>
<Th align="right">{t('settings.costDashboard.inputTokens')}</Th>
<Th align="right">{t('settings.costDashboard.outputTokens')}</Th>
<Th align="right">{t('settings.costDashboard.cost')}</Th>
<Th>{t('settings.costDashboard.session')}</Th>
</tr>
</thead>
<tbody>
{records.map(record => (
<tr
key={record.id}
className="border-b border-stone-100 transition-colors last:border-0 hover:bg-stone-50/60 dark:border-neutral-800/60 dark:hover:bg-neutral-800/40">
<Td>
<div className="tabular-nums text-stone-700 dark:text-neutral-200">
{formatDateTime(record.timestamp)}
</div>
</Td>
<Td>
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-[10px] font-medium text-stone-700 ring-1 ring-inset ring-stone-200 dark:bg-neutral-800 dark:text-neutral-200 dark:ring-neutral-700">
{record.category}
</span>
</Td>
<Td>
<div className="max-w-[16rem] truncate font-medium text-stone-800 dark:text-neutral-100">
{record.model}
</div>
<div className="text-[10px] text-stone-500 dark:text-neutral-400">
{record.provider ?? t('settings.costDashboard.unknownProvider')}
</div>
</Td>
<Td align="right">{formatTokens(record.input_tokens)}</Td>
<Td align="right">{formatTokens(record.output_tokens)}</Td>
<Td align="right">
<span className="font-semibold tabular-nums text-stone-900 dark:text-neutral-50">
{formatCurrency(record.cost_usd, currency)}
</span>
</Td>
<Td>
<span className="font-mono text-[10px] text-stone-500 dark:text-neutral-400">
{shortId(record.session_id)}
</span>
</Td>
</tr>
))}
</tbody>
</table>
</div>
);
};
interface CellProps {
children: ReactNode;
align?: 'left' | 'right';
}
const Th = ({ children, align = 'left' }: CellProps) => (
<th className={`px-2 py-2 font-medium ${align === 'right' ? 'text-right' : 'text-left'}`}>
{children}
</th>
);
const Td = ({ children, align = 'left' }: CellProps) => (
<td className={`px-2 py-2 align-middle ${align === 'right' ? 'text-right' : 'text-left'}`}>
{children}
</td>
);
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;
}
+114 -4
View File
@@ -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<T> {
result?: T;
logs?: string[];
@@ -64,6 +96,26 @@ export interface UseCostDashboardResult {
refetch: () => Promise<void>;
}
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<void>;
}
function unwrapRpcPayload<T>(response: RpcEnvelope<T> | 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<CostUsageLogPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isFetching, setIsFetching] = useState<boolean>(true);
const [lastUpdated, setLastUpdated] = useState<number | null>(null);
const cancelledRef = useRef<boolean>(false);
const fetchOnce = useCallback(async () => {
setIsFetching(true);
try {
const response = await callCoreRpc<RpcEnvelope<CostUsageLogPayload> | 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 };
}
+12
View File
@@ -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': 'محرك البحث',
+12
View File
@@ -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':
+14
View File
@@ -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',
+13
View File
@@ -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',
+13
View File
@@ -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',
+14
View File
@@ -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',
+13
View File
@@ -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': 'खोज इंजन',
+13
View File
@@ -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',
+13
View File
@@ -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',
+13
View File
@@ -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': '검색 엔진',
+13
View File
@@ -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',
+13
View File
@@ -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',
+13
View File
@@ -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': 'Поисковая система',
+12
View File
@@ -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': '搜索引擎',
+214 -1
View File
@@ -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<ModelStatsDto>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UsageLogRecordDto {
pub id: String,
pub timestamp: String,
pub session_id: String,
pub model: String,
pub provider: Option<String>,
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<UsageLogRecordDto>,
pub by_category: Vec<CategoryStatsDto>,
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<String> {
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<CostRecord>,
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<String, CategoryStatsDto> = 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<CategoryStatsDto> = 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<ModelStatsDto> = entry
.by_model
@@ -300,6 +433,32 @@ pub fn summary(config: &Config) -> Result<RpcOutcome<Value>> {
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<RpcOutcome<Value>> {
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();
+99 -3
View File
@@ -17,11 +17,29 @@ struct DailyHistoryParams {
days: Option<u32>,
}
#[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<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> ControllerFuture {
})
}
fn handle_cost_get_usage_log(params: Map<String, Value>) -> 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::<UsageLogParams>(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<Value>) -> Result<Value, String> {
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");
+36
View File
@@ -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<Vec<CostRecord>> {
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<CostRecord> = 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)