Add estimated USD to AI cost dashboard

This commit is contained in:
rcourtman 2025-12-12 10:43:07 +00:00
parent 935b4da7ac
commit 71b6a2ae12
5 changed files with 212 additions and 27 deletions

View file

@ -1,4 +1,4 @@
import { Component, Show, createSignal, onMount, For } from 'solid-js';
import { Component, Show, createMemo, createSignal, onMount, For } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { AIAPI } from '@/api/ai';
@ -8,22 +8,49 @@ import { notificationStore } from '@/stores/notifications';
import type { AICostSummary } from '@/types/ai';
import { PROVIDER_NAMES } from '@/types/ai';
const usdFormatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
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);
let requestSeq = 0;
const anyPricingKnown = createMemo(() => {
const data = summary();
if (!data) return false;
return data.provider_models.some((pm) => pm.pricing_known);
});
const estimatedTotalUSD = createMemo(() => {
const data = summary();
if (!data || !anyPricingKnown()) return null;
return data.totals.estimated_usd ?? 0;
});
const formatUSD = (usd: number) => usdFormatter.format(usd);
const loadSummary = async (rangeDays: number) => {
const seq = ++requestSeq;
setLoading(true);
setLoadError(null);
try {
const data = await AIAPI.getCostSummary(rangeDays);
if (seq !== requestSeq) return;
setSummary(data);
} catch (err) {
if (seq !== requestSeq) return;
logger.error('[AICostDashboard] Failed to load cost summary:', err);
notificationStore.error('Failed to load AI cost summary');
setSummary(null);
setLoadError('Failed to load usage data');
} finally {
setLoading(false);
if (seq === requestSeq) setLoading(false);
}
};
@ -32,6 +59,7 @@ export const AICostDashboard: Component = () => {
});
const handleRangeClick = (rangeDays: number) => {
if (loading() || rangeDays === days()) return;
setDays(rangeDays);
loadSummary(rangeDays);
};
@ -51,44 +79,51 @@ export const AICostDashboard: Component = () => {
size="sm"
class="flex-1"
/>
<Show when={loading()}>
<div class="text-xs text-gray-500 dark:text-gray-400">Loading</div>
</Show>
<div class="flex items-center gap-1">
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(7)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 7
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
7d
</button>
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(30)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 30
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
30d
</button>
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(90)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 90
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
90d
</button>
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(365)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 365
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
1y
</button>
@ -97,28 +132,31 @@ export const AICostDashboard: Component = () => {
</div>
<div class="p-6 space-y-4">
<Show when={loading()}>
<Show when={!summary() && loading()}>
<div class="text-sm text-gray-500 dark:text-gray-400">Loading usage</div>
</Show>
<Show when={!loading() && summary() == null}>
<Show when={!summary() && !loading() && loadError()}>
<div class="text-sm text-gray-500 dark:text-gray-400">{loadError()}</div>
</Show>
<Show when={!summary() && !loading() && !loadError()}>
<div class="text-sm text-gray-500 dark:text-gray-400">No usage data yet.</div>
</Show>
<Show when={!loading() && summary()}>
<Show when={summary()}>
{(data) => (
<>
<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">Input tokens</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Estimated spend</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{formatNumber(data().totals.input_tokens)}
</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">Output tokens</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{formatNumber(data().totals.output_tokens)}
<Show
when={estimatedTotalUSD() != null}
fallback={<span class="text-gray-500 dark:text-gray-400"></span>}
>
{formatUSD(estimatedTotalUSD() ?? 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">
@ -127,6 +165,16 @@ export const AICostDashboard: Component = () => {
{formatNumber(data().totals.total_tokens)}
</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">Model/provider pairs</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{formatNumber(data().provider_models.length)}
</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>
<div class="overflow-x-auto">
@ -135,6 +183,7 @@ export const AICostDashboard: Component = () => {
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 pr-4">Provider</th>
<th class="text-left py-2 pr-4">Model</th>
<th class="text-right py-2 px-2">Est. USD</th>
<th class="text-right py-2 px-2">Input</th>
<th class="text-right py-2 px-2">Output</th>
<th class="text-right py-2 px-2">Total</th>
@ -150,6 +199,14 @@ export const AICostDashboard: Component = () => {
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 font-mono text-xs">
{pm.model}
</td>
<td class="py-2 px-2 text-right text-gray-900 dark:text-gray-100">
<Show
when={pm.pricing_known}
fallback={<span class="text-gray-500 dark:text-gray-500"></span>}
>
{formatUSD(pm.estimated_usd ?? 0)}
</Show>
</td>
<td class="py-2 px-2 text-right text-gray-700 dark:text-gray-300">
{formatNumber(pm.input_tokens)}
</td>

View file

@ -177,6 +177,8 @@ export interface AICostProviderModelSummary {
input_tokens: number;
output_tokens: number;
total_tokens: number;
estimated_usd?: number;
pricing_known: boolean;
}
export interface AICostDailySummary {
@ -184,6 +186,7 @@ export interface AICostDailySummary {
input_tokens: number;
output_tokens: number;
total_tokens: number;
estimated_usd?: number;
}
export interface AICostSummary {

View file

@ -0,0 +1,84 @@
package cost
import "strings"
// TokenPrice represents a price per million tokens for a model.
// Prices are estimates intended for cross-provider budgeting, not billing reconciliation.
type TokenPrice struct {
InputUSDPerMTok float64
OutputUSDPerMTok float64
AsOf string
}
// EstimateUSD returns an estimated USD cost for the given provider/model and token counts.
// If the model pricing is unknown, ok is false and usd is 0.
func EstimateUSD(provider, model string, inputTokens, outputTokens int64) (usd float64, ok bool, price TokenPrice) {
price, ok = lookupPrice(provider, model)
if !ok {
return 0, false, TokenPrice{}
}
usd = (float64(inputTokens)/1_000_000.0)*price.InputUSDPerMTok +
(float64(outputTokens)/1_000_000.0)*price.OutputUSDPerMTok
return usd, true, price
}
type modelPrice struct {
Pattern string
InputUSDPerMTok float64
OutputUSDPerMTok float64
}
const pricingAsOf = "2025-12"
// NOTE: Keep this table small and conservative.
// The goal is quick estimation and relative comparisons, not exact billing.
var providerPrices = map[string][]modelPrice{
"openai": {
{Pattern: "gpt-4o*", InputUSDPerMTok: 5.00, OutputUSDPerMTok: 15.00},
{Pattern: "gpt-4o-mini*", InputUSDPerMTok: 0.15, OutputUSDPerMTok: 0.60},
},
"anthropic": {
{Pattern: "claude-opus*", InputUSDPerMTok: 15.00, OutputUSDPerMTok: 75.00},
{Pattern: "claude-sonnet*", InputUSDPerMTok: 3.00, OutputUSDPerMTok: 15.00},
{Pattern: "claude-haiku*", InputUSDPerMTok: 0.25, OutputUSDPerMTok: 1.25},
},
"ollama": {
{Pattern: "*", InputUSDPerMTok: 0, OutputUSDPerMTok: 0},
},
// deepseek pricing intentionally omitted until stable public pricing is confirmed.
}
func lookupPrice(provider, model string) (TokenPrice, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
model = strings.ToLower(strings.TrimSpace(model))
if provider == "" || model == "" {
return TokenPrice{}, false
}
prices, ok := providerPrices[provider]
if !ok {
return TokenPrice{}, false
}
for _, p := range prices {
if matchPattern(model, strings.ToLower(p.Pattern)) {
return TokenPrice{
InputUSDPerMTok: p.InputUSDPerMTok,
OutputUSDPerMTok: p.OutputUSDPerMTok,
AsOf: pricingAsOf,
}, true
}
}
return TokenPrice{}, false
}
func matchPattern(model, pattern string) bool {
if pattern == "*" {
return true
}
if strings.HasSuffix(pattern, "*") {
return strings.HasPrefix(model, strings.TrimSuffix(pattern, "*"))
}
return model == pattern
}

View file

@ -137,6 +137,12 @@ func (s *Store) GetSummary(days int) Summary {
totalInput += int64(e.InputTokens)
totalOutput += int64(e.OutputTokens)
usd, known, _ := EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens))
if known {
pm.EstimatedUSD += usd
pm.PricingKnown = true
}
date := e.Timestamp.Format("2006-01-02")
ds := dailyTotals[date]
if ds == nil {
@ -145,6 +151,9 @@ func (s *Store) GetSummary(days int) Summary {
}
ds.InputTokens += int64(e.InputTokens)
ds.OutputTokens += int64(e.OutputTokens)
if known {
ds.EstimatedUSD += usd
}
}
providerModels := make([]ProviderModelSummary, 0, len(pmTotals))
@ -175,6 +184,12 @@ func (s *Store) GetSummary(days int) Summary {
TotalTokens: totalInput + totalOutput,
}
for _, pm := range providerModels {
if pm.PricingKnown {
totals.EstimatedUSD += pm.EstimatedUSD
}
}
return Summary{
Days: days,
ProviderModels: providerModels,
@ -265,19 +280,22 @@ func normalizeModel(provider, requestModel, responseModel string) string {
// ProviderModelSummary is a rollup for a provider/model pair.
type ProviderModelSummary struct {
Provider string `json:"provider"`
Model string `json:"model"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
Provider string `json:"provider"`
Model string `json:"model"`
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"`
}
// DailySummary is a rollup for a single day across all providers.
type DailySummary struct {
Date string `json:"date"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
Date string `json:"date"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
EstimatedUSD float64 `json:"estimated_usd,omitempty"`
}
// Summary is returned by the cost summary API.

View file

@ -1,6 +1,7 @@
package cost
import (
"math"
"testing"
"time"
)
@ -61,6 +62,9 @@ func TestSummaryGroupsByProviderModelAndDailyTotals(t *testing.T) {
if openaiGpt4o.InputTokens != 110 || openaiGpt4o.OutputTokens != 55 {
t.Fatalf("openai gpt-4o tokens wrong: %+v", openaiGpt4o)
}
if !openaiGpt4o.PricingKnown || openaiGpt4o.EstimatedUSD <= 0 {
t.Fatalf("expected openai gpt-4o pricing to be known with positive USD, got %+v", openaiGpt4o)
}
openaiMini := got[key{"openai", "gpt-4o-mini"}]
if openaiMini.InputTokens != 20 || openaiMini.OutputTokens != 10 {
@ -71,6 +75,9 @@ func TestSummaryGroupsByProviderModelAndDailyTotals(t *testing.T) {
if anthropicOpus.InputTokens != 200 || anthropicOpus.OutputTokens != 100 {
t.Fatalf("anthropic opus tokens wrong: %+v", anthropicOpus)
}
if !anthropicOpus.PricingKnown || anthropicOpus.EstimatedUSD <= 0 {
t.Fatalf("expected anthropic opus pricing to be known with positive USD, got %+v", anthropicOpus)
}
// Daily totals across all providers.
dailyGot := make(map[string]DailySummary)
@ -111,3 +118,19 @@ func TestRetentionTrimsOldEvents(t *testing.T) {
t.Fatalf("expected old event to be trimmed, got %+v", summary.ProviderModels)
}
}
func TestEstimateUSDKnownAndUnknownModels(t *testing.T) {
usd, ok, _ := EstimateUSD("openai", "gpt-4o", 1_000_000, 2_000_000)
if !ok {
t.Fatalf("expected pricing to be known for gpt-4o")
}
expected := 35.0 // 1M input * $5 + 2M output * $15
if math.Abs(usd-expected) > 0.0001 {
t.Fatalf("expected usd %.4f, got %.4f", expected, usd)
}
usd, ok, _ = EstimateUSD("deepseek", "deepseek-reasoner", 1_000_000, 1_000_000)
if ok || usd != 0 {
t.Fatalf("expected deepseek pricing to be unknown, got ok=%v usd=%.4f", ok, usd)
}
}