diff --git a/app/package.json b/app/package.json index c24b218d3..3fed4bdae 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/components/dashboard/BudgetSummary.test.tsx b/app/src/components/dashboard/BudgetSummary.test.tsx new file mode 100644 index 000000000..a80905dd5 --- /dev/null +++ b/app/src/components/dashboard/BudgetSummary.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import BudgetSummary from './BudgetSummary'; + +describe('', () => { + it('renders all four metric tiles and the status badge', () => { + render( + + ); + 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( + + ); + expect(screen.getByTestId('budget-status-badge')).toHaveTextContent(/warning/i); + }); + + it('reflects "exceeded" status and clamps utilisation bar to 100%', () => { + render( + + ); + 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( + + ); + expect(screen.getByTestId('metric-budget-limit')).toHaveTextContent(/no limit set/i); + }); +}); diff --git a/app/src/components/dashboard/BudgetSummary.tsx b/app/src/components/dashboard/BudgetSummary.tsx new file mode 100644 index 000000000..5a1fd2e54 --- /dev/null +++ b/app/src/components/dashboard/BudgetSummary.tsx @@ -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 = { + normal: 'bg-sage-500', + warning: 'bg-amber-500', + exceeded: 'bg-coral-500', +}; + +const STATUS_TEXT: Record = { + 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 = { + 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 ( +
+ {/* Hero tile: 7-day total + status badge + progress bar */} +
+
+
+ + {t('settings.costDashboard.totalSpend')} +
+ + + {t(STATUS_LABEL_KEY[status])} + +
+
+ + {formatCurrency(periodTotalUsd, currency)} + + + {t('settings.costDashboard.lastSevenDays')} + +
+
+
+ + {budgetLimitMonthlyUsd > 0 + ? `${formatCurrency(monthToDateUsd, currency)} ${t('settings.costDashboard.utilizationOf')} ${formatCurrency(budgetLimitMonthlyUsd, currency)} ${t('settings.costDashboard.thisMonth')}` + : `${formatCurrency(monthToDateUsd, currency)} ${t('settings.costDashboard.thisMonth')}`} + + + {`${utilizationPct}%`} + +
+
+
+
+
+
+ +
+ } + label={t('settings.costDashboard.monthlyPace')} + value={formatCurrency(monthlyPaceUsd, currency)} + hint={t('settings.costDashboard.monthlyPaceHint')} + testId="metric-monthly-pace" + /> + } + label={t('settings.costDashboard.budgetLimit')} + value={ + budgetLimitMonthlyUsd > 0 + ? formatCurrency(budgetLimitMonthlyUsd, currency) + : t('settings.costDashboard.noBudget') + } + hint={t('settings.costDashboard.budgetLimitHint')} + testId="metric-budget-limit" + /> +
+
+ ); +}; + +interface SmallMetricProps { + icon: ReactNode; + label: string; + value: string; + hint: string; + testId: string; +} + +const SmallMetric = ({ icon, label, value, hint, testId }: SmallMetricProps) => ( +
+
+ {icon} + {label} +
+ + {value} + +
+); + +interface IconProps { + className?: string; +} + +const WalletIcon = ({ className }: IconProps) => ( + + + + + +); + +const TrendingUpIcon = ({ className }: IconProps) => ( + + + + +); + +const TargetIcon = ({ className }: IconProps) => ( + + + + + +); + +export default BudgetSummary; diff --git a/app/src/components/dashboard/ChartTooltip.test.tsx b/app/src/components/dashboard/ChartTooltip.test.tsx new file mode 100644 index 000000000..19a93d578 --- /dev/null +++ b/app/src/components/dashboard/ChartTooltip.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import ChartTooltip from './ChartTooltip'; + +describe('', () => { + it('renders title, rows, and a coloured swatch when colour is provided', () => { + render( + + ); + 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( + + ); + expect(screen.getByTestId('chart-tooltip')).toHaveTextContent('Daily target: $3.33'); + }); + + it('omits the footer block when none provided', () => { + const { container } = render( + + ); + // Footer is a div with the border-t class; assert it is not rendered. + expect(container.querySelector('.border-t')).toBeNull(); + }); +}); diff --git a/app/src/components/dashboard/ChartTooltip.tsx b/app/src/components/dashboard/ChartTooltip.tsx new file mode 100644 index 000000000..1cafe2b5f --- /dev/null +++ b/app/src/components/dashboard/ChartTooltip.tsx @@ -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) => ( +
+
{title}
+
    + {rows.map(row => ( +
  • + {row.color && ( + + )} + {row.label} + {row.value} +
  • + ))} +
+ {footer && ( +
+ {footer} +
+ )} +
+); + +export default ChartTooltip; diff --git a/app/src/components/dashboard/CostBarChart.test.tsx b/app/src/components/dashboard/CostBarChart.test.tsx new file mode 100644 index 000000000..8c992d293 --- /dev/null +++ b/app/src/components/dashboard/CostBarChart.test.tsx @@ -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'); + }); +}); diff --git a/app/src/components/dashboard/CostBarChart.tsx b/app/src/components/dashboard/CostBarChart.tsx new file mode 100644 index 000000000..467e71901 --- /dev/null +++ b/app/src/components/dashboard/CostBarChart.tsx @@ -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 ( +
+ {dailyTarget > 0 && ( +
+ + + {`${t('settings.costDashboard.dailyTarget')}: ${formatCurrency(dailyTarget, currency)}`} + +
+ )} +
+ + + + + formatCurrency(v, currency)} + /> + { + const item = props.payload?.[0]?.payload as ChartPoint | undefined; + if (!item) return null; + return ( + 0 + ? `${t('settings.costDashboard.dailyTarget')}: ${formatCurrency(dailyTarget, currency)}` + : undefined + } + /> + ); + }} + /> + {dailyTarget > 0 && ( + + )} + + {chartData.map(entry => ( + + ))} + { + if (!value) return null; + const cx = Number(x ?? 0) + Number(width ?? 0) / 2; + const cy = Math.max(0, Number(y ?? 0) - 6); + return ( + + + + {t('settings.costDashboard.todayBadge')} + + + ); + }} + /> + + + +
+
+ ); +}; + +export default CostBarChart; diff --git a/app/src/components/dashboard/CostDashboardPanel.test.tsx b/app/src/components/dashboard/CostDashboardPanel.test.tsx new file mode 100644 index 000000000..69c1a5fc0 --- /dev/null +++ b/app/src/components/dashboard/CostDashboardPanel.test.tsx @@ -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 }) => ( +
{children}
+ ); + 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; + +function makeStore() { + return configureStore({ reducer: { locale: (state = { current: 'en' }) => state } }); +} + +function renderPanel() { + return render( + + + + + + ); +} + +describe('', () => { + 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/) + ); + }); +}); diff --git a/app/src/components/dashboard/CostDashboardPanel.tsx b/app/src/components/dashboard/CostDashboardPanel.tsx new file mode 100644 index 000000000..0811fd8cc --- /dev/null +++ b/app/src/components/dashboard/CostDashboardPanel.tsx @@ -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 ( +
+ +
+
+

+ {t('settings.costDashboard.subtitle')} +

+
+ {lastUpdated !== null && ( + + + {`${t('settings.costDashboard.updated')} ${relativeTime(lastUpdated, t)}`} + + )} + +
+
+ + {error && ( +
+ {error} +
+ )} + {data && !data.enabled && ( +
+ {t('settings.costDashboard.disabledHint')} +
+ )} + + {!data && isLoading && } + + {data && ( + <> + +
+
+

+ {t('settings.costDashboard.sevenDayCost')} +

+ + {t('settings.costDashboard.utcNote')} + +
+ +
+
+
+

+ {t('settings.costDashboard.sevenDayTokens')} +

+ + {t('settings.costDashboard.stackedNote')} + +
+ +
+
+
+

+ {t('settings.costDashboard.modelBreakdown')} +

+

+ {t('settings.costDashboard.modelBreakdownHint')} +

+
+ +
+ {!hasAnyCost && ( +
+
+ {t('settings.costDashboard.noData')} +
+
+ {t('settings.costDashboard.noDataHint')} +
+
+ )} + + )} +
+
+ ); +}; + +interface IconProps { + className?: string; +} + +const RefreshIcon = ({ className }: IconProps) => ( + + + + + + +); + +export default CostDashboardPanel; diff --git a/app/src/components/dashboard/DashboardSkeleton.test.tsx b/app/src/components/dashboard/DashboardSkeleton.test.tsx new file mode 100644 index 000000000..fb83daced --- /dev/null +++ b/app/src/components/dashboard/DashboardSkeleton.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import DashboardSkeleton from './DashboardSkeleton'; + +describe('', () => { + it('renders the loading skeleton container with accessible status role', () => { + render(); + const node = screen.getByTestId('cost-dashboard-skeleton'); + expect(node).toBeInTheDocument(); + expect(node).toHaveAttribute('role', 'status'); + expect(node).toHaveAttribute('aria-live', 'polite'); + }); +}); diff --git a/app/src/components/dashboard/DashboardSkeleton.tsx b/app/src/components/dashboard/DashboardSkeleton.tsx new file mode 100644 index 000000000..c37e1a7c6 --- /dev/null +++ b/app/src/components/dashboard/DashboardSkeleton.tsx @@ -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 = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+); + +export default DashboardSkeleton; diff --git a/app/src/components/dashboard/ModelCostTable.test.tsx b/app/src/components/dashboard/ModelCostTable.test.tsx new file mode 100644 index 000000000..b6a8f29df --- /dev/null +++ b/app/src/components/dashboard/ModelCostTable.test.tsx @@ -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('', () => { + it('renders one row per model with cost, tokens, requests, and percent', () => { + render(); + 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(); + expect(screen.getByTestId('model-cost-table-empty')).toBeInTheDocument(); + }); + + it('renders a dash when the provider is unknown', () => { + render( + + ); + expect(screen.getByText('—')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/dashboard/ModelCostTable.tsx b/app/src/components/dashboard/ModelCostTable.tsx new file mode 100644 index 000000000..16857b69e --- /dev/null +++ b/app/src/components/dashboard/ModelCostTable.tsx @@ -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 = { + 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 ( +
+ {t('settings.costDashboard.noModels')} +
+ ); + } + + return ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
{t('settings.costDashboard.model')}{t('settings.costDashboard.provider')}{t('settings.costDashboard.tokens')}{t('settings.costDashboard.requests')}{t('settings.costDashboard.cost')}{t('settings.costDashboard.percentOfTotal')}
+
+ {modelName} +
+
+ + {row.provider ?? t('settings.costDashboard.unknownProvider')} + + + + {formatTokens(row.total_tokens)} + + + + {row.request_count} + + + + {formatCurrency(row.cost_usd, currency)} + + +
+
+
+
+ + {`${sharePct.toFixed(1)}%`} + +
+
+
+ ); +}; + +interface CellProps { + children: React.ReactNode; + align?: 'left' | 'right'; +} + +const Th = ({ children, align = 'left' }: CellProps) => ( + + {children} + +); + +const Td = ({ children, align = 'left' }: CellProps) => ( + {children} +); + +export default ModelCostTable; diff --git a/app/src/components/dashboard/TokenUsageChart.test.tsx b/app/src/components/dashboard/TokenUsageChart.test.tsx new file mode 100644 index 000000000..980449f70 --- /dev/null +++ b/app/src/components/dashboard/TokenUsageChart.test.tsx @@ -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 }) => ( +
{children}
+ ); + 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('', () => { + it('renders the chart wrapper with the expected test-id', () => { + render(); + expect(screen.getByTestId('token-usage-chart')).toBeInTheDocument(); + }); + + it('renders gracefully when given an empty day list', () => { + render(); + expect(screen.getByTestId('token-usage-chart')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/dashboard/TokenUsageChart.tsx b/app/src/components/dashboard/TokenUsageChart.tsx new file mode 100644 index 000000000..46a3d0a77 --- /dev/null +++ b/app/src/components/dashboard/TokenUsageChart.tsx @@ -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 ( +
+ + + + + formatTokens(v)} + /> + { + const item = props.payload?.[0]?.payload as TokenPoint | undefined; + if (!item) return null; + return ( + + ); + }} + /> + + String(value) === 'input' + ? t('settings.costDashboard.inputTokens') + : t('settings.costDashboard.outputTokens') + } + wrapperStyle={{ fontSize: '11px' }} + iconType="circle" + /> + + + + +
+ ); +}; + +export default TokenUsageChart; diff --git a/app/src/components/dashboard/formatCurrency.test.ts b/app/src/components/dashboard/formatCurrency.test.ts new file mode 100644 index 000000000..5de95ba56 --- /dev/null +++ b/app/src/components/dashboard/formatCurrency.test.ts @@ -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)'); + }); +}); diff --git a/app/src/components/dashboard/formatCurrency.ts b/app/src/components/dashboard/formatCurrency.ts new file mode 100644 index 000000000..d8c37cc39 --- /dev/null +++ b/app/src/components/dashboard/formatCurrency.ts @@ -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)); +} diff --git a/app/src/hooks/useCostDashboard.test.ts b/app/src/hooks/useCostDashboard.test.ts new file mode 100644 index 000000000..646c4bcb3 --- /dev/null +++ b/app/src/hooks/useCostDashboard.test.ts @@ -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; + +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(); + }); +}); diff --git a/app/src/hooks/useCostDashboard.ts b/app/src/hooks/useCostDashboard.ts new file mode 100644 index 000000000..9c63eb131 --- /dev/null +++ b/app/src/hooks/useCostDashboard.ts @@ -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 { + 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; +} + +/** + * 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(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isFetching, setIsFetching] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const cancelledRef = useRef(false); + + const fetchOnce = useCallback(async () => { + setIsFetching(true); + try { + const response = await callCoreRpc | 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 }; +} diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index c8d60bd4c..6d3e5cc8b 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index 25bfe0c54..797d9c35f 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index f05ee79fd..951d5035f 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index 45c025cf7..5de532849 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index 961aae1db..8f3b3f0df 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index 62a2d42e8..90f0bf295 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index b90e96a7e..5512e58dd 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index a44393f21..645b3c9a3 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index 94eb04a1b..421096663 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index 9a01ac35a..31136cd16 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index c575234ab..d212f2cb9 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index ce1fd8ac3..bfb50924f 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -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.', diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index db7fcd2ee..bf6611ae6 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -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.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index c610cb5f9..ddef7088f 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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.', diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index ea272db66..f9c9ff6a0 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -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(, { maxWidthClass: 'max-w-4xl' })} /> + , { maxWidthClass: 'max-w-4xl' })} + /> )} /> )} /> )} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c6ce20c7..04152c806 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 8393a2a75..0c6d3c5fe 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -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 `/agents/*.toml`. Idempotent — safe to call diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 2ed224d85..ac749065a 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -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; diff --git a/src/openhuman/config/schema/identity_cost.rs b/src/openhuman/config/schema/identity_cost.rs index 00d38d847..18d014388 100644 --- a/src/openhuman/config/schema/identity_cost.rs +++ b/src/openhuman/config/schema/identity_cost.rs @@ -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, + + /// 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] diff --git a/src/openhuman/cost/global.rs b/src/openhuman/cost/global.rs new file mode 100644 index 000000000..9f02d4415 --- /dev/null +++ b/src/openhuman/cost/global.rs @@ -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> = 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> { + 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 { + 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()); + } +} diff --git a/src/openhuman/cost/mod.rs b/src/openhuman/cost/mod.rs index cd84be668..346a5a908 100644 --- a/src/openhuman/cost/mod.rs +++ b/src/openhuman/cost/mod.rs @@ -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, +}; diff --git a/src/openhuman/cost/rpc.rs b/src/openhuman/cost/rpc.rs new file mode 100644 index 000000000..dcf004a5b --- /dev/null +++ b/src/openhuman/cost/rpc.rs @@ -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, +} + +#[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, + pub percent_of_total: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CostDashboardDto { + pub days: Vec, + 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, +} + +#[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, +} + +fn provider_for(model: &str) -> Option { + 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 = 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 = 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>, + last_error: Option<(Instant, String)>, +} + +static FALLBACK_TRACKER: Mutex> = Mutex::new(None); +const FALLBACK_ERROR_TTL: Duration = Duration::from_secs(30); + +fn resolve_tracker(config: &Config) -> Result> { + 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> { + 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> { + 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 = 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> { + 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; 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}"); + } +} diff --git a/src/openhuman/cost/schemas.rs b/src/openhuman/cost/schemas.rs index ecb39697d..68e3f92af 100644 --- a/src/openhuman/cost/schemas.rs +++ b/src/openhuman/cost/schemas.rs @@ -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, +} pub fn all_controller_schemas() -> Vec { - 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 { - 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) -> 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) -> 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::(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) -> 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) -> Result { + 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); + } } diff --git a/src/openhuman/cost/tracker.rs b/src/openhuman/cost/tracker.rs index ccfa4341a..d42c7847e 100644 --- a/src/openhuman/cost/tracker.rs +++ b/src/openhuman/cost/tracker.rs @@ -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> { + 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 = 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 { + 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 = 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 = 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 { diff --git a/src/openhuman/cost/tracker_tests.rs b/src/openhuman/cost/tracker_tests.rs index f77477131..fc8db7021 100644 --- a/src/openhuman/cost/tracker_tests.rs +++ b/src/openhuman/cost/tracker_tests.rs @@ -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) -> 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![ diff --git a/src/openhuman/cost/types.rs b/src/openhuman/cost/types.rs index 0e8d16797..3ca0ae3b1 100644 --- a/src/openhuman/cost/types.rs +++ b/src/openhuman/cost/types.rs @@ -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, +} + +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, + /// 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, +} + #[cfg(test)] mod tests { use super::*;