@@ -209,6 +308,70 @@ export const AICostDashboard: Component = () => {
+
+
+
Chat
+
+ {formatNumber(useCaseMap().get('chat')?.tokens ?? 0)} tokens
+
+
+ —}
+ >
+ {formatUSD(useCaseMap().get('chat')?.usd ?? 0)}
+
+
+
+
+
Patrol
+
+ {formatNumber(useCaseMap().get('patrol')?.tokens ?? 0)} tokens
+
+
+ —}
+ >
+ {formatUSD(useCaseMap().get('patrol')?.usd ?? 0)}
+
+
+
+
+
Budget (USD)
+
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"
+ />
+
+ Applies to selected range.
+
+
+
+
+
USD is an estimate based on public list prices. It may differ from billing.
diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts
index cd371bb..986acee 100644
--- a/frontend-modern/src/types/ai.ts
+++ b/frontend-modern/src/types/ai.ts
@@ -189,9 +189,22 @@ export interface AICostDailySummary {
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 {
days: number;
+ retention_days: number;
+ effective_days: number;
+ truncated: boolean;
provider_models: AICostProviderModelSummary[];
+ use_cases: AICostUseCaseSummary[];
daily_totals: AICostDailySummary[];
totals: AICostProviderModelSummary;
}
diff --git a/internal/ai/cost/store.go b/internal/ai/cost/store.go
index 1e6981e..4821c89 100644
--- a/internal/ai/cost/store.go
+++ b/internal/ai/cost/store.go
@@ -31,7 +31,7 @@ type Persistence interface {
}
// 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.
type Store struct {
@@ -100,7 +100,15 @@ func (s *Store) GetSummary(days int) Summary {
}
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()
events := make([]UsageEvent, 0, len(s.events))
@@ -188,12 +196,17 @@ func (s *Store) GetSummary(days int) Summary {
for _, pm := range providerModels {
if pm.PricingKnown {
totals.EstimatedUSD += pm.EstimatedUSD
+ totals.PricingKnown = true
}
}
return Summary{
Days: days,
+ RetentionDays: retentionDays,
+ EffectiveDays: effectiveDays,
+ Truncated: truncated,
ProviderModels: providerModels,
+ UseCases: summarizeUseCases(events),
DailyTotals: daily,
Totals: totals,
}
@@ -312,10 +325,90 @@ type DailySummary struct {
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.
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"`
+ UseCases []UseCaseSummary `json:"use_cases"`
DailyTotals []DailySummary `json:"daily_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
+}
diff --git a/internal/ai/cost/store_test.go b/internal/ai/cost/store_test.go
index 9f671f4..51a4b98 100644
--- a/internal/ai/cost/store_test.go
+++ b/internal/ai/cost/store_test.go
@@ -99,6 +99,18 @@ func TestSummaryGroupsByProviderModelAndDailyTotals(t *testing.T) {
if dailyGot[todayKey].InputTokens != 200 || dailyGot[todayKey].OutputTokens != 100 {
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) {
@@ -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) {
usd, ok, _ := EstimateUSD("openai", "gpt-4o", 1_000_000, 2_000_000)
if !ok {
diff --git a/internal/ai/service.go b/internal/ai/service.go
index 872001f..d80557f 100644
--- a/internal/ai/service.go
+++ b/internal/ai/service.go
@@ -127,9 +127,19 @@ func (s *Service) GetCostSummary(days int) cost.Summary {
if days <= 0 {
days = 30
}
+ effectiveDays := days
+ truncated := false
+ if cost.DefaultMaxDays > 0 && days > cost.DefaultMaxDays {
+ effectiveDays = cost.DefaultMaxDays
+ truncated = true
+ }
return cost.Summary{
Days: days,
+ RetentionDays: cost.DefaultMaxDays,
+ EffectiveDays: effectiveDays,
+ Truncated: truncated,
ProviderModels: []cost.ProviderModelSummary{},
+ UseCases: []cost.UseCaseSummary{},
DailyTotals: []cost.DailySummary{},
Totals: cost.ProviderModelSummary{
Provider: "all",