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 { 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';
|
||||||
|
|
@ -8,22 +8,49 @@ import { notificationStore } from '@/stores/notifications';
|
||||||
import type { AICostSummary } from '@/types/ai';
|
import type { AICostSummary } from '@/types/ai';
|
||||||
import { PROVIDER_NAMES } 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 = () => {
|
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 [summary, setSummary] = createSignal<AICostSummary | 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 loadSummary = async (rangeDays: number) => {
|
||||||
|
const seq = ++requestSeq;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const data = await AIAPI.getCostSummary(rangeDays);
|
const data = await AIAPI.getCostSummary(rangeDays);
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
setSummary(data);
|
setSummary(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
logger.error('[AICostDashboard] Failed to load cost summary:', err);
|
logger.error('[AICostDashboard] Failed to load cost summary:', err);
|
||||||
notificationStore.error('Failed to load AI cost summary');
|
notificationStore.error('Failed to load AI cost summary');
|
||||||
setSummary(null);
|
setLoadError('Failed to load usage data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === requestSeq) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -32,6 +59,7 @@ export const AICostDashboard: Component = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRangeClick = (rangeDays: number) => {
|
const handleRangeClick = (rangeDays: number) => {
|
||||||
|
if (loading() || rangeDays === days()) return;
|
||||||
setDays(rangeDays);
|
setDays(rangeDays);
|
||||||
loadSummary(rangeDays);
|
loadSummary(rangeDays);
|
||||||
};
|
};
|
||||||
|
|
@ -51,44 +79,51 @@ export const AICostDashboard: Component = () => {
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1"
|
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">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={loading()}
|
||||||
onClick={() => handleRangeClick(7)}
|
onClick={() => handleRangeClick(7)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 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'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
7d
|
7d
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={loading()}
|
||||||
onClick={() => handleRangeClick(30)}
|
onClick={() => handleRangeClick(30)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 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'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
30d
|
30d
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={loading()}
|
||||||
onClick={() => handleRangeClick(90)}
|
onClick={() => handleRangeClick(90)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 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'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
90d
|
90d
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={loading()}
|
||||||
onClick={() => handleRangeClick(365)}
|
onClick={() => handleRangeClick(365)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 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'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
1y
|
1y
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -97,28 +132,31 @@ export const AICostDashboard: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 space-y-4">
|
<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>
|
<div class="text-sm text-gray-500 dark:text-gray-400">Loading usage…</div>
|
||||||
</Show>
|
</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>
|
<div class="text-sm text-gray-500 dark:text-gray-400">No usage data yet.</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!loading() && summary()}>
|
<Show when={summary()}>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<>
|
<>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<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="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">
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{formatNumber(data().totals.input_tokens)}
|
<Show
|
||||||
</div>
|
when={estimatedTotalUSD() != null}
|
||||||
</div>
|
fallback={<span class="text-gray-500 dark:text-gray-400">—</span>}
|
||||||
<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>
|
{formatUSD(estimatedTotalUSD() ?? 0)}
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
</Show>
|
||||||
{formatNumber(data().totals.output_tokens)}
|
|
||||||
</div>
|
</div>
|
||||||
</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="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)}
|
{formatNumber(data().totals.total_tokens)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
|
|
@ -135,6 +183,7 @@ export const AICostDashboard: Component = () => {
|
||||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
<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">Provider</th>
|
||||||
<th class="text-left py-2 pr-4">Model</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">Input</th>
|
||||||
<th class="text-right py-2 px-2">Output</th>
|
<th class="text-right py-2 px-2">Output</th>
|
||||||
<th class="text-right py-2 px-2">Total</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">
|
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 font-mono text-xs">
|
||||||
{pm.model}
|
{pm.model}
|
||||||
</td>
|
</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">
|
<td class="py-2 px-2 text-right text-gray-700 dark:text-gray-300">
|
||||||
{formatNumber(pm.input_tokens)}
|
{formatNumber(pm.input_tokens)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,8 @@ export interface AICostProviderModelSummary {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
|
estimated_usd?: number;
|
||||||
|
pricing_known: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AICostDailySummary {
|
export interface AICostDailySummary {
|
||||||
|
|
@ -184,6 +186,7 @@ export interface AICostDailySummary {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
|
estimated_usd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AICostSummary {
|
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)
|
totalInput += int64(e.InputTokens)
|
||||||
totalOutput += int64(e.OutputTokens)
|
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")
|
date := e.Timestamp.Format("2006-01-02")
|
||||||
ds := dailyTotals[date]
|
ds := dailyTotals[date]
|
||||||
if ds == nil {
|
if ds == nil {
|
||||||
|
|
@ -145,6 +151,9 @@ func (s *Store) GetSummary(days int) Summary {
|
||||||
}
|
}
|
||||||
ds.InputTokens += int64(e.InputTokens)
|
ds.InputTokens += int64(e.InputTokens)
|
||||||
ds.OutputTokens += int64(e.OutputTokens)
|
ds.OutputTokens += int64(e.OutputTokens)
|
||||||
|
if known {
|
||||||
|
ds.EstimatedUSD += usd
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
providerModels := make([]ProviderModelSummary, 0, len(pmTotals))
|
providerModels := make([]ProviderModelSummary, 0, len(pmTotals))
|
||||||
|
|
@ -175,6 +184,12 @@ func (s *Store) GetSummary(days int) Summary {
|
||||||
TotalTokens: totalInput + totalOutput,
|
TotalTokens: totalInput + totalOutput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, pm := range providerModels {
|
||||||
|
if pm.PricingKnown {
|
||||||
|
totals.EstimatedUSD += pm.EstimatedUSD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Summary{
|
return Summary{
|
||||||
Days: days,
|
Days: days,
|
||||||
ProviderModels: providerModels,
|
ProviderModels: providerModels,
|
||||||
|
|
@ -265,19 +280,22 @@ func normalizeModel(provider, requestModel, responseModel string) string {
|
||||||
|
|
||||||
// ProviderModelSummary is a rollup for a provider/model pair.
|
// ProviderModelSummary is a rollup for a provider/model pair.
|
||||||
type ProviderModelSummary struct {
|
type ProviderModelSummary struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
InputTokens int64 `json:"input_tokens"`
|
InputTokens int64 `json:"input_tokens"`
|
||||||
OutputTokens int64 `json:"output_tokens"`
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
TotalTokens int64 `json:"total_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.
|
// DailySummary is a rollup for a single day across all providers.
|
||||||
type DailySummary struct {
|
type DailySummary struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
InputTokens int64 `json:"input_tokens"`
|
InputTokens int64 `json:"input_tokens"`
|
||||||
OutputTokens int64 `json:"output_tokens"`
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
EstimatedUSD float64 `json:"estimated_usd,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary is returned by the cost summary API.
|
// Summary is returned by the cost summary API.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package cost
|
package cost
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -61,6 +62,9 @@ func TestSummaryGroupsByProviderModelAndDailyTotals(t *testing.T) {
|
||||||
if openaiGpt4o.InputTokens != 110 || openaiGpt4o.OutputTokens != 55 {
|
if openaiGpt4o.InputTokens != 110 || openaiGpt4o.OutputTokens != 55 {
|
||||||
t.Fatalf("openai gpt-4o tokens wrong: %+v", openaiGpt4o)
|
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"}]
|
openaiMini := got[key{"openai", "gpt-4o-mini"}]
|
||||||
if openaiMini.InputTokens != 20 || openaiMini.OutputTokens != 10 {
|
if openaiMini.InputTokens != 20 || openaiMini.OutputTokens != 10 {
|
||||||
|
|
@ -71,6 +75,9 @@ func TestSummaryGroupsByProviderModelAndDailyTotals(t *testing.T) {
|
||||||
if anthropicOpus.InputTokens != 200 || anthropicOpus.OutputTokens != 100 {
|
if anthropicOpus.InputTokens != 200 || anthropicOpus.OutputTokens != 100 {
|
||||||
t.Fatalf("anthropic opus tokens wrong: %+v", anthropicOpus)
|
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.
|
// Daily totals across all providers.
|
||||||
dailyGot := make(map[string]DailySummary)
|
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)
|
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