mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(cost): add usage spend insights (#3461)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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': 'محرك البحث',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'खोज इंजन',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '검색 엔진',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Поисковая система',
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user