Persist AI cost budget and allow history reset
This commit is contained in:
parent
5ba4e6a84c
commit
4df04970ea
9 changed files with 230 additions and 63 deletions
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue