Persist AI cost budget and allow history reset

This commit is contained in:
rcourtman 2025-12-12 12:10:58 +00:00
parent 5ba4e6a84c
commit 4df04970ea
9 changed files with 230 additions and 63 deletions

View file

@ -50,6 +50,14 @@ export class AIAPI {
return apiFetchJSON(`${this.baseUrl}/ai/cost/summary?days=${days}`) as Promise<AICostSummary>;
}
// 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 }> {

View file

@ -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<string | null>(null);
const [summary, setSummary] = createSignal<AICostSummary | null>(null);
const [budgetUSD, setBudgetUSD] = createSignal<string>('');
const [aiSettings, setAISettings] = createSignal<AISettings | null>(null);
const [budgetUSD30dInput, setBudgetUSD30dInput] = createSignal<string>('');
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 = () => {
<Show when={isOverBudget()}>
<div class="text-xs px-3 py-2 rounded border border-red-200 dark:border-red-800/60 bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100">
Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(parsedBudgetUSD() ?? 0)}).
Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(budgetForRange() ?? 0)}).
</div>
</Show>
@ -338,15 +382,28 @@ export const AICostDashboard: Component = () => {
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Budget (USD)</div>
<div class="flex items-center justify-between gap-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Budget alert (USD per 30d)</div>
<button
type="button"
disabled={!isDirtyBudget() || savingBudget()}
onClick={saveBudget}
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 ${(!isDirtyBudget() || savingBudget()) ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Save
</button>
</div>
<input
value={budgetUSD()}
onInput={(e) => 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"
/>
<div class="text-[11px] text-gray-500 dark:text-gray-400 mt-1">
Applies to selected range.
Cross-provider estimate. Pro-rated for {days()}d:{' '}
<Show when={budgetForRange() != null} fallback={<span></span>}>
{formatUSD(budgetForRange() ?? 0)}
</Show>
</div>
</div>
</div>
@ -376,6 +433,20 @@ export const AICostDashboard: Component = () => {
USD is an estimate based on public list prices. It may differ from billing.
</div>
<div class="flex items-center justify-between gap-3">
<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>
<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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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