Add estimated USD to AI cost dashboard
This commit is contained in:
parent
935b4da7ac
commit
71b6a2ae12
5 changed files with 212 additions and 27 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
84
internal/ai/cost/pricing.go
Normal file
84
internal/ai/cost/pricing.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue