Improve AI cost dashboard ranges and breakdowns

This commit is contained in:
rcourtman 2025-12-12 11:35:41 +00:00
parent 47eefe6763
commit 5ba4e6a84c
5 changed files with 321 additions and 8 deletions

View file

@ -1,4 +1,4 @@
import { Component, Show, createMemo, createSignal, onMount, For } from 'solid-js';
import { Component, Show, createMemo, createSignal, onMount, For, createEffect } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { AIAPI } from '@/api/ai';
@ -15,11 +15,50 @@ const usdFormatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2,
});
const budgetStorageKey = 'pulse_ai_cost_budget_usd_v1';
const TinySparkline: Component<{
values: number[];
width?: number;
height?: number;
stroke?: string;
}> = (props) => {
const width = () => props.width ?? 160;
const height = () => props.height ?? 28;
const stroke = () => props.stroke ?? '#22c55e';
const pathD = createMemo(() => {
const values = props.values;
const w = width();
const h = height();
if (!values || values.length === 0) return '';
const max = Math.max(...values, 0);
const safeMax = max <= 0 ? 1 : max;
const xStep = values.length <= 1 ? 0 : w / (values.length - 1);
let d = '';
values.forEach((v, idx) => {
const x = idx * xStep;
const y = h - (Math.max(0, v) / safeMax) * h;
d += `${idx === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)} `;
});
return d.trim();
});
return (
<svg width={width()} height={height()} viewBox={`0 0 ${width()} ${height()}`} class="block">
<path d={pathD()} fill="none" stroke={stroke()} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
};
export const AICostDashboard: Component = () => {
const [days, setDays] = createSignal(30);
const [loading, setLoading] = createSignal(false);
const [loadError, setLoadError] = createSignal<string | null>(null);
const [summary, setSummary] = createSignal<AICostSummary | null>(null);
const [budgetUSD, setBudgetUSD] = createSignal<string>('');
let requestSeq = 0;
const anyPricingKnown = createMemo(() => {
@ -34,15 +73,29 @@ export const AICostDashboard: Component = () => {
return data.totals.estimated_usd ?? 0;
});
const useCaseMap = createMemo(() => {
const data = summary();
const map = new Map<string, { tokens: number; usd: number; pricingKnown: boolean }>();
if (!data) return map;
for (const uc of data.use_cases ?? []) {
map.set(uc.use_case, {
tokens: uc.total_tokens,
usd: uc.estimated_usd ?? 0,
pricingKnown: uc.pricing_known,
});
}
return map;
});
const dailyTokenValues = createMemo(() => (summary()?.daily_totals ?? []).map((d) => d.total_tokens));
const dailyUSDValues = createMemo(() => (summary()?.daily_totals ?? []).map((d) => d.estimated_usd ?? 0));
const formatUSD = (usd: number) => usdFormatter.format(usd);
const loadSummary = async (rangeDays: number) => {
const seq = ++requestSeq;
const isInitialLoad = summary() === null;
// Only show loading indicator on initial load to prevent flicker on range changes
if (isInitialLoad) {
setLoading(true);
}
setLoading(true);
setLoadError(null);
try {
const data = await AIAPI.getCostSummary(rangeDays);
@ -67,6 +120,40 @@ export const AICostDashboard: Component = () => {
loadSummary(days());
});
onMount(() => {
try {
const stored = localStorage.getItem(budgetStorageKey);
if (stored) setBudgetUSD(stored);
} catch (_err) {
// ignore
}
});
createEffect(() => {
const value = budgetUSD();
try {
if (!value) localStorage.removeItem(budgetStorageKey);
else localStorage.setItem(budgetStorageKey, value);
} catch (_err) {
// ignore
}
});
const parsedBudgetUSD = createMemo(() => {
const raw = budgetUSD().trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
});
const isOverBudget = createMemo(() => {
const budget = parsedBudgetUSD();
const usd = estimatedTotalUSD();
if (budget == null || usd == null) return false;
return usd > budget;
});
const handleRangeClick = (rangeDays: number) => {
if (loading() || rangeDays === days()) return;
setDays(rangeDays);
@ -156,6 +243,18 @@ export const AICostDashboard: Component = () => {
<div class="text-sm text-gray-500 dark:text-gray-400">Loading usage</div>
</Show>
<Show when={summary()?.truncated}>
<div class="text-xs px-3 py-2 rounded border border-blue-200 dark:border-blue-800/60 bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-100">
Showing the last {summary()?.effective_days} days due to a {summary()?.retention_days}-day retention window.
</div>
</Show>
<Show when={isOverBudget()}>
<div class="text-xs px-3 py-2 rounded border border-red-200 dark:border-red-800/60 bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100">
Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(parsedBudgetUSD() ?? 0)}).
</div>
</Show>
<Show when={loadError() && summary()}>
<div class="flex items-center justify-between gap-3 text-xs px-3 py-2 rounded border border-amber-200 dark:border-amber-800/60 bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-100">
<div class="truncate">
@ -209,6 +308,70 @@ export const AICostDashboard: Component = () => {
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Chat</div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{formatNumber(useCaseMap().get('chat')?.tokens ?? 0)} tokens
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<Show
when={useCaseMap().get('chat')?.pricingKnown}
fallback={<span></span>}
>
{formatUSD(useCaseMap().get('chat')?.usd ?? 0)}
</Show>
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Patrol</div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{formatNumber(useCaseMap().get('patrol')?.tokens ?? 0)} tokens
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<Show
when={useCaseMap().get('patrol')?.pricingKnown}
fallback={<span></span>}
>
{formatUSD(useCaseMap().get('patrol')?.usd ?? 0)}
</Show>
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Budget (USD)</div>
<input
value={budgetUSD()}
onInput={(e) => setBudgetUSD(e.currentTarget.value)}
placeholder="e.g. 25"
class="mt-1 w-full px-2 py-1 text-sm rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
/>
<div class="text-[11px] text-gray-500 dark:text-gray-400 mt-1">
Applies to selected range.
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-xs text-gray-500 dark:text-gray-400">Daily estimated USD</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{data().daily_totals.length} points</div>
</div>
<div class="mt-2">
<TinySparkline values={dailyUSDValues()} stroke="#10b981" />
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-xs text-gray-500 dark:text-gray-400">Daily total tokens</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{data().daily_totals.length} points</div>
</div>
<div class="mt-2">
<TinySparkline values={dailyTokenValues()} stroke="#3b82f6" />
</div>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
USD is an estimate based on public list prices. It may differ from billing.
</div>

View file

@ -189,9 +189,22 @@ export interface AICostDailySummary {
estimated_usd?: number;
}
export interface AICostUseCaseSummary {
use_case: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
estimated_usd?: number;
pricing_known: boolean;
}
export interface AICostSummary {
days: number;
retention_days: number;
effective_days: number;
truncated: boolean;
provider_models: AICostProviderModelSummary[];
use_cases: AICostUseCaseSummary[];
daily_totals: AICostDailySummary[];
totals: AICostProviderModelSummary;
}

View file

@ -31,7 +31,7 @@ type Persistence interface {
}
// DefaultMaxDays is the default retention window for raw usage events.
const DefaultMaxDays = 90
const DefaultMaxDays = 365
// Store provides thread-safe usage tracking with optional persistence.
type Store struct {
@ -100,7 +100,15 @@ func (s *Store) GetSummary(days int) Summary {
}
now := time.Now()
cutoff := now.AddDate(0, 0, -days)
retentionDays := s.maxDays
effectiveDays := days
truncated := false
if retentionDays > 0 && effectiveDays > retentionDays {
effectiveDays = retentionDays
truncated = true
}
cutoff := now.AddDate(0, 0, -effectiveDays)
s.mu.RLock()
events := make([]UsageEvent, 0, len(s.events))
@ -188,12 +196,17 @@ func (s *Store) GetSummary(days int) Summary {
for _, pm := range providerModels {
if pm.PricingKnown {
totals.EstimatedUSD += pm.EstimatedUSD
totals.PricingKnown = true
}
}
return Summary{
Days: days,
RetentionDays: retentionDays,
EffectiveDays: effectiveDays,
Truncated: truncated,
ProviderModels: providerModels,
UseCases: summarizeUseCases(events),
DailyTotals: daily,
Totals: totals,
}
@ -312,10 +325,90 @@ type DailySummary struct {
EstimatedUSD float64 `json:"estimated_usd,omitempty"`
}
// UseCaseSummary is a rollup for a use-case (e.g. "chat", "patrol").
type UseCaseSummary struct {
UseCase string `json:"use_case"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
EstimatedUSD float64 `json:"estimated_usd,omitempty"`
PricingKnown bool `json:"pricing_known"`
}
// Summary is returned by the cost summary API.
type Summary struct {
Days int `json:"days"`
Days int `json:"days"`
RetentionDays int `json:"retention_days"`
EffectiveDays int `json:"effective_days"`
Truncated bool `json:"truncated"`
ProviderModels []ProviderModelSummary `json:"provider_models"`
UseCases []UseCaseSummary `json:"use_cases"`
DailyTotals []DailySummary `json:"daily_totals"`
Totals ProviderModelSummary `json:"totals"`
}
func summarizeUseCases(events []UsageEvent) []UseCaseSummary {
type totals struct {
input int64
output int64
usd float64
known bool
}
perUseCase := make(map[string]*totals)
for _, e := range events {
useCase := strings.TrimSpace(strings.ToLower(e.UseCase))
if useCase == "" {
useCase = "unknown"
}
t := perUseCase[useCase]
if t == nil {
t = &totals{}
perUseCase[useCase] = t
}
t.input += int64(e.InputTokens)
t.output += int64(e.OutputTokens)
provider := strings.ToLower(strings.TrimSpace(e.Provider))
model := normalizeModel(provider, e.RequestModel, e.ResponseModel)
provider, model = inferProviderAndModel(provider, model)
usd, known, _ := EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens))
if known {
t.usd += usd
t.known = true
}
}
out := make([]UseCaseSummary, 0, len(perUseCase))
for useCase, t := range perUseCase {
out = append(out, UseCaseSummary{
UseCase: useCase,
InputTokens: t.input,
OutputTokens: t.output,
TotalTokens: t.input + t.output,
EstimatedUSD: t.usd,
PricingKnown: t.known,
})
}
order := map[string]int{
"chat": 0,
"patrol": 1,
"unknown": 2,
}
sort.Slice(out, func(i, j int) bool {
oi, okI := order[out[i].UseCase]
oj, okJ := order[out[j].UseCase]
if okI && okJ && oi != oj {
return oi < oj
}
if okI != okJ {
return okI
}
return out[i].UseCase < out[j].UseCase
})
return out
}

View file

@ -99,6 +99,18 @@ func TestSummaryGroupsByProviderModelAndDailyTotals(t *testing.T) {
if dailyGot[todayKey].InputTokens != 200 || dailyGot[todayKey].OutputTokens != 100 {
t.Fatalf("daily totals for %s wrong: %+v", todayKey, dailyGot[todayKey])
}
// Use-case rollups.
useCases := make(map[string]UseCaseSummary)
for _, uc := range summary.UseCases {
useCases[uc.UseCase] = uc
}
if useCases["chat"].TotalTokens != (110 + 55 + 200 + 100) {
t.Fatalf("chat use-case totals wrong: %+v", useCases["chat"])
}
if useCases["patrol"].TotalTokens != (20 + 10) {
t.Fatalf("patrol use-case totals wrong: %+v", useCases["patrol"])
}
}
func TestRetentionTrimsOldEvents(t *testing.T) {
@ -119,6 +131,28 @@ func TestRetentionTrimsOldEvents(t *testing.T) {
}
}
func TestSummaryTruncationReflectsRetentionWindow(t *testing.T) {
store := NewStore(30)
now := time.Now()
store.Record(UsageEvent{
Timestamp: now.Add(-10 * 24 * time.Hour),
Provider: "openai",
RequestModel: "openai:gpt-4o",
InputTokens: 10,
OutputTokens: 10,
UseCase: "chat",
})
summary := store.GetSummary(365)
if !summary.Truncated {
t.Fatalf("expected summary to be truncated when requesting beyond retention")
}
if summary.RetentionDays != 30 || summary.EffectiveDays != 30 {
t.Fatalf("unexpected retention/effective days: %+v", summary)
}
}
func TestEstimateUSDKnownAndUnknownModels(t *testing.T) {
usd, ok, _ := EstimateUSD("openai", "gpt-4o", 1_000_000, 2_000_000)
if !ok {

View file

@ -127,9 +127,19 @@ func (s *Service) GetCostSummary(days int) cost.Summary {
if days <= 0 {
days = 30
}
effectiveDays := days
truncated := false
if cost.DefaultMaxDays > 0 && days > cost.DefaultMaxDays {
effectiveDays = cost.DefaultMaxDays
truncated = true
}
return cost.Summary{
Days: days,
RetentionDays: cost.DefaultMaxDays,
EffectiveDays: effectiveDays,
Truncated: truncated,
ProviderModels: []cost.ProviderModelSummary{},
UseCases: []cost.UseCaseSummary{},
DailyTotals: []cost.DailySummary{},
Totals: cost.ProviderModelSummary{
Provider: "all",