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 { Card } from '@/components/shared/Card';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { AIAPI } from '@/api/ai';
|
import { AIAPI } from '@/api/ai';
|
||||||
|
|
@ -15,11 +15,50 @@ const usdFormatter = new Intl.NumberFormat(undefined, {
|
||||||
maximumFractionDigits: 2,
|
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 = () => {
|
export const AICostDashboard: Component = () => {
|
||||||
const [days, setDays] = createSignal(30);
|
const [days, setDays] = createSignal(30);
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [loadError, setLoadError] = createSignal<string | null>(null);
|
const [loadError, setLoadError] = createSignal<string | null>(null);
|
||||||
const [summary, setSummary] = createSignal<AICostSummary | null>(null);
|
const [summary, setSummary] = createSignal<AICostSummary | null>(null);
|
||||||
|
const [budgetUSD, setBudgetUSD] = createSignal<string>('');
|
||||||
let requestSeq = 0;
|
let requestSeq = 0;
|
||||||
|
|
||||||
const anyPricingKnown = createMemo(() => {
|
const anyPricingKnown = createMemo(() => {
|
||||||
|
|
@ -34,15 +73,29 @@ export const AICostDashboard: Component = () => {
|
||||||
return data.totals.estimated_usd ?? 0;
|
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 formatUSD = (usd: number) => usdFormatter.format(usd);
|
||||||
|
|
||||||
const loadSummary = async (rangeDays: number) => {
|
const loadSummary = async (rangeDays: number) => {
|
||||||
const seq = ++requestSeq;
|
const seq = ++requestSeq;
|
||||||
const isInitialLoad = summary() === null;
|
const isInitialLoad = summary() === null;
|
||||||
// Only show loading indicator on initial load to prevent flicker on range changes
|
setLoading(true);
|
||||||
if (isInitialLoad) {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const data = await AIAPI.getCostSummary(rangeDays);
|
const data = await AIAPI.getCostSummary(rangeDays);
|
||||||
|
|
@ -67,6 +120,40 @@ export const AICostDashboard: Component = () => {
|
||||||
loadSummary(days());
|
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) => {
|
const handleRangeClick = (rangeDays: number) => {
|
||||||
if (loading() || rangeDays === days()) return;
|
if (loading() || rangeDays === days()) return;
|
||||||
setDays(rangeDays);
|
setDays(rangeDays);
|
||||||
|
|
@ -156,6 +243,18 @@ export const AICostDashboard: Component = () => {
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Loading usage…</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400">Loading usage…</div>
|
||||||
</Show>
|
</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()}>
|
<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="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">
|
<div class="truncate">
|
||||||
|
|
@ -209,6 +308,70 @@ export const AICostDashboard: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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.
|
USD is an estimate based on public list prices. It may differ from billing.
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -189,9 +189,22 @@ export interface AICostDailySummary {
|
||||||
estimated_usd?: number;
|
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 {
|
export interface AICostSummary {
|
||||||
days: number;
|
days: number;
|
||||||
|
retention_days: number;
|
||||||
|
effective_days: number;
|
||||||
|
truncated: boolean;
|
||||||
provider_models: AICostProviderModelSummary[];
|
provider_models: AICostProviderModelSummary[];
|
||||||
|
use_cases: AICostUseCaseSummary[];
|
||||||
daily_totals: AICostDailySummary[];
|
daily_totals: AICostDailySummary[];
|
||||||
totals: AICostProviderModelSummary;
|
totals: AICostProviderModelSummary;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ type Persistence interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultMaxDays is the default retention window for raw usage events.
|
// 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.
|
// Store provides thread-safe usage tracking with optional persistence.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
|
|
@ -100,7 +100,15 @@ func (s *Store) GetSummary(days int) Summary {
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
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()
|
s.mu.RLock()
|
||||||
events := make([]UsageEvent, 0, len(s.events))
|
events := make([]UsageEvent, 0, len(s.events))
|
||||||
|
|
@ -188,12 +196,17 @@ func (s *Store) GetSummary(days int) Summary {
|
||||||
for _, pm := range providerModels {
|
for _, pm := range providerModels {
|
||||||
if pm.PricingKnown {
|
if pm.PricingKnown {
|
||||||
totals.EstimatedUSD += pm.EstimatedUSD
|
totals.EstimatedUSD += pm.EstimatedUSD
|
||||||
|
totals.PricingKnown = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Summary{
|
return Summary{
|
||||||
Days: days,
|
Days: days,
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
EffectiveDays: effectiveDays,
|
||||||
|
Truncated: truncated,
|
||||||
ProviderModels: providerModels,
|
ProviderModels: providerModels,
|
||||||
|
UseCases: summarizeUseCases(events),
|
||||||
DailyTotals: daily,
|
DailyTotals: daily,
|
||||||
Totals: totals,
|
Totals: totals,
|
||||||
}
|
}
|
||||||
|
|
@ -312,10 +325,90 @@ type DailySummary struct {
|
||||||
EstimatedUSD float64 `json:"estimated_usd,omitempty"`
|
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.
|
// Summary is returned by the cost summary API.
|
||||||
type Summary struct {
|
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"`
|
ProviderModels []ProviderModelSummary `json:"provider_models"`
|
||||||
|
UseCases []UseCaseSummary `json:"use_cases"`
|
||||||
DailyTotals []DailySummary `json:"daily_totals"`
|
DailyTotals []DailySummary `json:"daily_totals"`
|
||||||
Totals ProviderModelSummary `json:"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 {
|
if dailyGot[todayKey].InputTokens != 200 || dailyGot[todayKey].OutputTokens != 100 {
|
||||||
t.Fatalf("daily totals for %s wrong: %+v", todayKey, dailyGot[todayKey])
|
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) {
|
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) {
|
func TestEstimateUSDKnownAndUnknownModels(t *testing.T) {
|
||||||
usd, ok, _ := EstimateUSD("openai", "gpt-4o", 1_000_000, 2_000_000)
|
usd, ok, _ := EstimateUSD("openai", "gpt-4o", 1_000_000, 2_000_000)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,19 @@ func (s *Service) GetCostSummary(days int) cost.Summary {
|
||||||
if days <= 0 {
|
if days <= 0 {
|
||||||
days = 30
|
days = 30
|
||||||
}
|
}
|
||||||
|
effectiveDays := days
|
||||||
|
truncated := false
|
||||||
|
if cost.DefaultMaxDays > 0 && days > cost.DefaultMaxDays {
|
||||||
|
effectiveDays = cost.DefaultMaxDays
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
return cost.Summary{
|
return cost.Summary{
|
||||||
Days: days,
|
Days: days,
|
||||||
|
RetentionDays: cost.DefaultMaxDays,
|
||||||
|
EffectiveDays: effectiveDays,
|
||||||
|
Truncated: truncated,
|
||||||
ProviderModels: []cost.ProviderModelSummary{},
|
ProviderModels: []cost.ProviderModelSummary{},
|
||||||
|
UseCases: []cost.UseCaseSummary{},
|
||||||
DailyTotals: []cost.DailySummary{},
|
DailyTotals: []cost.DailySummary{},
|
||||||
Totals: cost.ProviderModelSummary{
|
Totals: cost.ProviderModelSummary{
|
||||||
Provider: "all",
|
Provider: "all",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue