From 4df04970eac666c84536c6d7e20daa4fc3d54bfe Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Dec 2025 12:10:58 +0000 Subject: [PATCH] Persist AI cost budget and allow history reset --- frontend-modern/src/api/ai.ts | 8 ++ .../src/components/AI/AICostDashboard.tsx | 129 ++++++++++++++---- frontend-modern/src/types/ai.ts | 6 + internal/ai/cost/store.go | 10 ++ internal/ai/cost/store_test.go | 21 +++ internal/ai/service.go | 11 ++ internal/api/ai_handlers.go | 36 +++++ internal/api/router.go | 25 ++-- internal/config/ai.go | 47 ++++--- 9 files changed, 230 insertions(+), 63 deletions(-) diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index f0f9643..6aa3649 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -50,6 +50,14 @@ export class AIAPI { return apiFetchJSON(`${this.baseUrl}/ai/cost/summary?days=${days}`) as Promise; } + // Reset AI usage history (admin-only) + static async resetCostHistory(): Promise<{ ok: boolean }> { + return apiFetchJSON(`${this.baseUrl}/ai/cost/reset`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise<{ ok: boolean }>; + } + // 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 9ffdc1f..19fca74 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -1,11 +1,11 @@ -import { Component, Show, createMemo, createSignal, onMount, For, createEffect } from 'solid-js'; +import { Component, Show, createMemo, createSignal, onMount, For } from 'solid-js'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { AIAPI } from '@/api/ai'; import { formatNumber } from '@/utils/format'; import { logger } from '@/utils/logger'; import { notificationStore } from '@/stores/notifications'; -import type { AICostSummary } from '@/types/ai'; +import type { AICostSummary, AISettings } from '@/types/ai'; import { PROVIDER_NAMES } from '@/types/ai'; const usdFormatter = new Intl.NumberFormat(undefined, { @@ -15,8 +15,6 @@ const usdFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, }); -const budgetStorageKey = 'pulse_ai_cost_budget_usd_v1'; - const TinySparkline: Component<{ values: number[]; width?: number; @@ -58,7 +56,9 @@ export const AICostDashboard: Component = () => { const [loading, setLoading] = createSignal(false); const [loadError, setLoadError] = createSignal(null); const [summary, setSummary] = createSignal(null); - const [budgetUSD, setBudgetUSD] = createSignal(''); + const [aiSettings, setAISettings] = createSignal(null); + const [budgetUSD30dInput, setBudgetUSD30dInput] = createSignal(''); + const [savingBudget, setSavingBudget] = createSignal(false); let requestSeq = 0; const anyPricingKnown = createMemo(() => { @@ -120,40 +120,84 @@ export const AICostDashboard: Component = () => { loadSummary(days()); }); + const loadBudgetSettings = async () => { + try { + const s = await AIAPI.getSettings(); + setAISettings(s); + if (typeof s.cost_budget_usd_30d === 'number' && Number.isFinite(s.cost_budget_usd_30d) && s.cost_budget_usd_30d > 0) { + setBudgetUSD30dInput(String(s.cost_budget_usd_30d)); + } else { + setBudgetUSD30dInput(''); + } + } catch (err) { + logger.debug('[AICostDashboard] Failed to load AI settings for budget:', err); + setAISettings(null); + setBudgetUSD30dInput(''); + } + }; + onMount(() => { - try { - const stored = localStorage.getItem(budgetStorageKey); - if (stored) setBudgetUSD(stored); - } catch (_err) { - // ignore - } + loadBudgetSettings(); }); - createEffect(() => { - const value = budgetUSD(); - try { - if (!value) localStorage.removeItem(budgetStorageKey); - else localStorage.setItem(budgetStorageKey, value); - } catch (_err) { - // ignore - } - }); - - const parsedBudgetUSD = createMemo(() => { - const raw = budgetUSD().trim(); + const parsedBudgetUSD30d = createMemo(() => { + const raw = budgetUSD30dInput().trim(); if (!raw) return null; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return null; return n; }); + const budgetForRange = createMemo(() => { + const budget30d = parsedBudgetUSD30d(); + if (budget30d == null) return null; + const rangeDays = days(); + return (budget30d * rangeDays) / 30; + }); + const isOverBudget = createMemo(() => { - const budget = parsedBudgetUSD(); + const budget = budgetForRange(); const usd = estimatedTotalUSD(); if (budget == null || usd == null) return false; return usd > budget; }); + const isDirtyBudget = createMemo(() => { + const s = aiSettings(); + const current = typeof s?.cost_budget_usd_30d === 'number' ? s.cost_budget_usd_30d : 0; + const next = parsedBudgetUSD30d() ?? 0; + return Math.abs(current - next) > 0.0001; + }); + + const saveBudget = async () => { + if (savingBudget()) return; + setSavingBudget(true); + try { + const value = parsedBudgetUSD30d() ?? 0; + const next = await AIAPI.updateSettings({ cost_budget_usd_30d: value }); + setAISettings(next); + notificationStore.success('AI cost budget saved'); + } catch (err) { + logger.error('[AICostDashboard] Failed to save budget:', err); + notificationStore.error('Failed to save AI cost budget'); + await loadBudgetSettings(); + } finally { + setSavingBudget(false); + } + }; + + const resetHistory = async () => { + if (!confirm('Reset AI usage history? This clears the stored token/cost history.')) return; + try { + await AIAPI.resetCostHistory(); + notificationStore.success('AI usage history reset'); + await loadSummary(days()); + } catch (err) { + logger.error('[AICostDashboard] Failed to reset AI cost history:', err); + notificationStore.error('Failed to reset AI usage history'); + } + }; + const handleRangeClick = (rangeDays: number) => { if (loading() || rangeDays === days()) return; setDays(rangeDays); @@ -251,7 +295,7 @@ export const AICostDashboard: Component = () => {
- Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(parsedBudgetUSD() ?? 0)}). + Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(budgetForRange() ?? 0)}).
@@ -338,15 +382,28 @@ export const AICostDashboard: Component = () => {
-
Budget (USD)
+
+
Budget alert (USD per 30d)
+ +
setBudgetUSD(e.currentTarget.value)} + value={budgetUSD30dInput()} + onInput={(e) => setBudgetUSD30dInput(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. + Cross-provider estimate. Pro-rated for {days()}d:{' '} + —}> + {formatUSD(budgetForRange() ?? 0)} +
@@ -376,6 +433,20 @@ export const AICostDashboard: Component = () => { USD is an estimate based on public list prices. It may differ from billing. +
+
+ History retention: {data().retention_days} days +
+ +
+
diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index 986acee..945b0b5 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -38,6 +38,9 @@ export interface AISettings { ollama_base_url: string; // Ollama server URL openai_base_url?: string; // Custom OpenAI base URL configured_providers: AIProvider[]; // List of providers with credentials + + // Cost controls (30-day budget, pro-rated in UI) + cost_budget_usd_30d?: number; } export interface AISettingsUpdateRequest { @@ -68,6 +71,9 @@ export interface AISettingsUpdateRequest { clear_openai_key?: boolean; // Clear OpenAI API key clear_deepseek_key?: boolean; // Clear DeepSeek API key clear_ollama_url?: boolean; // Clear Ollama URL + + // Cost controls + cost_budget_usd_30d?: number; } diff --git a/internal/ai/cost/store.go b/internal/ai/cost/store.go index 4821c89..2607cf9 100644 --- a/internal/ai/cost/store.go +++ b/internal/ai/cost/store.go @@ -93,6 +93,16 @@ func (s *Store) Record(event UsageEvent) { s.mu.Unlock() } +// Clear removes all retained usage events and persists the empty history. +func (s *Store) Clear() error { + s.mu.Lock() + s.events = s.events[:0] + s.trimLocked(time.Now()) + s.scheduleSaveLocked() + s.mu.Unlock() + return s.Flush() +} + // GetSummary returns a rollup of usage over the last N days. func (s *Store) GetSummary(days int) Summary { if days <= 0 { diff --git a/internal/ai/cost/store_test.go b/internal/ai/cost/store_test.go index 51a4b98..94e934e 100644 --- a/internal/ai/cost/store_test.go +++ b/internal/ai/cost/store_test.go @@ -153,6 +153,27 @@ func TestSummaryTruncationReflectsRetentionWindow(t *testing.T) { } } +func TestClearEmptiesUsageHistory(t *testing.T) { + store := NewStore(30) + store.Record(UsageEvent{ + Timestamp: time.Now(), + Provider: "openai", + RequestModel: "openai:gpt-4o", + InputTokens: 10, + OutputTokens: 10, + UseCase: "chat", + }) + if len(store.GetSummary(30).ProviderModels) == 0 { + t.Fatalf("expected usage to be recorded before clear") + } + if err := store.Clear(); err != nil { + t.Fatalf("clear failed: %v", err) + } + if got := store.GetSummary(30); len(got.ProviderModels) != 0 || got.Totals.TotalTokens != 0 { + t.Fatalf("expected empty summary after clear, got %+v", got) + } +} + 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 d80557f..d9ff86d 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) } +// ClearCostHistory deletes retained AI usage events (admin operation). +func (s *Service) ClearCostHistory() error { + s.mu.RLock() + store := s.costStore + s.mu.RUnlock() + if store == nil { + return nil + } + return store.Clear() +} + // SetPatrolThresholdProvider sets the threshold provider for patrol // This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds func (s *Service) SetPatrolThresholdProvider(provider ThresholdProvider) { diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index a65b14a..256b470 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -140,6 +140,8 @@ type AISettingsResponse struct { OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials + // Cost controls + CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"` } // AISettingsUpdateRequest is the request body for PUT /api/settings/ai @@ -169,6 +171,8 @@ type AISettingsUpdateRequest struct { ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key ClearOllamaURL *bool `json:"clear_ollama_url,omitempty"` // Clear Ollama URL + // Cost controls + CostBudgetUSD30d *float64 `json:"cost_budget_usd_30d,omitempty"` } // HandleGetAISettings returns the current AI settings (GET /api/settings/ai) @@ -221,6 +225,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), OpenAIBaseURL: settings.OpenAIBaseURL, ConfiguredProviders: settings.GetConfiguredProviders(), + CostBudgetUSD30d: settings.CostBudgetUSD30d, } if err := utils.WriteJSONResponse(w, response); err != nil { @@ -364,6 +369,14 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt } } + if req.CostBudgetUSD30d != nil { + if *req.CostBudgetUSD30d < 0 { + http.Error(w, "cost_budget_usd_30d cannot be negative", http.StatusBadRequest) + return + } + settings.CostBudgetUSD30d = *req.CostBudgetUSD30d + } + // Handle alert-triggered analysis toggle if req.AlertTriggeredAnalysis != nil { settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis @@ -2435,6 +2448,29 @@ func (h *AISettingsHandler) HandleGetAICostSummary(w http.ResponseWriter, r *htt } } +// HandleResetAICostHistory deletes retained AI usage events (POST /api/ai/cost/reset). +func (h *AISettingsHandler) HandleResetAICostHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if h.aiService == nil { + http.Error(w, "AI service unavailable", http.StatusServiceUnavailable) + return + } + + if err := h.aiService.ClearCostHistory(); err != nil { + log.Error().Err(err).Msg("Failed to clear AI cost history") + http.Error(w, "Failed to clear AI cost history", http.StatusInternalServerError) + return + } + + if err := utils.WriteJSONResponse(w, map[string]any{"ok": true}); err != nil { + log.Error().Err(err).Msg("Failed to write clear cost history response") + } +} + // 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 8ab8eb5..5ba1527 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1096,6 +1096,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/debug/context", RequireAdmin(r.config, r.aiSettingsHandler.HandleDebugContext)) 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))) // 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 @@ -1418,7 +1419,7 @@ func (r *Router) StartPatrol(ctx context.Context) { if adapter != nil { r.aiSettingsHandler.SetMetricsHistoryProvider(adapter) } - + // Initialize baseline store for anomaly detection // Uses config dir for persistence baselineCfg := ai.DefaultBaselineConfig() @@ -1428,7 +1429,7 @@ func (r *Router) StartPatrol(ctx context.Context) { baselineStore := ai.NewBaselineStore(baselineCfg) if baselineStore != nil { r.aiSettingsHandler.SetBaselineStore(baselineStore) - + // Start background baseline learning loop go r.startBaselineLearning(ctx, baselineStore, metricsHistory) } @@ -1452,11 +1453,11 @@ func (r *Router) startBaselineLearning(ctx context.Context, store *ai.BaselineSt if store == nil || metricsHistory == nil { return } - + // Learn every hour ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() - + // Run initial learning after a short delay (allow metrics to accumulate) select { case <-ctx.Done(): @@ -1464,9 +1465,9 @@ func (r *Router) startBaselineLearning(ctx context.Context, store *ai.BaselineSt case <-time.After(5 * time.Minute): r.learnBaselines(store, metricsHistory) } - + log.Info().Msg("Baseline learning loop started") - + for { select { case <-ctx.Done(): @@ -1487,11 +1488,11 @@ func (r *Router) learnBaselines(store *ai.BaselineStore, metricsHistory *monitor if r.monitor == nil { return } - + state := r.monitor.GetState() learningWindow := 7 * 24 * time.Hour // Learn from 7 days of data var learned int - + // Learn baselines for nodes for _, node := range state.Nodes { for _, metric := range []string{"cpu", "memory"} { @@ -1507,7 +1508,7 @@ func (r *Router) learnBaselines(store *ai.BaselineStore, metricsHistory *monitor } } } - + // Learn baselines for VMs for _, vm := range state.VMs { if vm.Template { @@ -1526,7 +1527,7 @@ func (r *Router) learnBaselines(store *ai.BaselineStore, metricsHistory *monitor } } } - + // Learn baselines for containers for _, ct := range state.Containers { if ct.Template { @@ -1545,12 +1546,12 @@ func (r *Router) learnBaselines(store *ai.BaselineStore, metricsHistory *monitor } } } - + // Save after learning if err := store.Save(); err != nil { log.Warn().Err(err).Msg("Failed to save baselines") } - + log.Debug(). Int("baselines_updated", learned). Int("resources", store.ResourceCount()). diff --git a/internal/config/ai.go b/internal/config/ai.go index 588811e..7ee1018 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -16,14 +16,14 @@ const ( // This is stored in ai.enc (encrypted) in the config directory type AIConfig struct { Enabled bool `json:"enabled"` - Provider string `json:"provider"` // DEPRECATED: legacy single provider field, kept for migration - APIKey string `json:"api_key"` // DEPRECATED: legacy single API key, kept for migration - Model string `json:"model"` // Currently selected default model (format: "provider:model-name") + Provider string `json:"provider"` // DEPRECATED: legacy single provider field, kept for migration + APIKey string `json:"api_key"` // DEPRECATED: legacy single API key, kept for migration + Model string `json:"model"` // Currently selected default model (format: "provider:model-name") ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (defaults to Model) PatrolModel string `json:"patrol_model,omitempty"` // Model for background patrol (defaults to Model, can be cheaper) - BaseURL string `json:"base_url"` // DEPRECATED: legacy base URL, kept for migration - AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval - CustomContext string `json:"custom_context"` // user-provided context about their infrastructure + BaseURL string `json:"base_url"` // DEPRECATED: legacy base URL, kept for migration + AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval + CustomContext string `json:"custom_context"` // user-provided context about their infrastructure // Multi-provider credentials - each provider can be configured independently AnthropicAPIKey string `json:"anthropic_api_key,omitempty"` // Anthropic API key @@ -39,17 +39,21 @@ type AIConfig struct { OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time // Patrol settings for background AI monitoring - PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol - PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 360 = 6 hours) - PatrolSchedulePreset string `json:"patrol_schedule_preset,omitempty"` // User-friendly preset: "15min", "1hr", "6hr", "12hr", "daily", "disabled" - PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol - PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers in patrol - PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol - PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage in patrol - PatrolAutoFix bool `json:"patrol_auto_fix,omitempty"` // When true, patrol can attempt automatic remediation (default: false, observe only) + PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol + PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 360 = 6 hours) + PatrolSchedulePreset string `json:"patrol_schedule_preset,omitempty"` // User-friendly preset: "15min", "1hr", "6hr", "12hr", "daily", "disabled" + PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol + PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers in patrol + PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol + PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage in patrol + PatrolAutoFix bool `json:"patrol_auto_fix,omitempty"` // When true, patrol can attempt automatic remediation (default: false, observe only) // Alert-triggered AI analysis - analyze specific resources when alerts fire AlertTriggeredAnalysis bool `json:"alert_triggered_analysis,omitempty"` // Enable AI analysis when alerts fire (token-efficient) + + // AI cost controls + // Budget is expressed as an estimated USD amount over a 30-day window (pro-rated in UI for other ranges). + CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"` } // AIProvider constants @@ -94,13 +98,13 @@ func NewDefaultAIConfig() *AIConfig { AuthMethod: AuthMethodAPIKey, // Patrol defaults - enabled when AI is enabled // Default to 6 hour intervals (much more token-efficient than 15 min) - PatrolEnabled: true, - PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency - PatrolSchedulePreset: "6hr", - PatrolAnalyzeNodes: true, - PatrolAnalyzeGuests: true, - PatrolAnalyzeDocker: true, - PatrolAnalyzeStorage: true, + PatrolEnabled: true, + PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency + PatrolSchedulePreset: "6hr", + PatrolAnalyzeNodes: true, + PatrolAnalyzeGuests: true, + PatrolAnalyzeDocker: true, + PatrolAnalyzeStorage: true, // Alert-triggered analysis is highly token-efficient - enabled by default AlertTriggeredAnalysis: true, } @@ -394,4 +398,3 @@ func (c *AIConfig) IsPatrolEnabled() bool { func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool { return c.AlertTriggeredAnalysis } -