Improve AI cost dashboard ranges and breakdowns
This commit is contained in:
parent
47eefe6763
commit
5ba4e6a84c
5 changed files with 321 additions and 8 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue