Add AI cost export and top target rollups

This commit is contained in:
rcourtman 2025-12-12 12:55:39 +00:00
parent 9c78cf7f84
commit 5c9ebf8d3a
8 changed files with 357 additions and 9 deletions

View file

@ -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<Response> {
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 }> {

View file

@ -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 = () => {
<div class="text-xs text-gray-500 dark:text-gray-400">
History retention: {data().retention_days} days
</div>
<button
type="button"
disabled={loading()}
onClick={resetHistory}
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Reset history
</button>
<div class="flex items-center gap-2">
<button
type="button"
disabled={loading()}
onClick={() => downloadExport('csv')}
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Export CSV
</button>
<button
type="button"
disabled={loading()}
onClick={() => downloadExport('json')}
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Export JSON
</button>
<button
type="button"
disabled={loading()}
onClick={resetHistory}
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Reset history
</button>
</div>
</div>
<Show when={(data().targets?.length ?? 0) > 0}>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 pr-4">Top targets</th>
<th class="text-right py-2 px-2">Est. USD</th>
<th class="text-right py-2 px-2">Calls</th>
<th class="text-right py-2 px-2">Tokens</th>
</tr>
</thead>
<tbody>
<For each={data().targets}>
{(t) => (
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 font-mono text-xs">
{t.target_type}:{t.target_id}
</td>
<td class="py-2 px-2 text-right text-gray-900 dark:text-gray-100">
<Show
when={t.pricing_known}
fallback={<span class="text-gray-500 dark:text-gray-500"></span>}
>
{formatUSD(t.estimated_usd ?? 0)}
</Show>
</td>
<td class="py-2 px-2 text-right text-gray-700 dark:text-gray-300">
{formatNumber(t.calls)}
</td>
<td class="py-2 px-2 text-right text-gray-900 dark:text-gray-100">
{formatNumber(t.total_tokens)}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">

View file

@ -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;
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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 {

View file

@ -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