From 5c9ebf8d3a712bfcaefea9616def5ded1e13c52e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Dec 2025 12:55:39 +0000 Subject: [PATCH] Add AI cost export and top target rollups --- frontend-modern/src/api/ai.ts | 6 +- .../src/components/AI/AICostDashboard.tsx | 95 +++++++++++++-- frontend-modern/src/types/ai.ts | 12 ++ internal/ai/cost/store.go | 111 ++++++++++++++++++ internal/ai/cost/store_test.go | 46 ++++++++ internal/ai/service.go | 11 ++ internal/api/ai_handlers.go | 84 +++++++++++++ internal/api/router.go | 1 + 8 files changed, 357 insertions(+), 9 deletions(-) diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index afc4d19..415d3f1 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -51,13 +51,17 @@ export class AIAPI { } // Reset AI usage history (admin-only) - static async resetCostHistory(): Promise<{ ok: boolean }> { + static async resetCostHistory(): Promise<{ ok: boolean; backup_file?: string }> { return apiFetchJSON(`${this.baseUrl}/ai/cost/reset`, { method: 'POST', body: JSON.stringify({}), }) as Promise<{ ok: boolean; backup_file?: string }>; } + static async exportCostHistory(days = 30, format: 'json' | 'csv' = 'csv'): Promise { + return apiFetch(`${this.baseUrl}/ai/cost/export?days=${days}&format=${format}`, { method: 'GET' }); + } + // Start OAuth flow for Claude Pro/Max subscription // Returns the authorization URL to redirect the user to static async startOAuth(): Promise<{ auth_url: string; state: string }> { diff --git a/frontend-modern/src/components/AI/AICostDashboard.tsx b/frontend-modern/src/components/AI/AICostDashboard.tsx index 6620c1d..b290e4b 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -169,6 +169,27 @@ export const AICostDashboard: Component = () => { } }; + const downloadExport = async (format: 'csv' | 'json') => { + try { + const resp = await AIAPI.exportCostHistory(days(), format); + if (!resp.ok) { + throw new Error(`Export failed (${resp.status})`); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `pulse-ai-usage-${new Date().toISOString().split('T')[0]}-${days()}d.${format}`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + logger.error('[AICostDashboard] Failed to export cost history:', err); + notificationStore.error('Failed to export AI usage history'); + } + }; + const handleRangeClick = (rangeDays: number) => { if (loading() || rangeDays === days()) return; setDays(rangeDays); @@ -397,16 +418,74 @@ export const AICostDashboard: Component = () => {
History retention: {data().retention_days} days
- +
+ + + +
+ 0}> +
+ + + + + + + + + + + + {(t) => ( + + + + + + + )} + + +
Top targetsEst. USDCallsTokens
+ {t.target_type}:{t.target_id} + + —} + > + {formatUSD(t.estimated_usd ?? 0)} + + + {formatNumber(t.calls)} + + {formatNumber(t.total_tokens)} +
+
+
+
diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index 945b0b5..82d3aa7 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -204,6 +204,17 @@ export interface AICostUseCaseSummary { pricing_known: boolean; } +export interface AICostTargetSummary { + target_type: string; + target_id: string; + calls: number; + input_tokens: number; + output_tokens: number; + total_tokens: number; + estimated_usd?: number; + pricing_known: boolean; +} + export interface AICostSummary { days: number; retention_days: number; @@ -211,6 +222,7 @@ export interface AICostSummary { truncated: boolean; provider_models: AICostProviderModelSummary[]; use_cases: AICostUseCaseSummary[]; + targets: AICostTargetSummary[]; daily_totals: AICostDailySummary[]; totals: AICostProviderModelSummary; } diff --git a/internal/ai/cost/store.go b/internal/ai/cost/store.go index 2607cf9..c1d3217 100644 --- a/internal/ai/cost/store.go +++ b/internal/ai/cost/store.go @@ -103,6 +103,30 @@ func (s *Store) Clear() error { return s.Flush() } +// ListEvents returns a copy of usage events within the requested time window, applying retention. +func (s *Store) ListEvents(days int) []UsageEvent { + if days <= 0 { + days = 30 + } + now := time.Now() + effectiveDays := days + if s.maxDays > 0 && effectiveDays > s.maxDays { + effectiveDays = s.maxDays + } + cutoff := now.AddDate(0, 0, -effectiveDays) + + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]UsageEvent, 0, len(s.events)) + for _, e := range s.events { + if !e.Timestamp.Before(cutoff) { + out = append(out, e) + } + } + return out +} + // GetSummary returns a rollup of usage over the last N days. func (s *Store) GetSummary(days int) Summary { if days <= 0 { @@ -217,6 +241,7 @@ func (s *Store) GetSummary(days int) Summary { Truncated: truncated, ProviderModels: providerModels, UseCases: summarizeUseCases(events), + Targets: summarizeTargets(events), DailyTotals: daily, Totals: totals, } @@ -345,6 +370,18 @@ type UseCaseSummary struct { PricingKnown bool `json:"pricing_known"` } +// TargetSummary is a rollup for a Pulse target (e.g. vm/container/node). +type TargetSummary struct { + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + Calls int64 `json:"calls"` + 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"` @@ -354,6 +391,7 @@ type Summary struct { ProviderModels []ProviderModelSummary `json:"provider_models"` UseCases []UseCaseSummary `json:"use_cases"` + Targets []TargetSummary `json:"targets"` DailyTotals []DailySummary `json:"daily_totals"` Totals ProviderModelSummary `json:"totals"` } @@ -422,3 +460,76 @@ func summarizeUseCases(events []UsageEvent) []UseCaseSummary { }) return out } + +func summarizeTargets(events []UsageEvent) []TargetSummary { + type key struct { + targetType string + targetID string + } + type totals struct { + calls int64 + input int64 + output int64 + usd float64 + known bool + } + + perTarget := make(map[key]*totals) + for _, e := range events { + targetType := strings.TrimSpace(strings.ToLower(e.TargetType)) + targetID := strings.TrimSpace(e.TargetID) + if targetType == "" || targetID == "" { + continue + } + k := key{targetType: targetType, targetID: targetID} + t := perTarget[k] + if t == nil { + t = &totals{} + perTarget[k] = t + } + t.calls++ + 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([]TargetSummary, 0, len(perTarget)) + for k, t := range perTarget { + out = append(out, TargetSummary{ + TargetType: k.targetType, + TargetID: k.targetID, + Calls: t.calls, + InputTokens: t.input, + OutputTokens: t.output, + TotalTokens: t.input + t.output, + EstimatedUSD: t.usd, + PricingKnown: t.known, + }) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].EstimatedUSD != out[j].EstimatedUSD { + return out[i].EstimatedUSD > out[j].EstimatedUSD + } + if out[i].TotalTokens != out[j].TotalTokens { + return out[i].TotalTokens > out[j].TotalTokens + } + if out[i].TargetType != out[j].TargetType { + return out[i].TargetType < out[j].TargetType + } + return out[i].TargetID < out[j].TargetID + }) + + if len(out) > 20 { + out = out[:20] + } + return out +} diff --git a/internal/ai/cost/store_test.go b/internal/ai/cost/store_test.go index 94e934e..867a4bf 100644 --- a/internal/ai/cost/store_test.go +++ b/internal/ai/cost/store_test.go @@ -111,6 +111,11 @@ func TestSummaryGroupsByProviderModelAndDailyTotals(t *testing.T) { if useCases["patrol"].TotalTokens != (20 + 10) { t.Fatalf("patrol use-case totals wrong: %+v", useCases["patrol"]) } + + // Target rollups should be empty for these events (no targets set). + if len(summary.Targets) != 0 { + t.Fatalf("expected no target rollups, got %+v", summary.Targets) + } } func TestRetentionTrimsOldEvents(t *testing.T) { @@ -193,3 +198,44 @@ func TestEstimateUSDKnownAndUnknownModels(t *testing.T) { t.Fatalf("expected usd %.4f, got %.4f", expected, usd) } } + +func TestSummaryTargetsRollup(t *testing.T) { + store := NewStore(30) + now := time.Now() + + store.Record(UsageEvent{ + Timestamp: now, + Provider: "deepseek", + RequestModel: "deepseek:deepseek-chat", + InputTokens: 1000, + OutputTokens: 100, + UseCase: "chat", + TargetType: "vm", + TargetID: "101", + }) + store.Record(UsageEvent{ + Timestamp: now, + Provider: "deepseek", + RequestModel: "deepseek:deepseek-chat", + InputTokens: 2000, + OutputTokens: 200, + UseCase: "chat", + TargetType: "vm", + TargetID: "101", + }) + + summary := store.GetSummary(7) + if len(summary.Targets) != 1 { + t.Fatalf("expected 1 target summary, got %d", len(summary.Targets)) + } + got := summary.Targets[0] + if got.TargetType != "vm" || got.TargetID != "101" || got.Calls != 2 { + t.Fatalf("unexpected target summary: %+v", got) + } + if got.TotalTokens != 3300 { + t.Fatalf("unexpected target token totals: %+v", got) + } + if !got.PricingKnown || got.EstimatedUSD <= 0 { + t.Fatalf("expected pricing known with positive USD: %+v", got) + } +} diff --git a/internal/ai/service.go b/internal/ai/service.go index d9ff86d..a8670be 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -149,6 +149,17 @@ func (s *Service) GetCostSummary(days int) cost.Summary { return store.GetSummary(days) } +// ListCostEvents returns retained AI usage events within the requested time window. +func (s *Service) ListCostEvents(days int) []cost.UsageEvent { + s.mu.RLock() + store := s.costStore + s.mu.RUnlock() + if store == nil { + return nil + } + return store.ListEvents(days) +} + // ClearCostHistory deletes retained AI usage events (admin operation). func (s *Service) ClearCostHistory() error { s.mu.RLock() diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 4659cd3..d4f067e 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2,12 +2,14 @@ package api import ( "context" + "encoding/csv" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -2496,6 +2498,88 @@ func (h *AISettingsHandler) HandleResetAICostHistory(w http.ResponseWriter, r *h } } +// HandleExportAICostHistory exports recent AI usage history as JSON or CSV (GET /api/ai/cost/export?days=N&format=csv|json). +func (h *AISettingsHandler) HandleExportAICostHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if h.aiService == nil { + http.Error(w, "AI service unavailable", http.StatusServiceUnavailable) + return + } + + days := 30 + if daysStr := r.URL.Query().Get("days"); daysStr != "" { + if v, err := strconv.Atoi(daysStr); err == nil && v > 0 { + if v > 365 { + v = 365 + } + days = v + } + } + + format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if format == "" { + format = "json" + } + if format != "json" && format != "csv" { + http.Error(w, "format must be 'json' or 'csv'", http.StatusBadRequest) + return + } + + events := h.aiService.ListCostEvents(days) + + filename := fmt.Sprintf("pulse-ai-usage-%s-%dd.%s", time.Now().UTC().Format("20060102"), days, format) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + + if format == "json" { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "days": days, + "events": events, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error().Err(err).Msg("Failed to write AI cost export JSON") + } + return + } + + w.Header().Set("Content-Type", "text/csv") + cw := csv.NewWriter(w) + _ = cw.Write([]string{ + "timestamp", + "provider", + "request_model", + "response_model", + "use_case", + "input_tokens", + "output_tokens", + "target_type", + "target_id", + "finding_id", + }) + for _, e := range events { + _ = cw.Write([]string{ + e.Timestamp.UTC().Format(time.RFC3339Nano), + e.Provider, + e.RequestModel, + e.ResponseModel, + e.UseCase, + strconv.Itoa(e.InputTokens), + strconv.Itoa(e.OutputTokens), + e.TargetType, + e.TargetID, + e.FindingID, + }) + } + cw.Flush() + if err := cw.Error(); err != nil { + log.Error().Err(err).Msg("Failed to write AI cost export CSV") + } +} + // HandleGetSuppressionRules returns all suppression rules (GET /api/ai/patrol/suppressions) func (h *AISettingsHandler) HandleGetSuppressionRules(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/api/router.go b/internal/api/router.go index 5ba1527..dd5182f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1097,6 +1097,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/agents", RequireAuth(r.config, r.aiSettingsHandler.HandleGetConnectedAgents)) r.mux.HandleFunc("/api/ai/cost/summary", RequireAuth(r.config, r.aiSettingsHandler.HandleGetAICostSummary)) r.mux.HandleFunc("/api/ai/cost/reset", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleResetAICostHistory))) + r.mux.HandleFunc("/api/ai/cost/export", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleExportAICostHistory))) // OAuth endpoints for Claude Pro/Max subscription authentication r.mux.HandleFunc("/api/ai/oauth/start", RequireAdmin(r.config, r.aiSettingsHandler.HandleOAuthStart)) r.mux.HandleFunc("/api/ai/oauth/exchange", RequireAdmin(r.config, r.aiSettingsHandler.HandleOAuthExchange)) // Manual code input