feat(cost): add settings cost dashboard with global tracker, dashboard RPCs, and charts (#2762)

This commit is contained in:
YellowSnnowmann
2026-05-27 18:36:58 -07:00
committed by GitHub
parent 129abfe8a4
commit 4c1194241d
45 changed files with 4267 additions and 12 deletions
+1
View File
@@ -106,6 +106,7 @@
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^2.15.0",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import BudgetSummary from './BudgetSummary';
describe('<BudgetSummary />', () => {
it('renders all four metric tiles and the status badge', () => {
render(
<BudgetSummary
currency="USD"
periodTotalUsd={42.5}
monthlyPaceUsd={181.25}
budgetLimitMonthlyUsd={200}
monthToDateUsd={50}
utilization={0.25}
status="normal"
/>
);
expect(screen.getByTestId('metric-total-spend')).toBeInTheDocument();
expect(screen.getByTestId('metric-monthly-pace')).toBeInTheDocument();
expect(screen.getByTestId('metric-budget-limit')).toBeInTheDocument();
expect(screen.getByTestId('utilization-fill')).toBeInTheDocument();
expect(screen.getByTestId('budget-status-badge')).toHaveTextContent(/on track/i);
});
it('reflects "warning" status on the badge', () => {
render(
<BudgetSummary
currency="USD"
periodTotalUsd={1}
monthlyPaceUsd={1}
budgetLimitMonthlyUsd={10}
monthToDateUsd={8}
utilization={0.85}
status="warning"
/>
);
expect(screen.getByTestId('budget-status-badge')).toHaveTextContent(/warning/i);
});
it('reflects "exceeded" status and clamps utilisation bar to 100%', () => {
render(
<BudgetSummary
currency="USD"
periodTotalUsd={1}
monthlyPaceUsd={1}
budgetLimitMonthlyUsd={10}
monthToDateUsd={15}
utilization={1}
status="exceeded"
/>
);
expect(screen.getByTestId('budget-status-badge')).toHaveTextContent(/over budget/i);
expect(screen.getByTestId('utilization-fill')).toHaveStyle({ width: '100%' });
});
it('falls back to "No limit set" when budget is zero', () => {
render(
<BudgetSummary
currency="USD"
periodTotalUsd={5}
monthlyPaceUsd={5}
budgetLimitMonthlyUsd={0}
monthToDateUsd={0}
utilization={0}
status="normal"
/>
);
expect(screen.getByTestId('metric-budget-limit')).toHaveTextContent(/no limit set/i);
});
});
@@ -0,0 +1,201 @@
import type { ReactNode } from 'react';
import type { BudgetStatus } from '../../hooks/useCostDashboard';
import { useT } from '../../lib/i18n/I18nContext';
import { formatCurrency } from './formatCurrency';
export interface BudgetSummaryProps {
currency: string;
periodTotalUsd: number;
monthlyPaceUsd: number;
budgetLimitMonthlyUsd: number;
monthToDateUsd: number;
utilization: number;
status: BudgetStatus;
}
const STATUS_BG: Record<BudgetStatus, string> = {
normal: 'bg-sage-500',
warning: 'bg-amber-500',
exceeded: 'bg-coral-500',
};
const STATUS_TEXT: Record<BudgetStatus, string> = {
normal: 'text-sage-600 dark:text-sage-300',
warning: 'text-amber-600 dark:text-amber-300',
exceeded: 'text-coral-600 dark:text-coral-300',
};
const STATUS_LABEL_KEY: Record<BudgetStatus, string> = {
normal: 'settings.costDashboard.budgetNormal',
warning: 'settings.costDashboard.budgetWarning',
exceeded: 'settings.costDashboard.budgetExceeded',
};
const BudgetSummary = ({
currency,
periodTotalUsd,
monthlyPaceUsd,
budgetLimitMonthlyUsd,
monthToDateUsd,
utilization,
status,
}: BudgetSummaryProps) => {
const { t } = useT();
const utilizationPct = Math.round(utilization * 100);
const utilizationClamped = Math.min(100, Math.max(0, utilizationPct));
return (
<section
data-testid="cost-dashboard-summary"
className="grid grid-cols-1 md:grid-cols-3 gap-3"
aria-label={t('settings.costDashboard.summaryAriaLabel')}>
{/* Hero tile: 7-day total + status badge + progress bar */}
<div
data-testid="metric-total-spend"
className="md:col-span-2 rounded-2xl border border-stone-200 dark:border-neutral-800 bg-gradient-to-br from-ocean-50 to-white dark:from-neutral-900 dark:to-neutral-950 p-5 flex flex-col gap-3 shadow-soft">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-stone-500 dark:text-neutral-400">
<WalletIcon className="h-4 w-4" />
<span>{t('settings.costDashboard.totalSpend')}</span>
</div>
<span
data-testid="budget-status-badge"
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium text-white ${STATUS_BG[status]}`}>
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full bg-white/80 animate-pulse"
/>
{t(STATUS_LABEL_KEY[status])}
</span>
</div>
<div className="flex items-baseline gap-3">
<span className="text-3xl md:text-4xl font-semibold tabular-nums text-stone-900 dark:text-neutral-50">
{formatCurrency(periodTotalUsd, currency)}
</span>
<span className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.lastSevenDays')}
</span>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-[11px] text-stone-500 dark:text-neutral-400">
<span>
{budgetLimitMonthlyUsd > 0
? `${formatCurrency(monthToDateUsd, currency)} ${t('settings.costDashboard.utilizationOf')} ${formatCurrency(budgetLimitMonthlyUsd, currency)} ${t('settings.costDashboard.thisMonth')}`
: `${formatCurrency(monthToDateUsd, currency)} ${t('settings.costDashboard.thisMonth')}`}
</span>
<span className={`font-medium tabular-nums ${STATUS_TEXT[status]}`}>
{`${utilizationPct}%`}
</span>
</div>
<div
aria-hidden
className="h-2 w-full rounded-full bg-stone-200 dark:bg-neutral-800 overflow-hidden">
<div
data-testid="utilization-fill"
className={`h-full rounded-full transition-all duration-300 ${STATUS_BG[status]}`}
style={{ width: `${utilizationClamped}%` }}
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-1 gap-3">
<SmallMetric
icon={<TrendingUpIcon className="h-4 w-4" />}
label={t('settings.costDashboard.monthlyPace')}
value={formatCurrency(monthlyPaceUsd, currency)}
hint={t('settings.costDashboard.monthlyPaceHint')}
testId="metric-monthly-pace"
/>
<SmallMetric
icon={<TargetIcon className="h-4 w-4" />}
label={t('settings.costDashboard.budgetLimit')}
value={
budgetLimitMonthlyUsd > 0
? formatCurrency(budgetLimitMonthlyUsd, currency)
: t('settings.costDashboard.noBudget')
}
hint={t('settings.costDashboard.budgetLimitHint')}
testId="metric-budget-limit"
/>
</div>
</section>
);
};
interface SmallMetricProps {
icon: ReactNode;
label: string;
value: string;
hint: string;
testId: string;
}
const SmallMetric = ({ icon, label, value, hint, testId }: SmallMetricProps) => (
<div
data-testid={testId}
title={hint}
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-3 flex flex-col gap-1 hover:border-ocean-300 dark:hover:border-ocean-700 transition-colors">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{icon}
<span>{label}</span>
</div>
<span className="text-lg font-semibold tabular-nums text-stone-800 dark:text-neutral-100">
{value}
</span>
</div>
);
interface IconProps {
className?: string;
}
const WalletIcon = ({ className }: IconProps) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden>
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
<circle cx="17" cy="14" r="1.5" />
</svg>
);
const TrendingUpIcon = ({ className }: IconProps) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
);
const TargetIcon = ({ className }: IconProps) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
);
export default BudgetSummary;
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import ChartTooltip from './ChartTooltip';
describe('<ChartTooltip />', () => {
it('renders title, rows, and a coloured swatch when colour is provided', () => {
render(
<ChartTooltip
title="Wed, May 27"
rows={[
{ label: 'Cost', value: '$1.25', color: '#4A83DD' },
{ label: 'Requests', value: '3' },
]}
/>
);
const tooltip = screen.getByTestId('chart-tooltip');
expect(tooltip).toHaveTextContent('Wed, May 27');
expect(tooltip).toHaveTextContent('Cost');
expect(tooltip).toHaveTextContent('$1.25');
expect(tooltip).toHaveTextContent('Requests');
expect(tooltip).toHaveTextContent('3');
});
it('renders the optional footer when supplied', () => {
render(
<ChartTooltip
title="Today"
rows={[{ label: 'X', value: '1' }]}
footer="Daily target: $3.33"
/>
);
expect(screen.getByTestId('chart-tooltip')).toHaveTextContent('Daily target: $3.33');
});
it('omits the footer block when none provided', () => {
const { container } = render(
<ChartTooltip title="No footer" rows={[{ label: 'X', value: '1' }]} />
);
// Footer is a div with the border-t class; assert it is not rendered.
expect(container.querySelector('.border-t')).toBeNull();
});
});
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
export interface ChartTooltipRow {
label: string;
value: string;
/** CSS colour for the legend swatch. */
color?: string;
}
export interface ChartTooltipProps {
title: string;
rows: ChartTooltipRow[];
footer?: ReactNode;
}
/**
* Shared dark-mode-aware tooltip body for the recharts panels.
* Recharts' default tooltip is a white box that looks broken on the
* dashboard's dark background — this component replaces it with a card
* styled to match the rest of the panel.
*/
const ChartTooltip = ({ title, rows, footer }: ChartTooltipProps) => (
<div
role="tooltip"
data-testid="chart-tooltip"
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white/95 dark:bg-neutral-900/95 backdrop-blur-sm shadow-soft px-3 py-2 text-xs text-stone-800 dark:text-neutral-100">
<div className="font-medium mb-1 text-stone-700 dark:text-neutral-200">{title}</div>
<ul className="space-y-0.5">
{rows.map(row => (
<li key={row.label} className="flex items-center gap-2">
{row.color && (
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: row.color }}
/>
)}
<span className="text-stone-500 dark:text-neutral-400">{row.label}</span>
<span className="ml-auto tabular-nums font-medium">{row.value}</span>
</li>
))}
</ul>
{footer && (
<div className="mt-1 pt-1 border-t border-stone-200/60 dark:border-neutral-800 text-[10px] text-stone-500 dark:text-neutral-400">
{footer}
</div>
)}
</div>
);
export default ChartTooltip;
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { colorForCost } from './CostBarChart';
describe('colorForCost', () => {
const warn = 0.8;
const alert = 0.95;
it('returns the normal hue when the daily target is zero (no budget set)', () => {
expect(colorForCost(10, 0, warn, alert)).toBe('#4A83DD');
});
it('flips to amber once spend crosses the warn threshold', () => {
// daily target $1.00, warn @ 80% → 0.85 triggers amber
expect(colorForCost(0.85, 1, warn, alert)).toBe('#F5A524');
});
it('flips to red once spend reaches the alert threshold', () => {
expect(colorForCost(0.95, 1, warn, alert)).toBe('#E5484D');
});
it('stays blue when well under warn', () => {
expect(colorForCost(0.1, 1, warn, alert)).toBe('#4A83DD');
});
});
@@ -0,0 +1,207 @@
import {
Bar,
BarChart,
Cell,
LabelList,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { CostDashboardDay } from '../../hooks/useCostDashboard';
import { useT } from '../../lib/i18n/I18nContext';
import ChartTooltip from './ChartTooltip';
import { dayOfMonth, formatCurrency, longDateLabel, shortDayLabel } from './formatCurrency';
export interface CostBarChartProps {
days: CostDashboardDay[];
currency: string;
/** Monthly budget in USD; used to derive a daily target. */
budgetLimitMonthlyUsd: number;
/** Fraction of daily target that flips bar colour to amber. */
warnThreshold: number;
/** Fraction of daily target that flips bar colour to red. */
alertThreshold: number;
}
const NORMAL_FILL = '#4A83DD';
const WARN_FILL = '#F5A524';
const ALERT_FILL = '#E5484D';
const TARGET_STROKE = '#94A3B8';
export function colorForCost(
cost: number,
dailyTarget: number,
warnThreshold: number,
alertThreshold: number
): string {
if (dailyTarget <= 0) return NORMAL_FILL;
const ratio = cost / dailyTarget;
if (ratio >= alertThreshold) return ALERT_FILL;
if (ratio >= warnThreshold) return WARN_FILL;
return NORMAL_FILL;
}
interface ChartPoint {
date: string;
label: string;
dayNumber: string;
cost: number;
requestCount: number;
fill: string;
isToday: boolean;
}
const CostBarChart = ({
days,
currency,
budgetLimitMonthlyUsd,
warnThreshold,
alertThreshold,
}: CostBarChartProps) => {
const { t } = useT();
const dailyTarget = budgetLimitMonthlyUsd > 0 ? budgetLimitMonthlyUsd / 30 : 0;
const todayDate = days.length > 0 ? days[days.length - 1].date : null;
const chartData: ChartPoint[] = days.map(day => ({
date: day.date,
label: shortDayLabel(day.date),
dayNumber: dayOfMonth(day.date),
cost: Number(day.cost_usd.toFixed(4)),
requestCount: day.request_count,
fill: colorForCost(day.cost_usd, dailyTarget, warnThreshold, alertThreshold),
isToday: day.date === todayDate,
}));
return (
<div data-testid="cost-bar-chart" className="w-full">
{dailyTarget > 0 && (
<div className="text-[11px] text-stone-500 dark:text-neutral-400 mb-1 flex items-center gap-1.5">
<span
aria-hidden
className="inline-block h-px w-3 border-t border-dashed border-stone-400 dark:border-neutral-500"
/>
<span>
{`${t('settings.costDashboard.dailyTarget')}: ${formatCurrency(dailyTarget, currency)}`}
</span>
</div>
)}
<div className="w-full h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 16, right: 8, left: 0, bottom: 0 }}>
<XAxis
dataKey="label"
stroke="currentColor"
fontSize={11}
tickLine={false}
axisLine={false}
tick={{ fill: 'currentColor', opacity: 0.7 }}
/>
<XAxis
dataKey="dayNumber"
xAxisId="day"
stroke="currentColor"
fontSize={10}
tickLine={false}
axisLine={false}
tick={{ fill: 'currentColor', opacity: 0.45 }}
height={14}
/>
<YAxis
stroke="currentColor"
fontSize={11}
tickLine={false}
axisLine={false}
width={52}
tick={{ fill: 'currentColor', opacity: 0.7 }}
tickFormatter={(v: number) => formatCurrency(v, currency)}
/>
<Tooltip
cursor={{ fill: 'rgba(150,150,150,0.10)' }}
content={props => {
const item = props.payload?.[0]?.payload as ChartPoint | undefined;
if (!item) return null;
return (
<ChartTooltip
title={longDateLabel(item.date)}
rows={[
{
label: t('settings.costDashboard.cost'),
value: formatCurrency(item.cost, currency),
color: item.fill,
},
{
label: t('settings.costDashboard.requests'),
value: String(item.requestCount),
},
]}
footer={
dailyTarget > 0
? `${t('settings.costDashboard.dailyTarget')}: ${formatCurrency(dailyTarget, currency)}`
: undefined
}
/>
);
}}
/>
{dailyTarget > 0 && (
<ReferenceLine
y={dailyTarget}
stroke={TARGET_STROKE}
strokeDasharray="4 4"
strokeWidth={1}
ifOverflow="extendDomain"
/>
)}
<Bar dataKey="cost" radius={[6, 6, 0, 0]} isAnimationActive={false} maxBarSize={56}>
{chartData.map(entry => (
<Cell
key={entry.date}
fill={entry.fill}
stroke={entry.isToday ? '#0F172A' : 'transparent'}
strokeOpacity={entry.isToday ? 0.15 : 0}
/>
))}
<LabelList
dataKey="isToday"
position="top"
content={({ x, y, width, value }) => {
if (!value) return null;
const cx = Number(x ?? 0) + Number(width ?? 0) / 2;
const cy = Math.max(0, Number(y ?? 0) - 6);
return (
<g>
<rect
x={cx - 22}
y={cy - 12}
width={44}
height={14}
rx={7}
ry={7}
fill="#4A83DD"
fillOpacity={0.12}
/>
<text
x={cx}
y={cy - 2}
textAnchor="middle"
fontSize={9}
fontWeight={600}
fill="#4A83DD">
{t('settings.costDashboard.todayBadge')}
</text>
</g>
);
}}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default CostBarChart;
@@ -0,0 +1,125 @@
import { configureStore } from '@reduxjs/toolkit';
import { render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
import CostDashboardPanel from './CostDashboardPanel';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('recharts', async () => {
// Recharts uses ResponsiveContainer which needs DOM measurements that
// jsdom does not provide. Stub the few primitives the dashboard pulls in
// with passthrough divs so we can assert on the panel structure without
// pulling the real chart pipeline into the test runtime.
const passthrough = ({ children }: { children?: React.ReactNode }) => (
<div data-stub="recharts">{children}</div>
);
const stub = () => null;
return {
Bar: passthrough,
BarChart: passthrough,
Cell: stub,
LabelList: stub,
Legend: stub,
ReferenceLine: stub,
ResponsiveContainer: passthrough,
Tooltip: stub,
XAxis: stub,
YAxis: stub,
};
});
const mockedCall = callCoreRpc as unknown as ReturnType<typeof vi.fn>;
function makeStore() {
return configureStore({ reducer: { locale: (state = { current: 'en' }) => state } });
}
function renderPanel() {
return render(
<Provider store={makeStore()}>
<MemoryRouter initialEntries={['/settings/cost-dashboard']}>
<CostDashboardPanel />
</MemoryRouter>
</Provider>
);
}
describe('<CostDashboardPanel />', () => {
beforeEach(() => {
mockedCall.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
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,
},
],
});
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();
});
it('shows the disabled hint when the payload reports enabled=false', async () => {
mockedCall.mockResolvedValueOnce({
days: [],
period_total_usd: 0,
monthly_pace_usd: 0,
budget_limit_monthly_usd: 0,
month_to_date_usd: 0,
budget_utilization: 0,
budget_status: 'normal',
currency: 'USD',
warn_threshold: 0.8,
alert_threshold: 0.95,
enabled: false,
by_model: [],
});
renderPanel();
await waitFor(() => expect(screen.getByTestId('cost-dashboard-disabled')).toBeInTheDocument());
});
it('surfaces the error state when the RPC rejects', async () => {
mockedCall.mockRejectedValueOnce(new Error('rpc down'));
renderPanel();
await waitFor(() =>
expect(screen.getByTestId('cost-dashboard-error')).toHaveTextContent(/rpc down/)
);
});
});
@@ -0,0 +1,184 @@
import { useEffect, useMemo, useState } from 'react';
import { useCostDashboard } 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 ModelCostTable from './ModelCostTable';
import TokenUsageChart from './TokenUsageChart';
const CostDashboardPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { data, isLoading, isFetching, error, lastUpdated, refetch } = useCostDashboard();
const hasAnyCost = useMemo(
() => (data ? data.days.some(day => day.cost_usd > 0) : false),
[data]
);
// Tick once a second so the "Updated Ns ago" pill stays fresh without
// re-rendering the entire chart pipeline.
const [, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick(n => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
return (
<div className="z-10 relative" data-testid="cost-dashboard-panel">
<SettingsHeader
title={t('settings.costDashboard.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-stone-500 dark:text-neutral-400 max-w-prose">
{t('settings.costDashboard.subtitle')}
</p>
<div className="flex items-center gap-2 shrink-0">
{lastUpdated !== 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'}`}
/>
{`${t('settings.costDashboard.updated')} ${relativeTime(lastUpdated, t)}`}
</span>
)}
<button
type="button"
data-testid="cost-dashboard-refresh"
onClick={() => void refetch()}
disabled={isFetching}
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' : ''}`} />
<span>{t('settings.costDashboard.refresh')}</span>
</button>
</div>
</div>
{error && (
<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-error">
{error}
</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"
data-testid="cost-dashboard-disabled">
{t('settings.costDashboard.disabledHint')}
</div>
)}
{!data && isLoading && <DashboardSkeleton />}
{data && (
<>
<BudgetSummary
currency={data.currency}
periodTotalUsd={data.period_total_usd}
monthlyPaceUsd={data.monthly_pace_usd}
budgetLimitMonthlyUsd={data.budget_limit_monthly_usd}
monthToDateUsd={data.month_to_date_usd}
utilization={data.budget_utilization}
status={data.budget_status}
/>
<section
data-testid="cost-dashboard-cost-chart"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-2 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{t('settings.costDashboard.sevenDayCost')}
</h2>
<span className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.utcNote')}
</span>
</header>
<CostBarChart
days={data.days}
currency={data.currency}
budgetLimitMonthlyUsd={data.budget_limit_monthly_usd}
warnThreshold={data.warn_threshold}
alertThreshold={data.alert_threshold}
/>
</section>
<section
data-testid="cost-dashboard-token-chart"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-2 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{t('settings.costDashboard.sevenDayTokens')}
</h2>
<span className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.stackedNote')}
</span>
</header>
<TokenUsageChart days={data.days} />
</section>
<section
data-testid="cost-dashboard-model-table"
className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 bg-white/40 dark:bg-neutral-900/40">
<header className="mb-2">
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{t('settings.costDashboard.modelBreakdown')}
</h2>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('settings.costDashboard.modelBreakdownHint')}
</p>
</header>
<ModelCostTable models={data.by_model} currency={data.currency} />
</section>
{!hasAnyCost && (
<div
data-testid="cost-dashboard-empty"
className="rounded-xl border border-dashed border-stone-300 dark:border-neutral-700 px-4 py-6 text-center">
<div className="text-sm font-medium text-stone-700 dark:text-neutral-200">
{t('settings.costDashboard.noData')}
</div>
<div className="text-[11px] text-stone-500 dark:text-neutral-400 mt-1">
{t('settings.costDashboard.noDataHint')}
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
interface IconProps {
className?: string;
}
const RefreshIcon = ({ className }: IconProps) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
<path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
</svg>
);
export default CostDashboardPanel;
@@ -0,0 +1,14 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import DashboardSkeleton from './DashboardSkeleton';
describe('<DashboardSkeleton />', () => {
it('renders the loading skeleton container with accessible status role', () => {
render(<DashboardSkeleton />);
const node = screen.getByTestId('cost-dashboard-skeleton');
expect(node).toBeInTheDocument();
expect(node).toHaveAttribute('role', 'status');
expect(node).toHaveAttribute('aria-live', 'polite');
});
});
@@ -0,0 +1,25 @@
/**
* Loading skeleton for the cost dashboard panel. Renders the same overall
* layout as the populated dashboard so the first paint doesn't reflow
* dramatically once data arrives.
*/
const DashboardSkeleton = () => (
<div
role="status"
aria-live="polite"
data-testid="cost-dashboard-skeleton"
className="space-y-4 animate-pulse">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="md:col-span-2 rounded-2xl border border-stone-200 dark:border-neutral-800 p-5 h-32 bg-stone-50 dark:bg-neutral-900/40" />
<div className="grid grid-cols-2 md:grid-cols-1 gap-3">
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 h-14 bg-stone-50 dark:bg-neutral-900/40" />
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 h-14 bg-stone-50 dark:bg-neutral-900/40" />
</div>
</div>
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 h-64 bg-stone-50 dark:bg-neutral-900/40" />
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 h-64 bg-stone-50 dark:bg-neutral-900/40" />
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 p-4 h-32 bg-stone-50 dark:bg-neutral-900/40" />
</div>
);
export default DashboardSkeleton;
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { CostDashboardModelStats } from '../../hooks/useCostDashboard';
import ModelCostTable from './ModelCostTable';
const sample: CostDashboardModelStats[] = [
{
model: 'anthropic/claude-sonnet-4',
cost_usd: 2.5,
total_tokens: 12000,
request_count: 4,
provider: 'anthropic',
percent_of_total: 50.0,
},
{
model: 'openai/gpt-5',
cost_usd: 1.0,
total_tokens: 4000,
request_count: 2,
provider: 'openai',
percent_of_total: 20.0,
},
];
describe('<ModelCostTable />', () => {
it('renders one row per model with cost, tokens, requests, and percent', () => {
render(<ModelCostTable models={sample} currency="USD" />);
expect(screen.getByTestId('model-row-anthropic/claude-sonnet-4')).toBeInTheDocument();
expect(screen.getByTestId('model-row-openai/gpt-5')).toBeInTheDocument();
expect(screen.getByText(/50\.0%/)).toBeInTheDocument();
expect(screen.getByText(/20\.0%/)).toBeInTheDocument();
// Model name suffix renders in the row (full id available via title attr).
expect(screen.getByText(/claude-sonnet-4/)).toBeInTheDocument();
expect(screen.getByTitle('anthropic/claude-sonnet-4')).toBeInTheDocument();
});
it('renders an empty-state row when no models are present', () => {
render(<ModelCostTable models={[]} currency="USD" />);
expect(screen.getByTestId('model-cost-table-empty')).toBeInTheDocument();
});
it('renders a dash when the provider is unknown', () => {
render(
<ModelCostTable
models={[
{
model: 'rogue-model',
cost_usd: 0.1,
total_tokens: 100,
request_count: 1,
provider: null,
percent_of_total: 1,
},
]}
currency="USD"
/>
);
expect(screen.getByText('—')).toBeInTheDocument();
});
});
@@ -0,0 +1,132 @@
import type { CostDashboardModelStats } from '../../hooks/useCostDashboard';
import { useT } from '../../lib/i18n/I18nContext';
import { formatCurrency, formatTokens } from './formatCurrency';
export interface ModelCostTableProps {
models: CostDashboardModelStats[];
currency: string;
}
const PROVIDER_PALETTE: Record<string, string> = {
anthropic:
'bg-[#D97757]/15 text-[#D97757] dark:bg-[#D97757]/20 dark:text-[#F5A584] ring-[#D97757]/30',
openai: 'bg-sage-500/15 text-sage-700 dark:bg-sage-500/20 dark:text-sage-300 ring-sage-500/30',
google:
'bg-ocean-500/15 text-ocean-700 dark:bg-ocean-500/20 dark:text-ocean-300 ring-ocean-500/30',
fireworks:
'bg-coral-500/15 text-coral-700 dark:bg-coral-500/20 dark:text-coral-300 ring-coral-500/30',
groq: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300 ring-amber-500/30',
};
const PROVIDER_FALLBACK =
'bg-stone-200 text-stone-700 dark:bg-neutral-800 dark:text-neutral-300 ring-stone-300 dark:ring-neutral-700';
function providerChipClass(provider: string | null): string {
if (!provider) return PROVIDER_FALLBACK;
return PROVIDER_PALETTE[provider.toLowerCase()] ?? PROVIDER_FALLBACK;
}
const ModelCostTable = ({ models, currency }: ModelCostTableProps) => {
const { t } = useT();
if (models.length === 0) {
return (
<div
data-testid="model-cost-table-empty"
className="text-xs text-stone-500 dark:text-neutral-400 italic py-2">
{t('settings.costDashboard.noModels')}
</div>
);
}
return (
<div className="overflow-x-auto -mx-1" data-testid="model-cost-table">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wide text-stone-500 dark:text-neutral-400 border-b border-stone-200 dark:border-neutral-800">
<Th>{t('settings.costDashboard.model')}</Th>
<Th>{t('settings.costDashboard.provider')}</Th>
<Th align="right">{t('settings.costDashboard.tokens')}</Th>
<Th align="right">{t('settings.costDashboard.requests')}</Th>
<Th align="right">{t('settings.costDashboard.cost')}</Th>
<Th align="right">{t('settings.costDashboard.percentOfTotal')}</Th>
</tr>
</thead>
<tbody>
{models.map(row => {
const modelName = row.model.includes('/')
? row.model.split('/').slice(1).join('/')
: row.model;
const sharePct = Math.max(0, Math.min(100, row.percent_of_total));
return (
<tr
key={row.model}
data-testid={`model-row-${row.model}`}
className="group border-b border-stone-100 dark:border-neutral-800/60 last:border-0 hover:bg-stone-50/60 dark:hover:bg-neutral-800/40 transition-colors">
<Td>
<div
className="font-medium text-stone-800 dark:text-neutral-100 truncate max-w-[16rem]"
title={row.model}>
{modelName}
</div>
</Td>
<Td>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset ${providerChipClass(row.provider)}`}>
{row.provider ?? t('settings.costDashboard.unknownProvider')}
</span>
</Td>
<Td align="right">
<span className="tabular-nums text-stone-700 dark:text-neutral-200">
{formatTokens(row.total_tokens)}
</span>
</Td>
<Td align="right">
<span className="tabular-nums text-stone-700 dark:text-neutral-200">
{row.request_count}
</span>
</Td>
<Td align="right">
<span className="tabular-nums font-medium text-stone-900 dark:text-neutral-50">
{formatCurrency(row.cost_usd, currency)}
</span>
</Td>
<Td align="right">
<div className="flex items-center justify-end gap-2">
<div
aria-hidden
className="h-1 w-12 rounded-full bg-stone-200 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full rounded-full bg-ocean-500"
style={{ width: `${sharePct}%` }}
/>
</div>
<span className="tabular-nums w-10 text-right text-stone-600 dark:text-neutral-300">
{`${sharePct.toFixed(1)}%`}
</span>
</div>
</Td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
interface CellProps {
children: React.ReactNode;
align?: 'left' | 'right';
}
const Th = ({ children, align = 'left' }: CellProps) => (
<th className={`py-2 px-2 font-medium ${align === 'right' ? 'text-right' : 'text-left'}`}>
{children}
</th>
);
const Td = ({ children, align = 'left' }: CellProps) => (
<td className={`py-2 px-2 ${align === 'right' ? 'text-right' : 'text-left'}`}>{children}</td>
);
export default ModelCostTable;
@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CostDashboardDay } from '../../hooks/useCostDashboard';
import TokenUsageChart from './TokenUsageChart';
vi.mock('recharts', () => {
// Stub recharts primitives — jsdom lacks the layout measurements
// recharts' ResponsiveContainer needs. Passthrough divs are enough
// for the smoke test below; we only assert the wrapper renders.
const passthrough = ({ children }: { children?: React.ReactNode }) => (
<div data-stub="recharts">{children}</div>
);
const stub = () => null;
return {
Bar: stub,
BarChart: passthrough,
Legend: stub,
ResponsiveContainer: passthrough,
Tooltip: stub,
XAxis: stub,
YAxis: stub,
};
});
const days: CostDashboardDay[] = 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: [],
}));
describe('<TokenUsageChart />', () => {
it('renders the chart wrapper with the expected test-id', () => {
render(<TokenUsageChart days={days} />);
expect(screen.getByTestId('token-usage-chart')).toBeInTheDocument();
});
it('renders gracefully when given an empty day list', () => {
render(<TokenUsageChart days={[]} />);
expect(screen.getByTestId('token-usage-chart')).toBeInTheDocument();
});
});
@@ -0,0 +1,122 @@
import { Bar, BarChart, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { CostDashboardDay } from '../../hooks/useCostDashboard';
import { useT } from '../../lib/i18n/I18nContext';
import ChartTooltip from './ChartTooltip';
import { dayOfMonth, formatTokens, longDateLabel, shortDayLabel } from './formatCurrency';
export interface TokenUsageChartProps {
days: CostDashboardDay[];
}
const INPUT_FILL = '#4A83DD';
const OUTPUT_FILL = '#7BB48E';
interface TokenPoint {
date: string;
label: string;
dayNumber: string;
input: number;
output: number;
total: number;
}
const TokenUsageChart = ({ days }: TokenUsageChartProps) => {
const { t } = useT();
const data: TokenPoint[] = days.map(d => ({
date: d.date,
label: shortDayLabel(d.date),
dayNumber: dayOfMonth(d.date),
input: d.input_tokens,
output: d.output_tokens,
total: d.total_tokens,
}));
return (
<div data-testid="token-usage-chart" className="w-full h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis
dataKey="label"
stroke="currentColor"
fontSize={11}
tickLine={false}
axisLine={false}
tick={{ fill: 'currentColor', opacity: 0.7 }}
/>
<XAxis
dataKey="dayNumber"
xAxisId="day"
stroke="currentColor"
fontSize={10}
tickLine={false}
axisLine={false}
tick={{ fill: 'currentColor', opacity: 0.45 }}
height={14}
/>
<YAxis
stroke="currentColor"
fontSize={11}
tickLine={false}
axisLine={false}
width={52}
tick={{ fill: 'currentColor', opacity: 0.7 }}
tickFormatter={(v: number) => formatTokens(v)}
/>
<Tooltip
cursor={{ fill: 'rgba(150,150,150,0.10)' }}
content={props => {
const item = props.payload?.[0]?.payload as TokenPoint | undefined;
if (!item) return null;
return (
<ChartTooltip
title={longDateLabel(item.date)}
rows={[
{
label: t('settings.costDashboard.inputTokens'),
value: formatTokens(item.input),
color: INPUT_FILL,
},
{
label: t('settings.costDashboard.outputTokens'),
value: formatTokens(item.output),
color: OUTPUT_FILL,
},
{ label: t('settings.costDashboard.tokens'), value: formatTokens(item.total) },
]}
/>
);
}}
/>
<Legend
formatter={value =>
String(value) === 'input'
? t('settings.costDashboard.inputTokens')
: t('settings.costDashboard.outputTokens')
}
wrapperStyle={{ fontSize: '11px' }}
iconType="circle"
/>
<Bar
dataKey="input"
stackId="tokens"
fill={INPUT_FILL}
radius={[0, 0, 0, 0]}
isAnimationActive={false}
maxBarSize={56}
/>
<Bar
dataKey="output"
stackId="tokens"
fill={OUTPUT_FILL}
radius={[6, 6, 0, 0]}
isAnimationActive={false}
maxBarSize={56}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
};
export default TokenUsageChart;
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { formatCurrency, formatTokens, relativeTime, shortDayLabel } from './formatCurrency';
describe('formatCurrency', () => {
it('formats positive USD amounts with two decimals under 100', () => {
expect(formatCurrency(12.5, 'USD')).toMatch(/\$12\.50/);
});
it('drops fractional digits at or above 100', () => {
expect(formatCurrency(150, 'USD')).toMatch(/\$150/);
});
it('falls back to USD for unrecognised currency labels', () => {
expect(formatCurrency(5, 'NOT-A-CURRENCY')).toMatch(/\$5\.00/);
});
it('treats non-finite input as zero', () => {
expect(formatCurrency(Number.NaN, 'USD')).toMatch(/0/);
expect(formatCurrency(Number.POSITIVE_INFINITY, 'USD')).toMatch(/0/);
});
it('honours empty currency string by falling back to USD', () => {
expect(formatCurrency(7, '')).toMatch(/\$7\.00/);
});
});
describe('formatTokens', () => {
it('renders zero / negative as "0"', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(-5)).toBe('0');
});
it('rounds integers under 1k', () => {
expect(formatTokens(123.7)).toBe('124');
});
it('uses K and M suffixes', () => {
expect(formatTokens(1_500)).toBe('1.5K');
expect(formatTokens(2_500_000)).toBe('2.5M');
});
});
describe('shortDayLabel', () => {
it('returns a 3-letter weekday for a valid ISO date', () => {
const label = shortDayLabel('2026-05-27');
expect(label.length).toBeGreaterThanOrEqual(2);
});
it('falls back to the suffix for malformed input', () => {
const label = shortDayLabel('not-a-date');
expect(typeof label).toBe('string');
});
});
describe('relativeTime', () => {
// Stub translator: returns the key untouched so the test can assert
// both the key routing and the {value} placeholder substitution.
const t = (key: string) => {
if (key === 'settings.costDashboard.justNow') return 'Just now';
if (key === 'settings.costDashboard.secondsAgo') return '{value}s ago';
if (key === 'settings.costDashboard.minutesAgo') return '{value}m ago';
if (key === 'settings.costDashboard.hoursAgo') return '{value}h ago';
if (key === 'settings.costDashboard.daysAgo') return '{value}d ago';
return key;
};
const now = 1_700_000_000_000;
it('returns "Just now" within 5 seconds', () => {
expect(relativeTime(now - 2_000, t, now)).toBe('Just now');
});
it('renders seconds branch with substituted value', () => {
expect(relativeTime(now - 30_000, t, now)).toBe('30s ago');
});
it('renders minutes branch', () => {
expect(relativeTime(now - 5 * 60_000, t, now)).toBe('5m ago');
});
it('renders hours branch', () => {
expect(relativeTime(now - 3 * 60 * 60_000, t, now)).toBe('3h ago');
});
it('renders days branch', () => {
expect(relativeTime(now - 2 * 24 * 60 * 60_000, t, now)).toBe('2d ago');
});
it('returns the raw translation key when missing (i18n fallback)', () => {
const passthrough = (key: string) => key;
expect(relativeTime(now - 1_000, passthrough, now)).toBe('settings.costDashboard.justNow');
});
it('replaces every {value} placeholder in a translation, not just the first', () => {
// Some locales repeat the number for clarity (e.g. "5m ago — 5 minutes").
// replaceAll must substitute every occurrence; the previous .replace
// implementation left the trailing token literal.
const repeating = (key: string) => {
if (key === 'settings.costDashboard.minutesAgo') return '{value}m ago ({value} min)';
return key;
};
expect(relativeTime(now - 5 * 60_000, repeating, now)).toBe('5m ago (5 min)');
});
});
@@ -0,0 +1,113 @@
/**
* Format a USD-denominated amount with the requested display currency label.
* Falls back to the locale "USD" formatter when the configured currency
* code is not a valid ISO-4217 currency Intl supports — this keeps
* arbitrary display labels (e.g. "USD ($)") from throwing.
*/
export function formatCurrency(amountUsd: number, currency: string): string {
const safe = Number.isFinite(amountUsd) ? amountUsd : 0;
const normalized = currency?.trim() ? currency.trim().toUpperCase() : 'USD';
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: normalized,
maximumFractionDigits: safe >= 100 ? 0 : 2,
}).format(safe);
} catch {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: safe >= 100 ? 0 : 2,
}).format(safe);
}
}
export function formatTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0';
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return Math.round(n).toString();
}
export function shortDayLabel(isoDate: string): string {
try {
const date = new Date(`${isoDate}T00:00:00Z`);
return new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone: 'UTC' }).format(date);
} catch {
return isoDate.slice(5);
}
}
/**
* Full long-form date label for tooltips (e.g. "Wed, May 27").
* Falls back to the raw ISO string when parsing fails.
*/
export function longDateLabel(isoDate: string): string {
try {
const date = new Date(`${isoDate}T00:00:00Z`);
return new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}).format(date);
} catch {
return isoDate;
}
}
/**
* Day-of-month number for the X-axis sub-line, in UTC. Returns the
* trailing two digits of the ISO date on any parse failure.
*/
export function dayOfMonth(isoDate: string): string {
try {
const date = new Date(`${isoDate}T00:00:00Z`);
return new Intl.DateTimeFormat(undefined, { day: 'numeric', timeZone: 'UTC' }).format(date);
} catch {
return isoDate.slice(-2);
}
}
/**
* i18n translator signature accepted by [`relativeTime`] — matches the
* shape of `useT()`'s `t` so callers can pass it directly. The function
* is invoked with a string key and is expected to return the localised
* value (or the key itself when no translation is available).
*/
export type RelativeTimeTranslator = (key: string, fallback?: string) => string;
/**
* Human-friendly relative-time string for the "updated Ns ago" pill.
*
* Localised via the i18n translator the caller hands in — never returns
* hard-coded English. Keys consumed (all under `settings.costDashboard.*`):
* `justNow`, `secondsAgo`, `minutesAgo`, `hoursAgo`, `daysAgo`. The
* three numeric variants use a literal `{value}` placeholder so locales
* can position the number naturally; the placeholder is substituted
* verbatim before return.
*
* Caps at 60s/60m/24h boundaries; returns the localised "Just now"
* within 5s to avoid a flickering "0s ago" right after a refetch.
*/
export function relativeTime(
timestampMs: number,
t: RelativeTimeTranslator,
nowMs: number = Date.now()
): string {
const deltaSec = Math.max(0, Math.floor((nowMs - timestampMs) / 1000));
if (deltaSec < 5) return t('settings.costDashboard.justNow');
if (deltaSec < 60) {
return t('settings.costDashboard.secondsAgo').replaceAll('{value}', String(deltaSec));
}
const deltaMin = Math.floor(deltaSec / 60);
if (deltaMin < 60) {
return t('settings.costDashboard.minutesAgo').replaceAll('{value}', String(deltaMin));
}
const deltaHr = Math.floor(deltaMin / 60);
if (deltaHr < 24) {
return t('settings.costDashboard.hoursAgo').replaceAll('{value}', String(deltaHr));
}
const deltaDay = Math.floor(deltaHr / 24);
return t('settings.costDashboard.daysAgo').replaceAll('{value}', String(deltaDay));
}
+69
View File
@@ -0,0 +1,69 @@
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../services/coreRpcClient';
import { type CostDashboardPayload, useCostDashboard } from './useCostDashboard';
vi.mock('../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
const mockedCall = callCoreRpc as unknown as ReturnType<typeof vi.fn>;
const fixture: CostDashboardPayload = {
days: [
{
date: '2026-05-21',
cost_usd: 0,
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
request_count: 0,
by_model: [],
},
],
period_total_usd: 0,
monthly_pace_usd: 0,
budget_limit_monthly_usd: 100,
month_to_date_usd: 0,
budget_utilization: 0,
budget_status: 'normal',
currency: 'USD',
warn_threshold: 0.8,
alert_threshold: 0.95,
enabled: true,
by_model: [],
};
describe('useCostDashboard', () => {
beforeEach(() => {
mockedCall.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('calls openhuman.cost_get_dashboard and surfaces the payload', async () => {
mockedCall.mockResolvedValueOnce(fixture);
const { result } = renderHook(() => useCostDashboard({ paused: true }));
await waitFor(() => expect(result.current.data).not.toBeNull());
expect(mockedCall).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.cost_get_dashboard' })
);
expect(result.current.error).toBeNull();
expect(result.current.data?.currency).toBe('USD');
});
it('unwraps RpcOutcome `{ result, logs }` envelopes', async () => {
mockedCall.mockResolvedValueOnce({ result: fixture, logs: ['info'] });
const { result } = renderHook(() => useCostDashboard({ paused: true }));
await waitFor(() => expect(result.current.data).not.toBeNull());
expect(result.current.data?.currency).toBe('USD');
});
it('reports error state when the RPC rejects', async () => {
mockedCall.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useCostDashboard({ paused: true }));
await waitFor(() => expect(result.current.error).toBe('boom'));
expect(result.current.data).toBeNull();
});
});
+137
View File
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { callCoreRpc } from '../services/coreRpcClient';
export type BudgetStatus = 'normal' | 'warning' | 'exceeded';
export interface CostDashboardModelStats {
model: string;
cost_usd: number;
total_tokens: number;
request_count: number;
provider: string | null;
percent_of_total: number;
}
export interface CostDashboardDay {
date: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
request_count: number;
by_model: CostDashboardModelStats[];
}
export interface CostDashboardPayload {
days: CostDashboardDay[];
period_total_usd: number;
monthly_pace_usd: number;
budget_limit_monthly_usd: number;
month_to_date_usd: number;
budget_utilization: number;
budget_status: BudgetStatus;
currency: string;
warn_threshold: number;
alert_threshold: number;
enabled: boolean;
by_model: CostDashboardModelStats[];
}
interface RpcEnvelope<T> {
result?: T;
logs?: string[];
}
const DEFAULT_REFRESH_MS = 10_000;
export interface UseCostDashboardOptions {
refreshMs?: number;
/** When true, polling pauses (e.g. when the panel is hidden). */
paused?: boolean;
}
export interface UseCostDashboardResult {
data: CostDashboardPayload | null;
/** True only before the first successful fetch resolves. */
isLoading: boolean;
/** True whenever a background refresh is in flight (initial load or poll). */
isFetching: boolean;
error: string | null;
/** Wall-clock ms when `data` was last successfully populated, or `null`. */
lastUpdated: number | null;
/** Manual refetch — does not toggle `isLoading` if `data` is already present. */
refetch: () => Promise<void>;
}
/**
* 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
* stay live without a page refresh.
*/
export function useCostDashboard(options: UseCostDashboardOptions = {}): UseCostDashboardResult {
const { refreshMs = DEFAULT_REFRESH_MS, paused = false } = options;
const [data, setData] = useState<CostDashboardPayload | 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<CostDashboardPayload> | CostDashboardPayload>({
method: 'openhuman.cost_get_dashboard',
params: {},
});
if (cancelledRef.current) return;
const payload =
response && typeof response === 'object' && 'result' in response && response.result
? (response.result as CostDashboardPayload)
: (response as CostDashboardPayload);
setData(payload);
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);
}
}
}, []);
const refetch = useCallback(async () => {
await fetchOnce();
}, [fetchOnce]);
useEffect(() => {
cancelledRef.current = false;
// Always fire one fetch on mount so the panel has data to render even
// when polling is paused (background tab, hidden panel). `paused`
// only suppresses the periodic interval — not the initial load —
// so the user never sees a blank chart on first navigation. If you
// need a fully-inert hook, gate the call site on the same flag.
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 };
}
+53
View File
@@ -486,6 +486,59 @@ const ar1: TranslationMap = {
'settings.heartbeat.desc': 'التحكم في إيقاعات جدولة الخلفية وفحص خريطة الحلقة.',
'settings.ledgerUsage.title': 'دفتر الأستاذ الاستخدام',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'محرك البحث',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -495,6 +495,59 @@ const bn1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'ইউসেজ লেজার',
'settings.ledgerUsage.desc': 'সাম্প্রতিক ক্রেডিট খরচ, বাজেটের গণিত, এবং পটভূমি API পড়া বাজেট।',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'সার্চ ইঞ্জিন',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -505,6 +505,59 @@ const de1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'Nutzungsbuch',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'Suchmaschine',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -1022,6 +1022,59 @@ const en1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'Usage ledger',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'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',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -507,6 +507,59 @@ const es1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'Libro mayor de uso',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'motor de búsqueda',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -509,6 +509,59 @@ const fr1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'Usage ledger',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'Moteur de recherche',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -492,6 +492,59 @@ const hi1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'उपयोग बही',
'settings.ledgerUsage.desc': 'हाल का क्रेडिट खर्च, बजट गणित, और पृष्ठभूमि API बजट पढ़ें।',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'खोज इंजन',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -498,6 +498,59 @@ const id1: TranslationMap = {
'settings.heartbeat.desc': 'Kontrol irama penjadwalan latar belakang dan periksa peta loop.',
'settings.ledgerUsage.title': 'Buku besar penggunaan',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'Mesin pencari',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -502,6 +502,59 @@ const it1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'Registro di utilizzo',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'Motore di ricerca',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -483,6 +483,59 @@ const ko1: TranslationMap = {
'settings.heartbeat.desc': '백그라운드 예약 흐름을 제어하고 루프 맵을 검사합니다.',
'settings.ledgerUsage.title': '사용량 원장',
'settings.ledgerUsage.desc': '최근 크레딧 지출, 예산 계산 및 배경 API 읽기 예산.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': '검색 엔진',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -507,6 +507,59 @@ const pt1: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'Razão de uso',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'Mecanismo de pesquisa',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -497,6 +497,59 @@ const ru1: TranslationMap = {
'settings.heartbeat.desc': 'Управляйте частотой фонового планирования и проверяйте карту циклов.',
'settings.ledgerUsage.title': 'Журнал использования',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': 'Поисковая система',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -479,6 +479,59 @@ const zhCN1: TranslationMap = {
'settings.heartbeat.desc': '控制后台调度节奏并检查循环图。',
'settings.ledgerUsage.title': '使用账本',
'settings.ledgerUsage.desc': '最近的信贷支出、预算数学和背景 API 阅读预算。',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'settings.costDashboard.noDataHint':
'Send an agent message — token usage from the next provider call will populate the chart within ~10 seconds.',
'settings.search.title': '搜索引擎',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+53
View File
@@ -629,6 +629,59 @@ const en: TranslationMap = {
'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.',
'settings.ledgerUsage.title': 'Usage ledger',
'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.',
'settings.costDashboard.title': 'Cost dashboard',
'settings.costDashboard.desc':
'7-day spend and token burn across the swarm, with budget pace and per-model breakdown.',
'settings.costDashboard.sevenDayCost': '7-day daily cost',
'settings.costDashboard.sevenDayTokens': '7-day token usage',
'settings.costDashboard.totalSpend': '7-day total',
'settings.costDashboard.monthlyPace': 'Monthly pace',
'settings.costDashboard.budgetLimit': 'Budget limit',
'settings.costDashboard.utilization': 'Utilisation',
'settings.costDashboard.modelBreakdown': 'Per-model breakdown',
'settings.costDashboard.model': 'Model',
'settings.costDashboard.provider': 'Provider',
'settings.costDashboard.cost': 'Cost',
'settings.costDashboard.tokens': 'Tokens',
'settings.costDashboard.requests': 'Requests',
'settings.costDashboard.percentOfTotal': '% of total',
'settings.costDashboard.inputTokens': 'Input',
'settings.costDashboard.outputTokens': 'Output',
'settings.costDashboard.budgetNormal': 'On track',
'settings.costDashboard.budgetWarning': 'Warning',
'settings.costDashboard.budgetExceeded': 'Over budget',
'settings.costDashboard.noBudget': 'No limit set',
'settings.costDashboard.noData': 'No cost recorded yet for the last 7 days.',
'settings.costDashboard.noModels': 'No model activity in the last 7 days.',
'settings.costDashboard.loading': 'Loading cost dashboard…',
'settings.costDashboard.disabledHint':
'Cost dashboard is disabled in config. Set [cost.dashboard] enabled = true in config.toml to re-enable.',
'settings.costDashboard.subtitle':
'Live spend and token burn across the swarm. Bars auto-refresh every few seconds — no page reload needed.',
'settings.costDashboard.summaryAriaLabel': 'Cost summary metrics',
'settings.costDashboard.lastSevenDays': 'last 7 days',
'settings.costDashboard.utilizationOf': 'of',
'settings.costDashboard.thisMonth': 'this month',
'settings.costDashboard.monthlyPaceHint':
'Projected monthly spend at the current daily run-rate (avg × 30).',
'settings.costDashboard.budgetLimitHint':
'Monthly budget read from cost.monthly_limit_usd in config.toml.',
'settings.costDashboard.dailyTarget': 'Daily target',
'settings.costDashboard.today': 'Today',
'settings.costDashboard.todayBadge': 'TODAY',
'settings.costDashboard.unknownProvider': '—',
'settings.costDashboard.justNow': 'Just now',
'settings.costDashboard.secondsAgo': '{value}s ago',
'settings.costDashboard.minutesAgo': '{value}m ago',
'settings.costDashboard.hoursAgo': '{value}h ago',
'settings.costDashboard.daysAgo': '{value}d ago',
'settings.costDashboard.updated': 'Updated',
'settings.costDashboard.refresh': 'Refresh',
'settings.costDashboard.utcNote': 'Days bucketed in UTC',
'settings.costDashboard.stackedNote': 'Input + output stacked',
'settings.costDashboard.modelBreakdownHint': 'Aggregated across the last 7 days.',
'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',
'settings.search.menuDesc':
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
+12
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import CostDashboardPanel from '../components/dashboard/CostDashboardPanel';
import LogoutAndClearActions from '../components/settings/LogoutAndClearActions';
import AboutPanel from '../components/settings/panels/AboutPanel';
import AgentAccessPanel from '../components/settings/panels/AgentAccessPanel';
@@ -323,6 +324,13 @@ const Settings = () => {
route: 'ledger-usage',
icon: LlmIcon,
},
{
id: 'cost-dashboard',
title: t('settings.costDashboard.title'),
description: t('settings.costDashboard.desc'),
route: 'cost-dashboard',
icon: LlmIcon,
},
];
const composioSettingsItems = [
@@ -437,6 +445,10 @@ const Settings = () => {
path="ledger-usage"
element={wrapSettingsPage(<LedgerUsagePanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="cost-dashboard"
element={wrapSettingsPage(<CostDashboardPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="search" element={wrapSettingsPage(<SearchPanel />)} />
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
+254
View File
@@ -135,6 +135,9 @@ importers:
react-router-dom:
specifier: ^7.13.0
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts:
specifier: ^2.15.0
version: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
redux-persist:
specifier: ^6.0.0
version: 6.0.0(react@19.2.5)(redux@5.0.1)
@@ -1916,6 +1919,33 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -2626,6 +2656,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
@@ -2755,6 +2789,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-uri-to-buffer@6.0.2:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
@@ -2800,6 +2878,9 @@ packages:
resolution: {integrity: sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -2880,6 +2961,9 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -3146,6 +3230,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
@@ -3194,6 +3281,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
@@ -3593,6 +3684,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
@@ -4754,6 +4849,12 @@ packages:
react-dom:
optional: true
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -4764,6 +4865,12 @@ packages:
'@types/react':
optional: true
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react@19.2.5:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
@@ -4801,6 +4908,17 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
recursive-readdir@2.2.3:
resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==}
engines: {node: '>=6.0.0'}
@@ -5250,6 +5368,9 @@ packages:
resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==}
engines: {node: '>=0.6.0'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -5481,6 +5602,9 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
vite-plugin-node-polyfills@0.26.0:
resolution: {integrity: sha512-BAe5YzJf368XGev02hDvioidx4uVH8dqEJlG73bjQSxM26/AQnGcKFomq9n3vGq5yqpSHKN4h1XQNxx9l98mBg==}
peerDependencies:
@@ -7155,6 +7279,30 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@@ -8090,6 +8238,8 @@ snapshots:
clone@1.0.4:
optional: true
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
@@ -8248,6 +8398,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-uri-to-buffer@6.0.2: {}
data-urls@7.0.0(@noble/hashes@2.2.0):
@@ -8289,6 +8477,8 @@ snapshots:
decamelize@6.0.1: {}
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
@@ -8363,6 +8553,11 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.29.2
csstype: 3.2.3
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -8786,6 +8981,8 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
@@ -8853,6 +9050,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@5.4.0: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
@@ -9321,6 +9520,8 @@ snapshots:
hasown: 2.0.3
side-channel: 1.1.0
internmap@2.0.3: {}
ip-address@10.1.0: {}
is-alphabetical@2.0.1: {}
@@ -10731,6 +10932,14 @@ snapshots:
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
react-smooth@4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
fast-equals: 5.4.0
prop-types: 15.8.1
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
get-nonce: 1.0.1
@@ -10739,6 +10948,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
react-transition-group@4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@babel/runtime': 7.29.2
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react@19.2.5: {}
read-cache@1.0.0:
@@ -10792,6 +11010,23 @@ snapshots:
readdirp@4.1.2: {}
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.18.1
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
recursive-readdir@2.2.3:
dependencies:
minimatch: 3.1.5
@@ -11416,6 +11651,8 @@ snapshots:
dependencies:
setimmediate: 1.0.5
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.1.1: {}
@@ -11667,6 +11904,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plugin-node-polyfills@0.26.0(rollup@4.60.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/plugin-inject': 5.0.5(rollup@4.60.2)
+6
View File
@@ -1811,6 +1811,12 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) {
}
}
// --- Cost dashboard tracker ---
// Activates the previously-dormant CostTracker so the dashboard RPC
// surface (`openhuman.cost_get_dashboard`) and `record_provider_usage`
// share one JSONL-backed store. Idempotent.
crate::openhuman::cost::init_global(cfg.cost.clone(), &workspace_dir);
// --- Sub-agent definition registry bootstrap ---
// Loads built-in archetype definitions plus any custom TOML files
// under `<workspace>/agents/*.toml`. Idempotent — safe to call
@@ -733,6 +733,15 @@ impl Agent {
// the provider doesn't return usage.
if let Some(ref usage) = resp.usage {
self.context.record_usage(usage);
// Feed the dashboard tracker. This always records
// (model + usage) when the process-global tracker
// is available — independent of `cost.enabled`,
// which gates budget enforcement only. The call
// is a no-op only when `init_global` has not yet
// run (before bootstrap) or failed; errors are
// logged and swallowed so cost telemetry never
// breaks a turn.
crate::openhuman::cost::record_provider_usage(&effective_model, usage);
cumulative_input_tokens += usage.input_tokens;
cumulative_output_tokens += usage.output_tokens;
cumulative_cached_input_tokens += usage.cached_input_tokens;
@@ -1056,6 +1065,7 @@ impl Agent {
// checkpoint message (mirrors the normal final-response path).
if let Some(ref usage) = checkpoint_usage {
self.context.record_usage(usage);
crate::openhuman::cost::record_provider_usage(&effective_model, usage);
cumulative_input_tokens += usage.input_tokens;
cumulative_output_tokens += usage.output_tokens;
cumulative_cached_input_tokens += usage.cached_input_tokens;
+106 -4
View File
@@ -10,8 +10,21 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct CostConfig {
/// Enable cost tracking (default: false)
#[serde(default)]
/// Enable budget enforcement (default: true).
///
/// When `true`, [`crate::openhuman::cost::CostTracker::check_budget`]
/// honours `daily_limit_usd` / `monthly_limit_usd` and refuses
/// over-budget requests via `BudgetCheck::Exceeded`.
///
/// **Important:** as of the cost-dashboard PR this flag controls
/// **enforcement only**, not telemetry capture. The dashboard
/// JSONL store at `{workspace}/state/costs.jsonl` is populated by
/// [`crate::openhuman::cost::record_provider_usage`] regardless of
/// this flag, so users can review historical spend before opting
/// into hard caps. Set `dashboard.enabled = false` to hide the
/// Settings panel; delete the JSONL file to clear collected
/// history. The file is local and never leaves the workspace.
#[serde(default = "default_cost_enabled")]
pub enabled: bool,
/// Daily spending limit in USD (default: 10.00)
@@ -29,6 +42,53 @@ pub struct CostConfig {
/// Per-model pricing (USD per 1M tokens)
#[serde(default)]
pub prices: HashMap<String, ModelPricing>,
/// Dashboard chart panel configuration. Drives the 7-day cost / token
/// visualisation in Settings → Cost dashboard.
#[serde(default)]
pub dashboard: CostDashboardConfig,
}
/// Configuration for the 7-day cost & token usage dashboard panel.
///
/// The monthly budget itself is read from [`CostConfig::monthly_limit_usd`]
/// — `warn_threshold` and `alert_threshold` are fractions of that budget
/// that drive bar colour-coding and status badges on the chart.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct CostDashboardConfig {
/// Whether the dashboard panel is enabled in the UI. The panel still
/// renders a disabled hint when this is false.
#[serde(default = "default_dashboard_enabled")]
pub enabled: bool,
/// Display currency label. Amounts are always stored in USD; this is
/// purely a presentation hint.
#[serde(default = "default_currency")]
pub currency: String,
/// Warn threshold as a fraction of the monthly budget (default: 0.8).
/// Bars and status flip to amber once month-to-date utilisation reaches
/// this value.
#[serde(default = "default_warn_threshold")]
pub warn_threshold: f64,
/// Alert threshold as a fraction of the monthly budget (default: 0.95).
/// Bars and status flip to red once month-to-date utilisation reaches
/// this value.
#[serde(default = "default_alert_threshold")]
pub alert_threshold: f64,
}
impl Default for CostDashboardConfig {
fn default() -> Self {
Self {
enabled: default_dashboard_enabled(),
currency: default_currency(),
warn_threshold: default_warn_threshold(),
alert_threshold: default_alert_threshold(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -42,6 +102,10 @@ pub struct ModelPricing {
pub output: f64,
}
fn default_cost_enabled() -> bool {
true
}
fn default_daily_limit() -> f64 {
10.0
}
@@ -54,14 +118,31 @@ fn default_warn_percent() -> u8 {
80
}
fn default_dashboard_enabled() -> bool {
true
}
fn default_currency() -> String {
"USD".to_string()
}
fn default_warn_threshold() -> f64 {
0.8
}
fn default_alert_threshold() -> f64 {
0.95
}
impl Default for CostConfig {
fn default() -> Self {
Self {
enabled: false,
enabled: default_cost_enabled(),
daily_limit_usd: default_daily_limit(),
monthly_limit_usd: default_monthly_limit(),
warn_at_percent: default_warn_percent(),
prices: get_default_pricing(),
dashboard: CostDashboardConfig::default(),
}
}
}
@@ -114,11 +195,32 @@ mod tests {
#[test]
fn cost_config_defaults() {
let c = CostConfig::default();
assert!(!c.enabled);
assert!(c.enabled);
assert_eq!(c.daily_limit_usd, 10.0);
assert_eq!(c.monthly_limit_usd, 100.0);
assert_eq!(c.warn_at_percent, 80);
assert!(!c.prices.is_empty());
assert!(c.dashboard.enabled);
assert_eq!(c.dashboard.currency, "USD");
assert!((c.dashboard.warn_threshold - 0.8).abs() < f64::EPSILON);
assert!((c.dashboard.alert_threshold - 0.95).abs() < f64::EPSILON);
}
#[test]
fn cost_dashboard_config_serde_roundtrip() {
let toml = r#"
enabled = true
[dashboard]
enabled = false
currency = "EUR"
warn_threshold = 0.5
alert_threshold = 0.9
"#;
let c: CostConfig = toml::from_str(toml).unwrap();
assert!(!c.dashboard.enabled);
assert_eq!(c.dashboard.currency, "EUR");
assert!((c.dashboard.warn_threshold - 0.5).abs() < f64::EPSILON);
assert!((c.dashboard.alert_threshold - 0.9).abs() < f64::EPSILON);
}
#[test]
+225
View File
@@ -0,0 +1,225 @@
//! Process-global `CostTracker` singleton.
//!
//! The dashboard RPC handlers and agent-turn telemetry hook share a single
//! tracker instance so cost records are persisted exactly once per provider
//! call and the in-memory daily/monthly aggregates stay coherent.
//!
//! Initialisation is intentionally lazy from the caller's perspective: the
//! `bootstrap_core_runtime` path calls [`init_global`] at startup, and any
//! later call is a no-op. Callers that run before bootstrap (e.g. unit
//! tests) see `None` from [`try_global`] and skip recording — never a panic.
use std::path::Path;
use std::sync::Arc;
use once_cell::sync::OnceCell;
use crate::openhuman::config::CostConfig;
use crate::openhuman::inference::provider::traits::UsageInfo;
use super::tracker::CostTracker;
use super::types::TokenUsage;
static GLOBAL_TRACKER: OnceCell<Arc<CostTracker>> = OnceCell::new();
/// Initialise the global cost tracker. Idempotent — subsequent calls are
/// no-ops and the original tracker is preserved. Logs (but does not panic)
/// when construction fails so a bad workspace path never blocks core boot.
///
/// **Semantics note (changed in the cost-dashboard PR):**
///
/// - `cost.enabled = true` (the new default) — budget enforcement and
/// dashboard telemetry are both active.
/// - `cost.enabled = false` — budget enforcement is **off**, but the
/// dashboard telemetry path still appends to `costs.jsonl` (see
/// [`record_provider_usage`]). The flag now gates enforcement only;
/// observability is independent. This is a deliberate trade-off so
/// operators can review historical spend before opting into hard
/// budget caps. A `warn` is emitted below so the change is visible
/// in logs for anyone upgrading from a prior build where
/// `cost.enabled = false` blocked recording too.
///
/// The first-boot `info` log records `enabled` and the resolved
/// workspace so the default-on behaviour shows up in startup logs for
/// existing deployments that omit the `[cost]` block.
pub fn init_global(config: CostConfig, workspace_dir: &Path) {
if GLOBAL_TRACKER.get().is_some() {
return;
}
let cost_enabled = config.enabled;
match CostTracker::new(config, workspace_dir) {
Ok(tracker) => match GLOBAL_TRACKER.set(Arc::new(tracker)) {
Ok(()) => {
log::info!(
"[cost] global CostTracker initialised at workspace {} (cost.enabled={}, \
dashboard telemetry always-on). Set cost.dashboard.enabled=false in \
config.toml to hide the panel.",
workspace_dir.display(),
cost_enabled
);
if !cost_enabled {
log::warn!(
"[cost] cost.enabled=false: budget enforcement is OFF, but dashboard \
telemetry will still append to costs.jsonl. This is a behavioural \
change from prior builds where cost.enabled=false also blocked \
recording. Set cost.dashboard.enabled=false to disable the panel; \
the JSONL is local and never leaves the workspace."
);
}
}
Err(_) => {
// Another caller won a concurrent init race; the original
// tracker is kept. Avoid logging a misleading "initialised"
// line — the winner already did so.
log::debug!(
"[cost] global CostTracker already initialised by another caller; \
discarding duplicate instance"
);
}
},
Err(err) => {
log::warn!(
"[cost] failed to initialise global CostTracker at {}: {err} \
cost dashboard will report empty data until next core start",
workspace_dir.display()
);
}
}
}
/// Fetch the global tracker if it has been initialised. Returns `None`
/// before bootstrap or after an init failure — callers must treat the
/// absence as a soft no-op.
pub fn try_global() -> Option<Arc<CostTracker>> {
GLOBAL_TRACKER.get().cloned()
}
/// Convenience hook used by the agent turn loop: translates a provider
/// [`UsageInfo`] into a [`TokenUsage`] record and persists it via the
/// global tracker. Silently skipped when the tracker is uninitialised.
/// Errors are logged but never propagated — cost tracking must never
/// break a turn.
///
/// Note: this path uses
/// [`crate::openhuman::cost::tracker::CostTracker::record_usage_unconditional`],
/// so dashboard telemetry is captured even when `cost.enabled = false` —
/// the `cost.enabled` flag gates budget enforcement (refusing requests),
/// not observability. This lets users see history first and decide
/// whether to switch on enforcement.
///
/// `model` is the model identifier the request was routed to (e.g.
/// `"anthropic/claude-sonnet-4-20250514"`) and is used as the bucket key
/// in per-model aggregates.
pub fn record_provider_usage(model: &str, usage: &UsageInfo) {
let Some(token_usage) = build_token_usage(model, usage) else {
return;
};
let Some(tracker) = try_global() else {
return;
};
if let Err(err) = tracker.record_usage_unconditional(token_usage) {
log::debug!("[cost] record_provider_usage failed: {err}");
}
}
/// Translate a provider [`UsageInfo`] into a [`TokenUsage`] record.
///
/// Returns `None` for an all-zero payload so the caller can skip the
/// write — providers that don't echo usage produce `UsageInfo::default()`
/// values, and persisting those would inflate the request count with
/// non-events. Non-finite or negative cost is clamped to `0.0`. Extracted
/// from [`record_provider_usage`] so the translation can be unit-tested
/// independently of the process-global tracker singleton.
pub(super) fn build_token_usage(model: &str, usage: &UsageInfo) -> Option<TokenUsage> {
if usage.input_tokens == 0 && usage.output_tokens == 0 && usage.charged_amount_usd == 0.0 {
return None;
}
let total_tokens = usage.input_tokens.saturating_add(usage.output_tokens);
Some(TokenUsage {
model: model.to_string(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens,
cost_usd: if usage.charged_amount_usd.is_finite() && usage.charged_amount_usd >= 0.0 {
usage.charged_amount_usd
} else {
0.0
},
timestamp: chrono::Utc::now(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_usage(input: u64, output: u64, charged: f64) -> UsageInfo {
UsageInfo {
input_tokens: input,
output_tokens: output,
context_window: 0,
cached_input_tokens: 0,
charged_amount_usd: charged,
}
}
#[test]
fn build_token_usage_skips_all_zero_payloads() {
let usage = make_usage(0, 0, 0.0);
assert!(build_token_usage("model-a", &usage).is_none());
}
#[test]
fn build_token_usage_populates_fields_and_total() {
let usage = make_usage(1000, 500, 1.25);
let translated = build_token_usage("anthropic/claude-sonnet-4", &usage).unwrap();
assert_eq!(translated.model, "anthropic/claude-sonnet-4");
assert_eq!(translated.input_tokens, 1000);
assert_eq!(translated.output_tokens, 500);
assert_eq!(translated.total_tokens, 1500);
assert!((translated.cost_usd - 1.25).abs() < f64::EPSILON);
}
#[test]
fn build_token_usage_clamps_nan_and_negative_cost_to_zero() {
let nan_usage = make_usage(10, 5, f64::NAN);
let neg_usage = make_usage(10, 5, -3.0);
let inf_usage = make_usage(10, 5, f64::INFINITY);
assert_eq!(build_token_usage("m", &nan_usage).unwrap().cost_usd, 0.0);
assert_eq!(build_token_usage("m", &neg_usage).unwrap().cost_usd, 0.0);
assert_eq!(build_token_usage("m", &inf_usage).unwrap().cost_usd, 0.0);
}
#[test]
fn build_token_usage_emits_when_tokens_present_even_with_zero_cost() {
let usage = make_usage(100, 50, 0.0);
assert!(build_token_usage("m", &usage).is_some());
}
#[test]
fn record_provider_usage_without_global_is_noop() {
// No GLOBAL_TRACKER initialised in this test process by default;
// call must return Ok without panic.
let usage = make_usage(10, 5, 0.5);
record_provider_usage("m", &usage);
}
#[test]
fn init_global_is_idempotent() {
// The OnceCell is process-wide. After at most one call across the
// whole test run it will be `Some`, and any further `init_global`
// call must be a no-op (and must not panic). We assert the
// post-condition either way: try_global resolves to Some on the
// happy path, or the construct-then-set race is logged silently.
let tmp = TempDir::new().unwrap();
let mut cfg = CostConfig::default();
cfg.enabled = true;
init_global(cfg.clone(), tmp.path());
init_global(cfg, tmp.path()); // second call is a no-op
// If this test ran first, global is now set. If another test set
// a different workspace already, the original is retained — both
// are valid behaviours per the contract.
assert!(try_global().is_some() || try_global().is_none());
}
}
+7 -1
View File
@@ -1,10 +1,16 @@
mod global;
mod rpc;
mod schemas;
pub mod tracker;
pub mod types;
pub use global::{init_global, record_provider_usage, try_global};
pub use schemas::{
all_controller_schemas as all_cost_controller_schemas,
all_registered_controllers as all_cost_registered_controllers,
};
pub use tracker::CostTracker;
pub use types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod};
pub use types::{
BudgetCheck, BudgetStatus, CostDashboard, CostRecord, CostSummary, DailyCostEntry, ModelStats,
TokenUsage, UsagePeriod,
};
+512
View File
@@ -0,0 +1,512 @@
//! RPC handlers for the cost dashboard surface.
//!
//! The handlers prefer the process-global [`CostTracker`] populated at boot
//! by [`crate::openhuman::cost::init_global`]. When the global is missing —
//! e.g. when the dashboard RPC fires before bootstrap completes, or after a
//! tracker-construction failure — the handler constructs a fallback tracker
//! against the config-provided workspace so the UI gets an answer rather
//! than an error. The fallback is read-only by design: it shares the same
//! JSONL file as the real tracker and will see whatever is on disk.
use anyhow::{anyhow, Context, Result};
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::openhuman::config::{Config, CostConfig};
use crate::rpc::RpcOutcome;
use super::global::try_global;
use super::tracker::CostTracker;
use super::types::{BudgetStatus, CostDashboard, CostSummary, DailyCostEntry, ModelStats};
#[derive(Debug, Clone, Serialize)]
pub struct DailyCostEntryDto {
pub date: String,
pub cost_usd: f64,
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
pub request_count: usize,
pub by_model: Vec<ModelStatsDto>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ModelStatsDto {
pub model: String,
pub cost_usd: f64,
pub total_tokens: u64,
pub request_count: usize,
pub provider: Option<String>,
pub percent_of_total: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct CostDashboardDto {
pub days: Vec<DailyCostEntryDto>,
pub period_total_usd: f64,
pub monthly_pace_usd: f64,
pub budget_limit_monthly_usd: f64,
pub month_to_date_usd: f64,
pub budget_utilization: f64,
pub budget_status: BudgetStatus,
pub currency: String,
pub warn_threshold: f64,
pub alert_threshold: f64,
pub enabled: bool,
pub by_model: Vec<ModelStatsDto>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CostSummaryDto {
pub session_cost_usd: f64,
pub daily_cost_usd: f64,
pub monthly_cost_usd: f64,
pub total_tokens: u64,
pub request_count: usize,
pub by_model: Vec<ModelStatsDto>,
}
fn provider_for(model: &str) -> Option<String> {
model.split_once('/').map(|(prov, _)| prov.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
} else {
0.0
};
ModelStatsDto {
model: stats.model.clone(),
cost_usd: stats.cost_usd,
total_tokens: stats.total_tokens,
request_count: stats.request_count,
provider: provider_for(&stats.model),
percent_of_total,
}
}
fn daily_entry_to_dto(entry: &DailyCostEntry) -> DailyCostEntryDto {
let mut by_model: Vec<ModelStatsDto> = entry
.by_model
.values()
.map(|m| model_stats_to_dto(m, entry.cost_usd))
.collect();
by_model.sort_by(|a, b| {
b.cost_usd
.partial_cmp(&a.cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
});
DailyCostEntryDto {
date: entry.date.format("%Y-%m-%d").to_string(),
cost_usd: entry.cost_usd,
input_tokens: entry.input_tokens,
output_tokens: entry.output_tokens,
total_tokens: entry.total_tokens,
request_count: entry.request_count,
by_model,
}
}
fn dashboard_to_dto(dash: CostDashboard, cost_cfg: &CostConfig) -> CostDashboardDto {
let total = dash.period_total_usd;
let days = dash.days.iter().map(daily_entry_to_dto).collect();
let by_model = dash
.by_model
.iter()
.map(|m| model_stats_to_dto(m, total))
.collect();
CostDashboardDto {
days,
period_total_usd: dash.period_total_usd,
monthly_pace_usd: dash.monthly_pace_usd,
budget_limit_monthly_usd: dash.budget_limit_monthly_usd,
month_to_date_usd: dash.month_to_date_usd,
budget_utilization: dash.budget_utilization,
budget_status: dash.budget_status,
currency: dash.currency,
warn_threshold: cost_cfg.dashboard.warn_threshold,
alert_threshold: cost_cfg.dashboard.alert_threshold,
enabled: cost_cfg.dashboard.enabled,
by_model,
}
}
fn summary_to_dto(s: &CostSummary) -> CostSummaryDto {
let total = s.session_cost_usd;
let mut by_model: Vec<ModelStatsDto> = s
.by_model
.values()
.map(|m| model_stats_to_dto(m, total))
.collect();
by_model.sort_by(|a, b| {
b.cost_usd
.partial_cmp(&a.cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
});
CostSummaryDto {
session_cost_usd: s.session_cost_usd,
daily_cost_usd: s.daily_cost_usd,
monthly_cost_usd: s.monthly_cost_usd,
total_tokens: s.total_tokens,
request_count: s.request_count,
by_model,
}
}
/// Cached fallback tracker used when the process-global tracker is
/// unavailable. Keyed on workspace path so a workspace switch rebuilds
/// the tracker rather than serving stale data. The cache also remembers
/// the last construction error and its timestamp, so repeated RPC polls
/// (every 10s from the UI hook) do not re-attempt a failing
/// `CostTracker::new` against the same bad workspace every call — the
/// failure is replayed for `FALLBACK_ERROR_TTL` before the next retry.
struct FallbackState {
workspace: PathBuf,
tracker: Option<Arc<CostTracker>>,
last_error: Option<(Instant, String)>,
}
static FALLBACK_TRACKER: Mutex<Option<FallbackState>> = Mutex::new(None);
const FALLBACK_ERROR_TTL: Duration = Duration::from_secs(30);
fn resolve_tracker(config: &Config) -> Result<Arc<CostTracker>> {
log::debug!(target: "cost_rpc", "[cost_rpc] resolve_tracker.start");
if let Some(global) = try_global() {
log::debug!(target: "cost_rpc", "[cost_rpc] resolve_tracker.global_hit");
return Ok(global);
}
log::warn!(target: "cost_rpc", "[cost_rpc] resolve_tracker.global_miss — falling back to per-call tracker");
let workspace = config.workspace_dir.clone();
let mut guard = FALLBACK_TRACKER.lock();
// Reuse the cached tracker only when the workspace path is unchanged.
if let Some(state) = guard.as_ref() {
if state.workspace == workspace {
if let Some(tracker) = &state.tracker {
log::debug!(target: "cost_rpc", "[cost_rpc] resolve_tracker.fallback_cached_hit");
return Ok(tracker.clone());
}
if let Some((when, err)) = &state.last_error {
if when.elapsed() < FALLBACK_ERROR_TTL {
log::debug!(
target: "cost_rpc",
"[cost_rpc] resolve_tracker.fallback_cached_error replay — err={err}"
);
return Err(anyhow!(
"cost tracker unavailable (cached failure, retry in {:?}): {err}",
FALLBACK_ERROR_TTL - when.elapsed()
));
}
}
}
}
match CostTracker::new(config.cost.clone(), &workspace) {
Ok(tracker) => {
log::debug!(target: "cost_rpc", "[cost_rpc] resolve_tracker.fallback_ready workspace={}", workspace.display());
let arc = Arc::new(tracker);
*guard = Some(FallbackState {
workspace,
tracker: Some(arc.clone()),
last_error: None,
});
Ok(arc)
}
Err(err) => {
let msg = format!("{err:#}");
log::warn!(
target: "cost_rpc",
"[cost_rpc] resolve_tracker.fallback_failed workspace={} err={msg}",
workspace.display()
);
*guard = Some(FallbackState {
workspace,
tracker: None,
last_error: Some((Instant::now(), msg)),
});
Err(err).context("Failed to construct fallback CostTracker for dashboard RPC")
}
}
}
/// Build the dashboard payload for the current config.
pub fn dashboard(config: &Config) -> Result<RpcOutcome<Value>> {
log::debug!(target: "cost_rpc", "[cost_rpc] dashboard.entry");
let tracker = resolve_tracker(config).inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc] dashboard.resolve_failed err={err:#}");
})?;
let dash = tracker
.get_dashboard(
&config.cost.dashboard.currency,
config.cost.dashboard.warn_threshold,
config.cost.dashboard.alert_threshold,
)
.inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc] dashboard.query_failed err={err:#}");
})
.context("cost dashboard query failed")?;
let day_count = dash.days.len();
let model_count = dash.by_model.len();
let dto = dashboard_to_dto(dash, &config.cost);
let value = serde_json::to_value(dto).context("cost dashboard serialize failed")?;
log::debug!(
target: "cost_rpc",
"[cost_rpc] dashboard.exit days={day_count} models={model_count}"
);
Ok(RpcOutcome::new(value, Vec::new()))
}
/// Return the per-day cost history for the requested span.
pub fn daily_history(config: &Config, days: u32) -> Result<RpcOutcome<Value>> {
log::debug!(target: "cost_rpc", "[cost_rpc] daily_history.entry days={days}");
let tracker = resolve_tracker(config).inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc] daily_history.resolve_failed err={err:#}");
})?;
let entries = tracker
.get_daily_history(days)
.inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc] daily_history.query_failed err={err:#}");
})
.context("cost daily history query failed")?;
let entry_count = entries.len();
let dto: Vec<DailyCostEntryDto> = entries.iter().map(daily_entry_to_dto).collect();
let value = serde_json::to_value(dto).context("cost daily history serialize failed")?;
log::debug!(target: "cost_rpc", "[cost_rpc] daily_history.exit entries={entry_count}");
Ok(RpcOutcome::new(value, Vec::new()))
}
/// Return the live session / daily / monthly summary.
pub fn summary(config: &Config) -> Result<RpcOutcome<Value>> {
log::debug!(target: "cost_rpc", "[cost_rpc] summary.entry");
let tracker = resolve_tracker(config).inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc] summary.resolve_failed err={err:#}");
})?;
let s = tracker
.get_summary()
.inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc] summary.query_failed err={err:#}");
})
.context("cost summary query failed")?;
let request_count = s.request_count;
let dto = summary_to_dto(&s);
let value = serde_json::to_value(dto).context("cost summary serialize failed")?;
log::debug!(target: "cost_rpc", "[cost_rpc] summary.exit requests={request_count}");
Ok(RpcOutcome::new(value, Vec::new()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::cost::types::TokenUsage;
use chrono::Utc;
use std::collections::HashMap;
use tempfile::TempDir;
fn tempdir_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
cfg.cost.enabled = true;
cfg.cost.monthly_limit_usd = 100.0;
cfg.cost.dashboard.warn_threshold = 0.8;
cfg.cost.dashboard.alert_threshold = 0.95;
cfg.cost.dashboard.currency = "USD".to_string();
cfg.cost.dashboard.enabled = true;
(tmp, cfg)
}
fn make_model_stats(model: &str, cost: f64) -> ModelStats {
ModelStats {
model: model.to_string(),
cost_usd: cost,
total_tokens: 1500,
request_count: 1,
}
}
#[test]
fn provider_for_extracts_namespace() {
assert_eq!(
provider_for("anthropic/claude-sonnet-4"),
Some("anthropic".to_string())
);
assert_eq!(provider_for("openai/gpt-5"), Some("openai".to_string()));
assert_eq!(provider_for("bare-model"), None);
}
#[test]
fn model_stats_dto_percent_zero_when_total_zero() {
let stats = make_model_stats("a/b", 0.0);
let dto = model_stats_to_dto(&stats, 0.0);
assert_eq!(dto.percent_of_total, 0.0);
assert_eq!(dto.provider.as_deref(), Some("a"));
}
#[test]
fn model_stats_dto_percent_scales_with_total() {
let stats = make_model_stats("anthropic/x", 2.5);
let dto = model_stats_to_dto(&stats, 10.0);
assert!((dto.percent_of_total - 25.0).abs() < f64::EPSILON);
}
#[test]
fn daily_entry_dto_sorts_models_by_cost_desc_and_formats_date() {
let mut by_model = HashMap::new();
by_model.insert("a".to_string(), make_model_stats("a", 1.0));
by_model.insert("b".to_string(), make_model_stats("b", 3.0));
by_model.insert("c".to_string(), make_model_stats("c", 2.0));
let entry = DailyCostEntry {
date: chrono::NaiveDate::from_ymd_opt(2026, 5, 27).unwrap(),
cost_usd: 6.0,
input_tokens: 1000,
output_tokens: 500,
total_tokens: 1500,
request_count: 3,
by_model,
};
let dto = daily_entry_to_dto(&entry);
assert_eq!(dto.date, "2026-05-27");
assert_eq!(dto.by_model.len(), 3);
assert_eq!(dto.by_model[0].model, "b");
assert_eq!(dto.by_model[1].model, "c");
assert_eq!(dto.by_model[2].model, "a");
}
#[test]
fn dashboard_dto_propagates_threshold_and_enabled_flags() {
let (_tmp, cfg) = tempdir_config();
let dash = CostDashboard {
days: vec![],
period_total_usd: 0.0,
monthly_pace_usd: 0.0,
budget_limit_monthly_usd: 100.0,
month_to_date_usd: 0.0,
budget_utilization: 0.0,
budget_status: BudgetStatus::Normal,
currency: "USD".to_string(),
by_model: vec![],
};
let dto = dashboard_to_dto(dash, &cfg.cost);
assert!((dto.warn_threshold - 0.8).abs() < f64::EPSILON);
assert!((dto.alert_threshold - 0.95).abs() < f64::EPSILON);
assert!(dto.enabled);
}
#[test]
fn summary_dto_sorts_models_by_cost_desc() {
let mut by_model = HashMap::new();
by_model.insert("low".to_string(), make_model_stats("low", 0.5));
by_model.insert("high".to_string(), make_model_stats("high", 5.0));
let summary = CostSummary {
session_cost_usd: 5.5,
daily_cost_usd: 5.5,
monthly_cost_usd: 5.5,
total_tokens: 3000,
request_count: 2,
by_model,
};
let dto = summary_to_dto(&summary);
assert_eq!(dto.by_model.len(), 2);
assert_eq!(dto.by_model[0].model, "high");
assert_eq!(dto.by_model[1].model, "low");
}
#[test]
fn dashboard_rpc_returns_value_against_tempdir_workspace() {
// Reset FALLBACK_TRACKER state so a previous test's cache cannot
// interfere with this isolated workspace.
*FALLBACK_TRACKER.lock() = None;
let (_tmp, cfg) = tempdir_config();
let outcome = dashboard(&cfg).expect("dashboard should resolve");
let payload = outcome.value;
assert!(payload.is_object());
let days = payload.get("days").and_then(|v| v.as_array()).unwrap();
assert_eq!(days.len(), 7);
}
#[test]
fn daily_history_rpc_clamps_and_returns_array() {
*FALLBACK_TRACKER.lock() = None;
let (_tmp, cfg) = tempdir_config();
let outcome = daily_history(&cfg, 0).expect("clamped to 1");
let arr = outcome.value.as_array().unwrap();
assert_eq!(arr.len(), 1);
}
#[test]
fn summary_rpc_returns_object() {
*FALLBACK_TRACKER.lock() = None;
let (_tmp, cfg) = tempdir_config();
let outcome = summary(&cfg).expect("summary should resolve");
let obj = outcome.value.as_object().unwrap();
assert!(obj.contains_key("session_cost_usd"));
assert!(obj.contains_key("by_model"));
}
#[test]
fn resolve_tracker_caches_fallback_across_calls() {
*FALLBACK_TRACKER.lock() = None;
let (_tmp, cfg) = tempdir_config();
let first = resolve_tracker(&cfg).unwrap();
let second = resolve_tracker(&cfg).unwrap();
// Both calls return Arc<CostTracker>; when no global is set the
// second call must hit the cached fallback (same Arc pointer).
if try_global().is_none() {
assert!(Arc::ptr_eq(&first, &second));
}
}
#[test]
fn resolve_tracker_replays_cached_error_until_ttl() {
// Pre-seed cache with a synthetic failure. Even though
// CostTracker::new would succeed against this tempdir, the cache
// takes precedence until the TTL elapses.
let (_tmp, cfg) = tempdir_config();
// Only meaningful when no global is set; otherwise try_global wins.
if try_global().is_some() {
return;
}
*FALLBACK_TRACKER.lock() = Some(FallbackState {
workspace: cfg.workspace_dir.clone(),
tracker: None,
last_error: Some((Instant::now(), "synthetic".to_string())),
});
let err = match resolve_tracker(&cfg) {
Ok(_) => panic!("expected cached failure replay"),
Err(e) => e.to_string(),
};
assert!(err.contains("cached failure"), "got: {err}");
}
#[test]
fn dashboard_query_includes_persisted_record() {
// Skip when the process-global tracker has been initialised by a
// sibling test — the global is one-shot per process and points
// at whatever workspace won the race, so we cannot reliably
// round-trip a record through `cfg.workspace_dir` here.
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 = dashboard(&cfg).expect("dashboard should resolve");
let total = outcome
.value
.get("period_total_usd")
.unwrap()
.as_f64()
.unwrap();
assert!((1.24..=1.26).contains(&total), "got total {total}");
}
}
+257 -4
View File
@@ -1,10 +1,263 @@
use crate::core::all::RegisteredController;
use crate::core::ControllerSchema;
//! Controller schemas and JSON-RPC dispatchers for the cost dashboard.
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
use super::rpc as cost_rpc;
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct DailyHistoryParams {
#[serde(default)]
days: Option<u32>,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
Vec::new()
vec![
schema_for("cost_get_dashboard"),
schema_for("cost_get_daily_history"),
schema_for("cost_get_summary"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
Vec::new()
vec![
RegisteredController {
schema: schema_for("cost_get_dashboard"),
handler: handle_cost_get_dashboard,
},
RegisteredController {
schema: schema_for("cost_get_daily_history"),
handler: handle_cost_get_daily_history,
},
RegisteredController {
schema: schema_for("cost_get_summary"),
handler: handle_cost_get_summary,
},
]
}
fn schema_for(function: &str) -> ControllerSchema {
match function {
"cost_get_dashboard" => ControllerSchema {
namespace: "cost",
function: "get_dashboard",
description:
"Fetch the 7-day cost & token dashboard payload: per-day buckets, summary \
metrics, budget utilisation, and per-model breakdown.",
inputs: vec![],
outputs: vec![json_output(
"dashboard",
"Dashboard payload with `days`, `byModel`, summary fields and budget status.",
)],
},
"cost_get_daily_history" => ControllerSchema {
namespace: "cost",
function: "get_daily_history",
description: "Fetch a per-day cost/token history for the requested span (default 7 \
days, clamped to [1, 366]).",
inputs: vec![FieldSchema {
name: "days",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Number of trailing days to include (default 7).",
required: false,
}],
outputs: vec![json_output(
"entries",
"Ordered list of daily entries, oldest first; gaps zero-filled.",
)],
},
"cost_get_summary" => ControllerSchema {
namespace: "cost",
function: "get_summary",
description: "Fetch the live session / daily / monthly cost summary.",
inputs: vec![],
outputs: vec![json_output(
"summary",
"Aggregated cost & token usage for the current session and active period.",
)],
},
_ => ControllerSchema {
namespace: "cost",
function: "unknown",
description: "Unknown cost controller.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
/// Short opaque correlation id for log threading across an async handler
/// invocation. Eight hex chars are enough to disambiguate concurrent
/// dashboard polls without bloating log lines, and the value is local
/// so it does not leak across processes.
fn new_correlation_id() -> String {
uuid::Uuid::new_v4().simple().to_string()[..8].to_string()
}
fn handle_cost_get_dashboard(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_dashboard.entry");
let config = config_rpc::load_config_with_timeout().await.inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_dashboard.config_load_failed err={err}");
})?;
let outcome = cost_rpc::dashboard(&config).map_err(|e| {
let s = e.to_string();
log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_dashboard.error err={s}");
s
})?;
let json = to_json(outcome);
log::debug!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_dashboard.exit ok={}", json.is_ok());
json
})
}
fn handle_cost_get_daily_history(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_daily_history.entry");
let config = config_rpc::load_config_with_timeout().await.inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_daily_history.config_load_failed err={err}");
})?;
let payload = if params.is_empty() {
DailyHistoryParams::default()
} else {
serde_json::from_value::<DailyHistoryParams>(Value::Object(params)).map_err(|e| {
let s = format!("invalid params: {e}");
log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_daily_history.bad_params err={s}");
s
})?
};
let days = payload.days.unwrap_or(7);
let outcome = cost_rpc::daily_history(&config, days).map_err(|e| {
let s = e.to_string();
log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_daily_history.error err={s}");
s
})?;
let json = to_json(outcome);
log::debug!(
target: "cost_rpc",
"[cost_rpc][{cid}] cost_get_daily_history.exit days={days} ok={}",
json.is_ok()
);
json
})
}
fn handle_cost_get_summary(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_summary.entry");
let config = config_rpc::load_config_with_timeout().await.inspect_err(|err| {
log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_summary.config_load_failed err={err}");
})?;
let outcome = cost_rpc::summary(&config).map_err(|e| {
let s = e.to_string();
log::warn!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_summary.error err={s}");
s
})?;
let json = to_json(outcome);
log::debug!(target: "cost_rpc", "[cost_rpc][{cid}] cost_get_summary.exit ok={}", json.is_ok());
json
})
}
fn to_json(outcome: RpcOutcome<Value>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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!(names.contains(&"get_dashboard"));
assert!(names.contains(&"get_daily_history"));
assert!(names.contains(&"get_summary"));
for schema in &schemas {
assert_eq!(schema.namespace, "cost");
}
}
#[test]
fn all_registered_controllers_has_three_handlers_matching_schemas() {
let registered = all_registered_controllers();
assert_eq!(registered.len(), 3);
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"));
}
#[test]
fn schema_for_dashboard_has_no_inputs_and_one_output() {
let s = schema_for("cost_get_dashboard");
assert_eq!(s.function, "get_dashboard");
assert!(s.inputs.is_empty());
assert_eq!(s.outputs.len(), 1);
assert_eq!(s.outputs[0].name, "dashboard");
}
#[test]
fn schema_for_daily_history_has_optional_days_input() {
let s = schema_for("cost_get_daily_history");
assert_eq!(s.function, "get_daily_history");
assert_eq!(s.inputs.len(), 1);
assert_eq!(s.inputs[0].name, "days");
assert!(!s.inputs[0].required);
}
#[test]
fn schema_for_summary_returns_summary_output() {
let s = schema_for("cost_get_summary");
assert_eq!(s.function, "get_summary");
assert_eq!(s.outputs[0].name, "summary");
}
#[test]
fn schema_for_unknown_returns_error_shape() {
let s = schema_for("cost_get_nonexistent");
assert_eq!(s.function, "unknown");
assert_eq!(s.outputs[0].name, "error");
}
#[test]
fn new_correlation_id_returns_eight_hex_chars() {
let cid = new_correlation_id();
assert_eq!(cid.len(), 8);
assert!(cid.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn new_correlation_id_is_unique_across_calls() {
let a = new_correlation_id();
let b = new_correlation_id();
// Collision probability for 8 hex chars (32 bits) per call is
// ~1/4B — virtually zero for a unit test.
assert_ne!(a, b);
}
}
+157 -3
View File
@@ -1,9 +1,12 @@
use super::types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod};
use super::types::{
BudgetCheck, BudgetStatus, CostDashboard, CostRecord, CostSummary, DailyCostEntry, ModelStats,
TokenUsage, UsagePeriod,
};
use crate::openhuman::config::CostConfig;
use anyhow::{anyhow, Context, Result};
use chrono::{Datelike, NaiveDate, Utc};
use chrono::{Datelike, Duration, NaiveDate, Utc};
use parking_lot::{Mutex, MutexGuard};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
@@ -107,11 +110,23 @@ impl CostTracker {
}
/// Record a usage event.
///
/// Honours `cost.enabled` — when budget enforcement is disabled the call
/// is a no-op. Use [`Self::record_usage_unconditional`] for telemetry
/// paths (dashboard, observability) that must capture data even when
/// the budget enforcement path is off, so the user can flip enforcement
/// on later without losing history.
pub fn record_usage(&self, usage: TokenUsage) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
self.record_usage_unconditional(usage)
}
/// Persist a usage event ignoring `cost.enabled`. Used by the dashboard
/// telemetry hook so cost history is recorded regardless of whether the
/// budget-enforcement gate is on.
pub fn record_usage_unconditional(&self, usage: TokenUsage) -> Result<()> {
if !usage.cost_usd.is_finite() || usage.cost_usd < 0.0 {
return Err(anyhow!(
"Token usage cost must be a finite, non-negative value"
@@ -173,6 +188,145 @@ impl CostTracker {
let storage = self.lock_storage();
storage.get_cost_for_month(year, month)
}
/// Get a daily cost/token history covering the last `days` calendar days,
/// ending on today (UTC) inclusive. Days with no recorded usage are
/// returned as zero-filled entries so callers can render the chart bars
/// without gap handling. Oldest day first.
///
/// `days` is clamped to the range `[1, 366]` to bound the scan window.
pub fn get_daily_history(&self, days: u32) -> Result<Vec<DailyCostEntry>> {
let span = days.clamp(1, 366) as i64;
let today = Utc::now().date_naive();
let earliest = today
.checked_sub_signed(Duration::days(span - 1))
.ok_or_else(|| anyhow!("Daily history range underflowed"))?;
let mut buckets: BTreeMap<NaiveDate, DailyCostEntry> = BTreeMap::new();
let storage = self.lock_storage();
storage.for_each_record(|record| {
let date = record.usage.timestamp.naive_utc().date();
if date < earliest || date > today {
return;
}
let entry = buckets
.entry(date)
.or_insert_with(|| DailyCostEntry::empty(date));
entry.cost_usd += record.usage.cost_usd;
entry.input_tokens = entry.input_tokens.saturating_add(record.usage.input_tokens);
entry.output_tokens = entry
.output_tokens
.saturating_add(record.usage.output_tokens);
entry.total_tokens = entry.total_tokens.saturating_add(record.usage.total_tokens);
entry.request_count += 1;
let model_entry = entry
.by_model
.entry(record.usage.model.clone())
.or_insert_with(|| ModelStats {
model: record.usage.model.clone(),
cost_usd: 0.0,
total_tokens: 0,
request_count: 0,
});
model_entry.cost_usd += record.usage.cost_usd;
model_entry.total_tokens = model_entry
.total_tokens
.saturating_add(record.usage.total_tokens);
model_entry.request_count += 1;
})?;
let mut out = Vec::with_capacity(span as usize);
for offset in 0..span {
let date = earliest + Duration::days(offset);
out.push(
buckets
.remove(&date)
.unwrap_or_else(|| DailyCostEntry::empty(date)),
);
}
Ok(out)
}
/// 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.
///
/// `warn_threshold` / `alert_threshold` are fractions of the monthly
/// budget — e.g. 0.8 (warn at 80%) and 0.95 (alert at 95%). When the
/// monthly limit is non-positive, status falls back to `Normal`.
pub fn get_dashboard(
&self,
currency: &str,
warn_threshold: f64,
alert_threshold: f64,
) -> Result<CostDashboard> {
let days = self.get_daily_history(7)?;
let period_total_usd: f64 = days.iter().map(|d| d.cost_usd).sum();
let daily_average = period_total_usd / days.len().max(1) as f64;
let monthly_pace_usd = daily_average * 30.0;
let budget_limit_monthly_usd = self.config.monthly_limit_usd.max(0.0);
let now = Utc::now();
let month_to_date_usd = self.get_monthly_cost(now.year(), now.month())?;
let budget_utilization = if budget_limit_monthly_usd > 0.0 {
(month_to_date_usd / budget_limit_monthly_usd).clamp(0.0, 1.0)
} else {
0.0
};
let budget_status = if budget_limit_monthly_usd <= 0.0 {
BudgetStatus::Normal
} else {
let warn = warn_threshold.clamp(0.0, 1.0);
let alert = alert_threshold.clamp(0.0, 1.0).max(warn);
let utilization_raw = month_to_date_usd / budget_limit_monthly_usd;
if utilization_raw >= alert {
BudgetStatus::Exceeded
} else if utilization_raw >= warn {
BudgetStatus::Warning
} else {
BudgetStatus::Normal
}
};
let mut by_model_totals: HashMap<String, ModelStats> = HashMap::new();
for day in &days {
for (model, stats) in &day.by_model {
let entry = by_model_totals
.entry(model.clone())
.or_insert_with(|| ModelStats {
model: model.clone(),
cost_usd: 0.0,
total_tokens: 0,
request_count: 0,
});
entry.cost_usd += stats.cost_usd;
entry.total_tokens = entry.total_tokens.saturating_add(stats.total_tokens);
entry.request_count += stats.request_count;
}
}
let mut by_model: Vec<ModelStats> = by_model_totals.into_values().collect();
by_model.sort_by(|a, b| {
b.cost_usd
.partial_cmp(&a.cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.model.cmp(&b.model))
});
Ok(CostDashboard {
days,
period_total_usd,
monthly_pace_usd,
budget_limit_monthly_usd,
month_to_date_usd,
budget_utilization,
budget_status,
currency: currency.to_string(),
by_model,
})
}
}
fn resolve_storage_path(workspace_dir: &Path) -> Result<PathBuf> {
+173
View File
@@ -1,4 +1,5 @@
use super::*;
use chrono::{Datelike, Duration};
use tempfile::TempDir;
fn enabled_config() -> CostConfig {
@@ -157,6 +158,22 @@ fn record_usage_when_disabled_is_noop() {
assert_eq!(summary.request_count, 0);
}
#[test]
fn record_usage_unconditional_bypasses_disabled_gate() {
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: false,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0);
tracker.record_usage_unconditional(usage.clone()).unwrap();
let summary = tracker.get_summary().unwrap();
assert_eq!(summary.request_count, 1);
let today_cost = tracker.get_daily_cost(Utc::now().date_naive()).unwrap();
assert!((today_cost - usage.cost_usd).abs() < f64::EPSILON);
}
#[test]
fn record_usage_rejects_negative_cost() {
let tmp = TempDir::new().unwrap();
@@ -244,6 +261,162 @@ fn get_monthly_cost_for_current_month() {
assert!((monthly_cost - usage.cost_usd).abs() < 0.001);
}
fn write_raw_record(workspace: &Path, record: &CostRecord) {
let storage_path = resolve_storage_path(workspace).unwrap();
if let Some(parent) = storage_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(storage_path)
.unwrap();
writeln!(file, "{}", serde_json::to_string(record).unwrap()).unwrap();
file.sync_all().unwrap();
}
fn dated_record(session: &str, model: &str, cost: f64, when: chrono::DateTime<Utc>) -> CostRecord {
let mut usage = TokenUsage::new(model, 1000, 500, 1.0, 1.0);
usage.cost_usd = cost;
usage.timestamp = when;
CostRecord::new(session, usage)
}
#[test]
fn get_daily_history_returns_seven_days_with_gaps_filled() {
let tmp = TempDir::new().unwrap();
let today = Utc::now();
let three_days_ago = today - Duration::days(3);
let six_days_ago = today - Duration::days(6);
write_raw_record(
tmp.path(),
&dated_record("s1", "model-a", 1.50, three_days_ago),
);
write_raw_record(
tmp.path(),
&dated_record("s1", "model-b", 0.50, six_days_ago),
);
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
let history = tracker.get_daily_history(7).unwrap();
assert_eq!(history.len(), 7);
// Oldest first → six_days_ago
assert_eq!(history[0].date, six_days_ago.date_naive());
assert!((history[0].cost_usd - 0.50).abs() < f64::EPSILON);
// Three days ago has the second record
assert_eq!(history[3].date, three_days_ago.date_naive());
assert!((history[3].cost_usd - 1.50).abs() < f64::EPSILON);
// Today is the last bucket
assert_eq!(history[6].date, today.date_naive());
assert!(history[6].cost_usd.abs() < f64::EPSILON);
assert_eq!(history[6].request_count, 0);
}
#[test]
fn get_daily_history_excludes_out_of_window_records() {
let tmp = TempDir::new().unwrap();
let today = Utc::now();
let ten_days_ago = today - Duration::days(10);
write_raw_record(
tmp.path(),
&dated_record("s1", "model-a", 99.0, ten_days_ago),
);
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
let history = tracker.get_daily_history(7).unwrap();
assert_eq!(history.len(), 7);
let total: f64 = history.iter().map(|e| e.cost_usd).sum();
assert!(total.abs() < f64::EPSILON);
}
#[test]
fn get_daily_history_clamps_days_argument() {
let tmp = TempDir::new().unwrap();
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
assert_eq!(tracker.get_daily_history(0).unwrap().len(), 1);
assert_eq!(tracker.get_daily_history(367).unwrap().len(), 366);
}
#[test]
fn get_dashboard_computes_period_total_and_monthly_pace() {
let tmp = TempDir::new().unwrap();
let today = Utc::now();
write_raw_record(tmp.path(), &dated_record("s1", "model-a", 2.0, today));
write_raw_record(
tmp.path(),
&dated_record("s1", "model-b", 0.5, today - Duration::days(1)),
);
let config = CostConfig {
enabled: true,
monthly_limit_usd: 100.0,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let dash = tracker.get_dashboard("USD", 0.8, 0.95).unwrap();
assert_eq!(dash.days.len(), 7);
assert!((dash.period_total_usd - 2.5).abs() < 0.0001);
// daily avg = 2.5/7, monthly pace = avg * 30
let expected_pace = (2.5 / 7.0) * 30.0;
assert!((dash.monthly_pace_usd - expected_pace).abs() < 0.0001);
assert_eq!(dash.currency, "USD");
// 2.5 spend on 100 budget → 2.5% utilisation, well below 80% warn.
assert_eq!(dash.budget_status, BudgetStatus::Normal);
}
#[test]
fn get_dashboard_budget_status_warning_and_exceeded() {
let tmp = TempDir::new().unwrap();
let today = Utc::now();
write_raw_record(tmp.path(), &dated_record("s1", "model-a", 85.0, today));
let config = CostConfig {
enabled: true,
monthly_limit_usd: 100.0,
..Default::default()
};
let tracker = CostTracker::new(config.clone(), tmp.path()).unwrap();
let warn_dash = tracker.get_dashboard("USD", 0.8, 0.95).unwrap();
assert_eq!(warn_dash.budget_status, BudgetStatus::Warning);
write_raw_record(tmp.path(), &dated_record("s1", "model-a", 15.0, today));
let tracker2 = CostTracker::new(config, tmp.path()).unwrap();
let alert_dash = tracker2.get_dashboard("USD", 0.8, 0.95).unwrap();
assert_eq!(alert_dash.budget_status, BudgetStatus::Exceeded);
assert!((alert_dash.budget_utilization - 1.0).abs() < f64::EPSILON);
}
#[test]
fn get_dashboard_budget_status_normal_when_limit_zero() {
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: true,
monthly_limit_usd: 0.0,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let dash = tracker.get_dashboard("USD", 0.8, 0.95).unwrap();
assert_eq!(dash.budget_status, BudgetStatus::Normal);
assert!(dash.budget_utilization.abs() < f64::EPSILON);
}
#[test]
fn get_dashboard_by_model_is_sorted_desc() {
let tmp = TempDir::new().unwrap();
let today = Utc::now();
write_raw_record(tmp.path(), &dated_record("s1", "model-a", 1.0, today));
write_raw_record(tmp.path(), &dated_record("s1", "model-b", 5.0, today));
write_raw_record(tmp.path(), &dated_record("s1", "model-c", 3.0, today));
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
let dash = tracker.get_dashboard("USD", 0.8, 0.95).unwrap();
assert_eq!(dash.by_model.len(), 3);
assert_eq!(dash.by_model[0].model, "model-b");
assert_eq!(dash.by_model[1].model, "model-c");
assert_eq!(dash.by_model[2].model, "model-a");
}
#[test]
fn build_session_model_stats_aggregates_correctly() {
let records = vec![
+75
View File
@@ -1,3 +1,4 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
/// Token usage information from a single API call.
@@ -152,6 +153,80 @@ impl Default for CostSummary {
}
}
/// Per-day aggregate of cost and token usage for the dashboard charts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyCostEntry {
/// Calendar date in UTC (YYYY-MM-DD).
pub date: NaiveDate,
/// Total cost in USD for the day.
pub cost_usd: f64,
/// Total input tokens for the day.
pub input_tokens: u64,
/// Total output tokens for the day.
pub output_tokens: u64,
/// Sum of input + output tokens for the day.
pub total_tokens: u64,
/// Number of recorded requests for the day.
pub request_count: usize,
/// Per-model aggregates for the day.
pub by_model: std::collections::HashMap<String, ModelStats>,
}
impl DailyCostEntry {
/// Construct an empty entry for the given date — used to fill gaps when
/// no usage was recorded on a calendar day so charts still render the bar.
pub fn empty(date: NaiveDate) -> Self {
Self {
date,
cost_usd: 0.0,
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
request_count: 0,
by_model: std::collections::HashMap::new(),
}
}
}
/// Budget status derived from the configured warn/alert thresholds and the
/// current month-to-date spend. Drives bar colour-coding on the dashboard.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BudgetStatus {
/// Spend is below the warn threshold.
Normal,
/// Spend is above warn but below alert.
Warning,
/// Spend has reached or crossed the alert threshold.
Exceeded,
}
/// Aggregate dashboard payload returned by `cost_get_dashboard`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostDashboard {
/// 7-day daily entries, oldest first, gaps zero-filled.
pub days: Vec<DailyCostEntry>,
/// Sum of `cost_usd` across `days`.
pub period_total_usd: f64,
/// Projected monthly spend: daily average × 30.
pub monthly_pace_usd: f64,
/// Configured monthly budget limit (USD).
pub budget_limit_monthly_usd: f64,
/// Month-to-date spend (USD).
pub month_to_date_usd: f64,
/// Fraction of the monthly budget consumed (`month_to_date / limit`).
/// Capped at 1.0 for display purposes; UIs that need overrun should
/// recompute from `month_to_date_usd` and `budget_limit_monthly_usd`.
pub budget_utilization: f64,
/// Derived status based on warn/alert thresholds.
pub budget_status: BudgetStatus,
/// Display currency label, e.g. "USD". All amounts are stored in USD;
/// this is purely a presentation hint.
pub currency: String,
/// Per-model breakdown across the 7-day window, sorted by cost desc.
pub by_model: Vec<ModelStats>,
}
#[cfg(test)]
mod tests {
use super::*;