diff --git a/frontend-modern/src/components/AI/AICostDashboard.tsx b/frontend-modern/src/components/AI/AICostDashboard.tsx index e0810d5..9ffdc1f 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -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 ( + + + + ); +}; + export const AICostDashboard: Component = () => { const [days, setDays] = createSignal(30); const [loading, setLoading] = createSignal(false); const [loadError, setLoadError] = createSignal(null); const [summary, setSummary] = createSignal(null); + const [budgetUSD, setBudgetUSD] = createSignal(''); 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(); + 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 = () => {
Loading usage…
+ +
+ Showing the last {summary()?.effective_days} days due to a {summary()?.retention_days}-day retention window. +
+
+ + +
+ Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(parsedBudgetUSD() ?? 0)}). +
+
+
@@ -209,6 +308,70 @@ export const AICostDashboard: Component = () => {
+
+
+
Chat
+
+ {formatNumber(useCaseMap().get('chat')?.tokens ?? 0)} tokens +
+
+ —} + > + {formatUSD(useCaseMap().get('chat')?.usd ?? 0)} + +
+
+
+
Patrol
+
+ {formatNumber(useCaseMap().get('patrol')?.tokens ?? 0)} tokens +
+
+ —} + > + {formatUSD(useCaseMap().get('patrol')?.usd ?? 0)} + +
+
+
+
Budget (USD)
+ 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" + /> +
+ Applies to selected range. +
+
+
+ +
+
+
+
Daily estimated USD
+
{data().daily_totals.length} points
+
+
+ +
+
+
+
+
Daily total tokens
+
{data().daily_totals.length} points
+
+
+ +
+
+
+
USD is an estimate based on public list prices. It may differ from billing.
diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index cd371bb..986acee 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -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; } diff --git a/internal/ai/cost/store.go b/internal/ai/cost/store.go index 1e6981e..4821c89 100644 --- a/internal/ai/cost/store.go +++ b/internal/ai/cost/store.go @@ -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 +} diff --git a/internal/ai/cost/store_test.go b/internal/ai/cost/store_test.go index 9f671f4..51a4b98 100644 --- a/internal/ai/cost/store_test.go +++ b/internal/ai/cost/store_test.go @@ -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 { diff --git a/internal/ai/service.go b/internal/ai/service.go index 872001f..d80557f 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -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",