From 71b6a2ae12265553aafd032cfa9a14a647f1ec5a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Dec 2025 10:43:07 +0000 Subject: [PATCH] Add estimated USD to AI cost dashboard --- .../src/components/AI/AICostDashboard.tsx | 93 +++++++++++++++---- frontend-modern/src/types/ai.ts | 3 + internal/ai/cost/pricing.go | 84 +++++++++++++++++ internal/ai/cost/store.go | 36 +++++-- internal/ai/cost/store_test.go | 23 +++++ 5 files changed, 212 insertions(+), 27 deletions(-) create mode 100644 internal/ai/cost/pricing.go diff --git a/frontend-modern/src/components/AI/AICostDashboard.tsx b/frontend-modern/src/components/AI/AICostDashboard.tsx index 817824c..05b1e54 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -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(null); const [summary, setSummary] = createSignal(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" /> + +
Loading…
+
@@ -97,28 +132,31 @@ export const AICostDashboard: Component = () => {
- +
Loading usage…
- + +
{loadError()}
+
+ +
No usage data yet.
- + {(data) => ( <>
-
Input tokens
+
Estimated spend
- {formatNumber(data().totals.input_tokens)} -
-
-
-
Output tokens
-
- {formatNumber(data().totals.output_tokens)} + —} + > + {formatUSD(estimatedTotalUSD() ?? 0)} +
@@ -127,6 +165,16 @@ export const AICostDashboard: Component = () => { {formatNumber(data().totals.total_tokens)}
+
+
Model/provider pairs
+
+ {formatNumber(data().provider_models.length)} +
+
+
+ +
+ USD is an estimate based on public list prices. It may differ from billing.
@@ -135,6 +183,7 @@ export const AICostDashboard: Component = () => { Provider Model + Est. USD Input Output Total @@ -150,6 +199,14 @@ export const AICostDashboard: Component = () => { {pm.model} + + —} + > + {formatUSD(pm.estimated_usd ?? 0)} + + {formatNumber(pm.input_tokens)} diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index abc7011..cd371bb 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -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 { diff --git a/internal/ai/cost/pricing.go b/internal/ai/cost/pricing.go new file mode 100644 index 0000000..cc86ebb --- /dev/null +++ b/internal/ai/cost/pricing.go @@ -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 +} diff --git a/internal/ai/cost/store.go b/internal/ai/cost/store.go index 0fb9ff2..9797bb9 100644 --- a/internal/ai/cost/store.go +++ b/internal/ai/cost/store.go @@ -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. diff --git a/internal/ai/cost/store_test.go b/internal/ai/cost/store_test.go index c3738f5..1455e64 100644 --- a/internal/ai/cost/store_test.go +++ b/internal/ai/cost/store_test.go @@ -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) + } +}