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>;
|
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
|
// Start OAuth flow for Claude Pro/Max subscription
|
||||||
// Returns the authorization URL to redirect the user to
|
// Returns the authorization URL to redirect the user to
|
||||||
static async startOAuth(): Promise<{ auth_url: string; state: string }> {
|
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 { Card } from '@/components/shared/Card';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { AIAPI } from '@/api/ai';
|
import { AIAPI } from '@/api/ai';
|
||||||
import { formatNumber } from '@/utils/format';
|
import { formatNumber } from '@/utils/format';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { notificationStore } from '@/stores/notifications';
|
import { notificationStore } from '@/stores/notifications';
|
||||||
import type { AICostSummary } from '@/types/ai';
|
import type { AICostSummary, AISettings } from '@/types/ai';
|
||||||
import { PROVIDER_NAMES } from '@/types/ai';
|
import { PROVIDER_NAMES } from '@/types/ai';
|
||||||
|
|
||||||
const usdFormatter = new Intl.NumberFormat(undefined, {
|
const usdFormatter = new Intl.NumberFormat(undefined, {
|
||||||
|
|
@ -15,8 +15,6 @@ const usdFormatter = new Intl.NumberFormat(undefined, {
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const budgetStorageKey = 'pulse_ai_cost_budget_usd_v1';
|
|
||||||
|
|
||||||
const TinySparkline: Component<{
|
const TinySparkline: Component<{
|
||||||
values: number[];
|
values: number[];
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|
@ -58,7 +56,9 @@ export const AICostDashboard: Component = () => {
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [loadError, setLoadError] = createSignal<string | null>(null);
|
const [loadError, setLoadError] = createSignal<string | null>(null);
|
||||||
const [summary, setSummary] = createSignal<AICostSummary | 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;
|
let requestSeq = 0;
|
||||||
|
|
||||||
const anyPricingKnown = createMemo(() => {
|
const anyPricingKnown = createMemo(() => {
|
||||||
|
|
@ -120,40 +120,84 @@ export const AICostDashboard: Component = () => {
|
||||||
loadSummary(days());
|
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(() => {
|
onMount(() => {
|
||||||
try {
|
loadBudgetSettings();
|
||||||
const stored = localStorage.getItem(budgetStorageKey);
|
|
||||||
if (stored) setBudgetUSD(stored);
|
|
||||||
} catch (_err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
const parsedBudgetUSD30d = createMemo(() => {
|
||||||
const value = budgetUSD();
|
const raw = budgetUSD30dInput().trim();
|
||||||
try {
|
|
||||||
if (!value) localStorage.removeItem(budgetStorageKey);
|
|
||||||
else localStorage.setItem(budgetStorageKey, value);
|
|
||||||
} catch (_err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedBudgetUSD = createMemo(() => {
|
|
||||||
const raw = budgetUSD().trim();
|
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
if (!Number.isFinite(n) || n <= 0) return null;
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const budgetForRange = createMemo(() => {
|
||||||
|
const budget30d = parsedBudgetUSD30d();
|
||||||
|
if (budget30d == null) return null;
|
||||||
|
const rangeDays = days();
|
||||||
|
return (budget30d * rangeDays) / 30;
|
||||||
|
});
|
||||||
|
|
||||||
const isOverBudget = createMemo(() => {
|
const isOverBudget = createMemo(() => {
|
||||||
const budget = parsedBudgetUSD();
|
const budget = budgetForRange();
|
||||||
const usd = estimatedTotalUSD();
|
const usd = estimatedTotalUSD();
|
||||||
if (budget == null || usd == null) return false;
|
if (budget == null || usd == null) return false;
|
||||||
return usd > budget;
|
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) => {
|
const handleRangeClick = (rangeDays: number) => {
|
||||||
if (loading() || rangeDays === days()) return;
|
if (loading() || rangeDays === days()) return;
|
||||||
setDays(rangeDays);
|
setDays(rangeDays);
|
||||||
|
|
@ -251,7 +295,7 @@ export const AICostDashboard: Component = () => {
|
||||||
|
|
||||||
<Show when={isOverBudget()}>
|
<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">
|
<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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -338,15 +382,28 @@ export const AICostDashboard: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
</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="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
|
<input
|
||||||
value={budgetUSD()}
|
value={budgetUSD30dInput()}
|
||||||
onInput={(e) => setBudgetUSD(e.currentTarget.value)}
|
onInput={(e) => setBudgetUSD30dInput(e.currentTarget.value)}
|
||||||
placeholder="e.g. 25"
|
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"
|
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">
|
<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>
|
</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.
|
USD is an estimate based on public list prices. It may differ from billing.
|
||||||
</div>
|
</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">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm">
|
||||||
<thead class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
<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
|
ollama_base_url: string; // Ollama server URL
|
||||||
openai_base_url?: string; // Custom OpenAI base URL
|
openai_base_url?: string; // Custom OpenAI base URL
|
||||||
configured_providers: AIProvider[]; // List of providers with credentials
|
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 {
|
export interface AISettingsUpdateRequest {
|
||||||
|
|
@ -68,6 +71,9 @@ export interface AISettingsUpdateRequest {
|
||||||
clear_openai_key?: boolean; // Clear OpenAI API key
|
clear_openai_key?: boolean; // Clear OpenAI API key
|
||||||
clear_deepseek_key?: boolean; // Clear DeepSeek API key
|
clear_deepseek_key?: boolean; // Clear DeepSeek API key
|
||||||
clear_ollama_url?: boolean; // Clear Ollama URL
|
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()
|
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.
|
// GetSummary returns a rollup of usage over the last N days.
|
||||||
func (s *Store) GetSummary(days int) Summary {
|
func (s *Store) GetSummary(days int) Summary {
|
||||||
if days <= 0 {
|
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) {
|
func TestEstimateUSDKnownAndUnknownModels(t *testing.T) {
|
||||||
usd, ok, _ := EstimateUSD("openai", "gpt-4o", 1_000_000, 2_000_000)
|
usd, ok, _ := EstimateUSD("openai", "gpt-4o", 1_000_000, 2_000_000)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,17 @@ func (s *Service) GetCostSummary(days int) cost.Summary {
|
||||||
return store.GetSummary(days)
|
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
|
// SetPatrolThresholdProvider sets the threshold provider for patrol
|
||||||
// This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds
|
// This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds
|
||||||
func (s *Service) SetPatrolThresholdProvider(provider ThresholdProvider) {
|
func (s *Service) SetPatrolThresholdProvider(provider ThresholdProvider) {
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@ type AISettingsResponse struct {
|
||||||
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
|
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
|
||||||
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
|
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
|
||||||
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
|
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
|
// 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
|
ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key
|
||||||
ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key
|
ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key
|
||||||
ClearOllamaURL *bool `json:"clear_ollama_url,omitempty"` // Clear Ollama URL
|
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)
|
// 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),
|
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
|
||||||
OpenAIBaseURL: settings.OpenAIBaseURL,
|
OpenAIBaseURL: settings.OpenAIBaseURL,
|
||||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||||
|
CostBudgetUSD30d: settings.CostBudgetUSD30d,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
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
|
// Handle alert-triggered analysis toggle
|
||||||
if req.AlertTriggeredAnalysis != nil {
|
if req.AlertTriggeredAnalysis != nil {
|
||||||
settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis
|
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)
|
// HandleGetSuppressionRules returns all suppression rules (GET /api/ai/patrol/suppressions)
|
||||||
func (h *AISettingsHandler) HandleGetSuppressionRules(w http.ResponseWriter, r *http.Request) {
|
func (h *AISettingsHandler) HandleGetSuppressionRules(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
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/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/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/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
|
// 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/start", RequireAdmin(r.config, r.aiSettingsHandler.HandleOAuthStart))
|
||||||
r.mux.HandleFunc("/api/ai/oauth/exchange", RequireAdmin(r.config, r.aiSettingsHandler.HandleOAuthExchange)) // Manual code input
|
r.mux.HandleFunc("/api/ai/oauth/exchange", RequireAdmin(r.config, r.aiSettingsHandler.HandleOAuthExchange)) // Manual code input
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,14 @@ const (
|
||||||
// This is stored in ai.enc (encrypted) in the config directory
|
// This is stored in ai.enc (encrypted) in the config directory
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Provider string `json:"provider"` // DEPRECATED: legacy single provider field, kept for migration
|
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
|
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")
|
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)
|
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)
|
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
|
BaseURL string `json:"base_url"` // DEPRECATED: legacy base URL, kept for migration
|
||||||
AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval
|
AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval
|
||||||
CustomContext string `json:"custom_context"` // user-provided context about their infrastructure
|
CustomContext string `json:"custom_context"` // user-provided context about their infrastructure
|
||||||
|
|
||||||
// Multi-provider credentials - each provider can be configured independently
|
// Multi-provider credentials - each provider can be configured independently
|
||||||
AnthropicAPIKey string `json:"anthropic_api_key,omitempty"` // Anthropic API key
|
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
|
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time
|
||||||
|
|
||||||
// Patrol settings for background AI monitoring
|
// Patrol settings for background AI monitoring
|
||||||
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
|
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)
|
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"
|
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
|
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol
|
||||||
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers 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
|
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol
|
||||||
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage 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)
|
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
|
// 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)
|
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
|
// AIProvider constants
|
||||||
|
|
@ -94,13 +98,13 @@ func NewDefaultAIConfig() *AIConfig {
|
||||||
AuthMethod: AuthMethodAPIKey,
|
AuthMethod: AuthMethodAPIKey,
|
||||||
// Patrol defaults - enabled when AI is enabled
|
// Patrol defaults - enabled when AI is enabled
|
||||||
// Default to 6 hour intervals (much more token-efficient than 15 min)
|
// Default to 6 hour intervals (much more token-efficient than 15 min)
|
||||||
PatrolEnabled: true,
|
PatrolEnabled: true,
|
||||||
PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency
|
PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency
|
||||||
PatrolSchedulePreset: "6hr",
|
PatrolSchedulePreset: "6hr",
|
||||||
PatrolAnalyzeNodes: true,
|
PatrolAnalyzeNodes: true,
|
||||||
PatrolAnalyzeGuests: true,
|
PatrolAnalyzeGuests: true,
|
||||||
PatrolAnalyzeDocker: true,
|
PatrolAnalyzeDocker: true,
|
||||||
PatrolAnalyzeStorage: true,
|
PatrolAnalyzeStorage: true,
|
||||||
// Alert-triggered analysis is highly token-efficient - enabled by default
|
// Alert-triggered analysis is highly token-efficient - enabled by default
|
||||||
AlertTriggeredAnalysis: true,
|
AlertTriggeredAnalysis: true,
|
||||||
}
|
}
|
||||||
|
|
@ -394,4 +398,3 @@ func (c *AIConfig) IsPatrolEnabled() bool {
|
||||||
func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool {
|
func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool {
|
||||||
return c.AlertTriggeredAnalysis
|
return c.AlertTriggeredAnalysis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue