mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(cost): add settings cost dashboard with global tracker, dashboard RPCs, and charts (#2762)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 />)} />
|
||||
|
||||
Generated
+254
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user