Add AI cost export and top target rollups
This commit is contained in:
parent
9c78cf7f84
commit
5c9ebf8d3a
8 changed files with 357 additions and 9 deletions
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue