Files
pulse/internal/config/ai.go
T
rcourtman 23695681ac Route new Patrol findings to alert notification channels
Patrol findings previously reached operators only through the Relay
mobile push path. Anyone relying on the email, webhook, or Apprise
destinations they already configured for alerts got no proactive signal
when Patrol detected a problem and had to open /patrol to learn about
it. This is the delivery half of #1369.

Each newly stored warning or critical finding now also flows through a
FindingNotifyCallback wired in the router, which projects the finding
into the alert shape the notification manager delivers. The callback
fires only on the genuinely-new path in recordFindingWithInvestigation,
so a finding notifies at most once per lifetime regardless of how many
later runs re-detect it, and SendAlert's own per-ID cooldown backstops
that. Demo mode never notifies.

Gating lives in AIConfig via patrol_finding_notifications_enabled
(default on, matching the long-standing default for mobile push) and
patrol_finding_notify_min_severity (warning or critical, default
warning). The enabled flag persists without omitempty so an explicit
opt-out survives reload while pre-existing configs inherit the default.
The settings surface for these fields follows in a separate commit once
the AI settings handler is free.
2026-07-18 11:59:05 +01:00

1022 lines
40 KiB
Go

package config
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// AuthMethod represents how Anthropic authentication is performed
type AuthMethod string
const (
// AuthMethodAPIKey uses a traditional API key (pay-per-use billing)
AuthMethodAPIKey AuthMethod = "api_key"
// AuthMethodOAuth is a legacy stored value retained only so old tokens can be cleared.
AuthMethodOAuth AuthMethod = "oauth"
)
// PatrolEventTriggerSettings describes which event sources may enqueue scoped patrol runs.
type PatrolEventTriggerSettings struct {
AlertTriggersEnabled bool
AnomalyTriggersEnabled bool
}
// AIConfig holds AI feature configuration
// This is stored in ai.enc (encrypted) in the config directory
type AIConfig struct {
Enabled bool `json:"enabled"`
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)
DiscoveryModel string `json:"discovery_model,omitempty"` // Model for infrastructure discovery (defaults to cheapest available, e.g., haiku)
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
OpenAIAPIKey string `json:"openai_api_key,omitempty"` // OpenAI API key
OpenRouterAPIKey string `json:"openrouter_api_key,omitempty"` // OpenRouter API key
DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // Google Gemini API key
ZaiAPIKey string `json:"zai_api_key,omitempty"` // Z.ai (Zhipu GLM) API key
ZaiBaseURL string `json:"zai_base_url,omitempty"` // Custom Z.ai OpenAI-compatible base URL (e.g. coding endpoint)
GroqAPIKey string `json:"groq_api_key,omitempty"` // Groq API key
MistralAPIKey string `json:"mistral_api_key,omitempty"` // Mistral API key
CerebrasAPIKey string `json:"cerebras_api_key,omitempty"` // Cerebras API key
TogetherAPIKey string `json:"together_api_key,omitempty"` // Together AI API key
FireworksAPIKey string `json:"fireworks_api_key,omitempty"` // Fireworks AI API key
CodexSubscriptionEnabled bool `json:"codex_subscription_enabled,omitempty"` // Use the locally authenticated Codex CLI for Pulse model turns
ClaudeSubscriptionEnabled bool `json:"claude_subscription_enabled,omitempty"` // Use the locally authenticated Claude CLI for Pulse model turns
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
OllamaPassword string `json:"ollama_password,omitempty"` // Optional Basic Auth password for Ollama
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses the server default
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional)
// Legacy Anthropic OAuth fields are retained only for cleanup/migration.
AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or legacy "oauth"
OAuthAccessToken string `json:"oauth_access_token,omitempty"` // legacy OAuth access token (encrypted at rest)
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // legacy OAuth refresh token (encrypted at rest)
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // legacy 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"` // How often to run quick patrols (default: 360 = 6 hours)
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes"` // Include Proxmox nodes in patrol
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests"` // Include VMs/containers in patrol
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker"` // Include Docker hosts in patrol
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage"` // Include storage in patrol
PatrolAutoFix bool `json:"patrol_auto_fix,omitempty"` // When true, patrol can attempt automatic remediation (default: false, observe only)
UseProactiveThresholds bool `json:"use_proactive_thresholds,omitempty"` // When true, patrol warns 5-15% BEFORE alert thresholds (default: false, use exact thresholds)
AutoFixModel string `json:"auto_fix_model,omitempty"` // Model for automatic remediation (defaults to PatrolModel, may want more capable model)
// Patrol finding notifications route each newly detected warning+ finding
// through the operator's alert notification channels (email, webhooks,
// Apprise). Findings are deduplicated upstream, so at most one
// notification fires per finding lifetime. The enabled flag is persisted
// without omitempty so an explicit opt-out survives reload; configs saved
// before the field existed inherit the default (enabled).
PatrolFindingNotificationsEnabled bool `json:"patrol_finding_notifications_enabled"`
// PatrolFindingNotifyMinSeverity is the minimum finding severity that
// warrants a notification ("warning" accepts warning+critical;
// "critical" accepts only critical; empty = "warning").
PatrolFindingNotifyMinSeverity string `json:"patrol_finding_notify_min_severity,omitempty"`
// Alert-triggered AI analysis - analyze specific resources when alerts fire
AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // Enable AI analysis when alerts fire (token-efficient)
// Event-triggered patrols - run extra patrols when alerts fire or anomalies are detected.
// The legacy aggregate flag is retained for compatibility; the canonical model is now split
// between alert-triggered and anomaly-triggered scoped patrol preferences.
PatrolEventTriggersEnabled bool `json:"patrol_event_triggers_enabled"`
PatrolAlertTriggersEnabled bool `json:"patrol_alert_triggers_enabled"`
PatrolAnomalyTriggersEnabled bool `json:"patrol_anomaly_triggers_enabled"`
// Fine-grained control over which firing alerts trigger a scoped patrol.
// PatrolAlertTriggerMinSeverity is the minimum alert level that warrants an
// investigation ("warning" accepts warning+critical; "critical" accepts only
// critical; empty = "critical"). PatrolAlertTriggerTypes optionally restricts
// triggering to specific alert types (cpu, memory, disk, ...); empty = all types.
PatrolAlertTriggerMinSeverity string `json:"patrol_alert_trigger_min_severity,omitempty"`
PatrolAlertTriggerTypes []string `json:"patrol_alert_trigger_types,omitempty"`
// Request timeout - how long to wait for AI responses (default: 300s / 5 min)
// Increase this for slow hardware running local models (e.g., Ollama on low-power devices)
RequestTimeoutSeconds int `json:"request_timeout_seconds,omitempty"`
// 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"`
// AI Infrastructure Control settings
// These control whether AI can take actions on infrastructure (start/stop VMs, containers, etc.)
ControlLevel string `json:"control_level,omitempty"` // "read_only", "controlled", "autonomous"
ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs or names that AI cannot control
// Patrol Autonomy settings - controls automatic investigation and remediation of findings
PatrolAutonomyLevel string `json:"patrol_autonomy_level,omitempty"` // "monitor", "approval", "assisted", "full"
PatrolFullModeUnlocked bool `json:"patrol_full_mode_unlocked"` // Legacy wire/storage compatibility only; never authority for effective full mode.
PatrolAutopilotAcknowledgements []unifiedresources.PatrolAutopilotAcknowledgement `json:"patrol_autopilot_acknowledgements,omitempty"`
PatrolAutopilotRevocations []unifiedresources.PatrolAutopilotRevocation `json:"patrol_autopilot_revocations,omitempty"`
PatrolAutopilotActivation *unifiedresources.PatrolAutopilotActivation `json:"patrol_autopilot_activation,omitempty"`
PatrolActionEmergencyStop bool `json:"patrol_action_emergency_stop"` // Blocks new human and policy action admission; does not imply rollback
PatrolInvestigationBudget int `json:"patrol_investigation_budget,omitempty"` // Max evidence calls per investigation (default: 15)
PatrolInvestigationTimeoutSec int `json:"patrol_investigation_timeout_sec,omitempty"` // Max seconds per investigation (default: 300)
// Discovery settings - controls automatic infrastructure discovery
DiscoveryEnabled bool `json:"discovery_enabled"` // Enable infrastructure discovery
DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between automatic re-scans (0 = manual only, default: 0)
}
// AIProvider constants
const (
AIProviderAnthropic = "anthropic"
AIProviderOpenAI = "openai"
AIProviderOpenRouter = "openrouter"
AIProviderOllama = "ollama"
AIProviderDeepSeek = "deepseek"
AIProviderGemini = "gemini"
AIProviderZai = "zai" // Z.ai OpenAI-compatible provider
AIProviderGroq = "groq"
AIProviderMistral = "mistral"
AIProviderCerebras = "cerebras"
AIProviderTogether = "together"
AIProviderFireworks = "fireworks"
AIProviderCodexSubscription = "codex-subscription"
AIProviderClaudeSubscription = "claude-subscription"
AIProviderQuickstart = "quickstart" // Retired Pulse-hosted proxy marker retained only for legacy config migration.
)
// AI Control Level constants
const (
// ControlLevelReadOnly - AI can only query infrastructure, no control tools available
ControlLevelReadOnly = string(agentcapabilities.ControlLevelReadOnly)
// ControlLevelControlled - AI can execute with per-command approval
ControlLevelControlled = string(agentcapabilities.ControlLevelControlled)
// ControlLevelAutonomous - AI executes without approval (requires Pro license)
ControlLevelAutonomous = string(agentcapabilities.ControlLevelAutonomous)
)
// Patrol Autonomy Level constants
const (
// PatrolAutonomyMonitor - Detect issues and create findings, no automatic investigation
PatrolAutonomyMonitor = "monitor"
// PatrolAutonomyApproval - Spawn Chat sessions to investigate, queue ALL fixes for user approval
PatrolAutonomyApproval = "approval"
// PatrolAutonomyAssisted - Auto-fix warnings, critical findings still need approval
PatrolAutonomyAssisted = "assisted"
// PatrolAutonomyFull - Full autonomy, auto-fix everything including critical (user accepts risk)
PatrolAutonomyFull = "full"
)
// Default patrol investigation settings
const (
DefaultPatrolInvestigationBudget = 15 // Max turns (tool calls) per investigation
DefaultPatrolInvestigationTimeoutSec = 600 // 10 minutes
MaxConcurrentInvestigations = 3 // Max parallel investigations
MaxInvestigationAttempts = 3 // Max retry attempts per finding
InvestigationCooldownHours = 1 // Hours before re-investigating same finding
)
const (
// DefaultAIModelQuickstart is retained only to recognize and retire legacy
// Pulse-hosted model aliases from pre-GA config.
DefaultAIModelQuickstart = "pulse-hosted"
DefaultOllamaBaseURL = "http://localhost:11434"
DefaultOllamaKeepAlive = "30s"
DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1"
DefaultDeepSeekBaseURL = "https://api.deepseek.com"
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta"
DefaultZaiBaseURL = "https://api.z.ai/api/paas/v4"
DefaultGroqBaseURL = "https://api.groq.com/openai/v1"
DefaultMistralBaseURL = "https://api.mistral.ai/v1"
DefaultCerebrasBaseURL = "https://api.cerebras.ai/v1"
DefaultTogetherBaseURL = "https://api.together.xyz/v1"
DefaultFireworksBaseURL = "https://api.fireworks.ai/inference/v1"
)
const (
DeepSeekModelV4Flash = "deepseek-v4-flash"
DeepSeekModelV4Pro = "deepseek-v4-pro"
DeepSeekModelLegacyChat = "deepseek-chat"
DeepSeekModelLegacyReasoner = "deepseek-reasoner"
)
// DeepSeekV4ModelIDs returns the current direct DeepSeek model IDs Pulse treats
// as tool-capable for Assistant and Patrol runtime paths.
func DeepSeekV4ModelIDs() []string {
return []string{DeepSeekModelV4Flash, DeepSeekModelV4Pro}
}
// DeepSeekLegacyAliasModelIDs returns direct DeepSeek aliases that are still
// accepted by the provider but should be presented as legacy aliases.
func DeepSeekLegacyAliasModelIDs() []string {
return []string{DeepSeekModelLegacyChat, DeepSeekModelLegacyReasoner}
}
func IsDeepSeekV4Model(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
case DeepSeekModelV4Flash, DeepSeekModelV4Pro:
return true
default:
return false
}
}
func IsDeepSeekLegacyAliasModel(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
case DeepSeekModelLegacyChat, DeepSeekModelLegacyReasoner:
return true
default:
return false
}
}
// NewDefaultAIConfig returns an AIConfig with sensible defaults
func NewDefaultAIConfig() *AIConfig {
return &AIConfig{
Enabled: false,
Model: "",
AuthMethod: AuthMethodAPIKey,
// Pulse keeps the v6 cost-control default explicit. Operators can
// clear this value to let the Ollama server's own default apply.
OllamaKeepAlive: DefaultOllamaKeepAlive,
// 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
PatrolAnalyzeNodes: true,
PatrolAnalyzeGuests: true,
PatrolAnalyzeDocker: true,
PatrolAnalyzeStorage: true,
// Alert-triggered analysis is highly token-efficient - enabled by default
AlertTriggeredAnalysis: true,
// Event-triggered patrols enabled by default (alerts, anomalies trigger extra patrols)
PatrolEventTriggersEnabled: true,
PatrolAlertTriggersEnabled: true,
PatrolAnomalyTriggersEnabled: true,
// Default to critical-only so alert-triggered investigations stay
// token-conservative out of the box. Operators can opt warnings in.
PatrolAlertTriggerMinSeverity: AlertTriggerSeverityCritical,
// Finding notifications default on at warning+, matching the
// long-standing default for Patrol mobile push. Findings fire once
// per lifetime, so the volume is inherently low.
PatrolFindingNotificationsEnabled: true,
}
}
// Alert-trigger minimum-severity sentinels.
const (
AlertTriggerSeverityWarning = "warning"
AlertTriggerSeverityCritical = "critical"
)
// GetPatrolAlertTriggerMinSeverity returns the configured minimum alert level
// that warrants a scoped investigation patrol, normalizing the empty default to
// critical-only.
func (c *AIConfig) GetPatrolAlertTriggerMinSeverity() string {
if c == nil {
return AlertTriggerSeverityCritical
}
min := strings.ToLower(strings.TrimSpace(c.PatrolAlertTriggerMinSeverity))
switch min {
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
return min
default:
return AlertTriggerSeverityCritical
}
}
// AlertTriggersInvestigation reports whether a firing alert of the given type
// and level should trigger a scoped investigation patrol, per the operator's
// alert-trigger policy. It enforces the master enable, the minimum-severity
// floor, and the optional alert-type allowlist.
func (c *AIConfig) AlertTriggersInvestigation(alertType, level string) bool {
if c == nil || !c.PatrolAlertTriggersEnabled {
return false
}
if !alertLevelMeetsMinimum(level, c.PatrolAlertTriggerMinSeverity) {
return false
}
if len(c.PatrolAlertTriggerTypes) > 0 && !alertTypeAllowed(alertType, c.PatrolAlertTriggerTypes) {
return false
}
return true
}
// GetPatrolFindingNotifyMinSeverity returns the configured minimum finding
// severity that warrants a notification, normalizing the empty default to
// warning. Warning is the right floor here because most actionable findings
// (data-loss risks, failing health checks) are reported at warning severity.
func (c *AIConfig) GetPatrolFindingNotifyMinSeverity() string {
if c == nil {
return AlertTriggerSeverityWarning
}
min := strings.ToLower(strings.TrimSpace(c.PatrolFindingNotifyMinSeverity))
switch min {
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
return min
default:
return AlertTriggerSeverityWarning
}
}
// PatrolFindingTriggersNotification reports whether a newly detected finding
// of the given severity should be routed to the operator's alert notification
// channels. Only warning and critical findings ever qualify; info and watch
// findings are UI-only detail.
func (c *AIConfig) PatrolFindingTriggersNotification(severity string) bool {
if c == nil || !c.Enabled || !c.PatrolFindingNotificationsEnabled {
return false
}
switch strings.ToLower(strings.TrimSpace(severity)) {
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
default:
return false
}
return alertLevelMeetsMinimum(severity, c.GetPatrolFindingNotifyMinSeverity())
}
// alertLevelMeetsMinimum ranks warning < critical. An empty minimum defaults to
// critical-only. An unknown alert level is treated as critical so it is never
// silently dropped.
func alertLevelMeetsMinimum(level, minimum string) bool {
rank := func(s string) int {
switch strings.ToLower(strings.TrimSpace(s)) {
case AlertTriggerSeverityWarning:
return 1
case AlertTriggerSeverityCritical:
return 2
default:
return 2
}
}
min := strings.ToLower(strings.TrimSpace(minimum))
if min == "" {
min = AlertTriggerSeverityCritical
}
return rank(level) >= rank(min)
}
func alertTypeAllowed(alertType string, allowed []string) bool {
at := strings.ToLower(strings.TrimSpace(alertType))
for _, a := range allowed {
if strings.ToLower(strings.TrimSpace(a)) == at {
return true
}
}
return false
}
// NormalizeOllamaKeepAlive validates the value Pulse sends as Ollama's
// keep_alive request option. Empty is intentional and means "omit keep_alive"
// so the Ollama server default applies.
func NormalizeOllamaKeepAlive(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", nil
}
if numeric, err := strconv.ParseFloat(trimmed, 64); err == nil {
if math.IsInf(numeric, 0) || math.IsNaN(numeric) {
return "", fmt.Errorf("must be a finite duration or second count")
}
return trimmed, nil
}
if _, err := time.ParseDuration(trimmed); err == nil {
return trimmed, nil
}
return "", fmt.Errorf("must be a duration such as 30s, 5m, or 24h; seconds such as 3600; -1 to keep loaded; 0 to unload; or empty to use the Ollama server default")
}
// GetOllamaKeepAlive returns the configured keep_alive value. A nil config
// keeps the Pulse default; an explicitly empty config value is preserved so
// callers can omit keep_alive and defer to the Ollama server.
func (c *AIConfig) GetOllamaKeepAlive() string {
if c == nil {
return DefaultOllamaKeepAlive
}
return strings.TrimSpace(c.OllamaKeepAlive)
}
// IsConfigured returns true if the AI config has enough info to make API calls
// For multi-provider setup, returns true if ANY provider is configured
func (c *AIConfig) IsConfigured() bool {
if c == nil || !c.Enabled {
return false
}
return len(c.GetConfiguredProviders()) > 0
}
// HasProvider returns true if the specified provider has credentials configured
func (c *AIConfig) HasProvider(provider string) bool {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case AIProviderAnthropic:
return c.AnthropicAPIKey != ""
case AIProviderOpenAI:
return c.OpenAIAPIKey != ""
case AIProviderOpenRouter:
return c.OpenRouterAPIKey != ""
case AIProviderDeepSeek:
return c.DeepSeekAPIKey != ""
case AIProviderGemini:
return c.GeminiAPIKey != ""
case AIProviderZai:
return c.ZaiAPIKey != ""
case AIProviderGroq:
return c.GroqAPIKey != ""
case AIProviderMistral:
return c.MistralAPIKey != ""
case AIProviderCerebras:
return c.CerebrasAPIKey != ""
case AIProviderTogether:
return c.TogetherAPIKey != ""
case AIProviderFireworks:
return c.FireworksAPIKey != ""
case AIProviderOllama:
// Ollama is only "configured" if user has explicitly set a base URL
return c.OllamaBaseURL != ""
case AIProviderCodexSubscription:
return c.CodexSubscriptionEnabled
case AIProviderClaudeSubscription:
return c.ClaudeSubscriptionEnabled
default:
return false
}
}
// GetConfiguredProviders returns a list of all providers with credentials configured
func (c *AIConfig) GetConfiguredProviders() []string {
if c == nil {
return nil
}
var configured []string
for _, def := range AIConfigurableProviderDefinitions() {
if c.HasProvider(def.ID) {
configured = append(configured, def.ID)
}
}
return configured
}
// GetAPIKeyForProvider returns the API key for the specified provider
func (c *AIConfig) GetAPIKeyForProvider(provider string) string {
if c == nil {
return ""
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case AIProviderAnthropic:
if c.AnthropicAPIKey != "" {
return c.AnthropicAPIKey
}
case AIProviderOpenAI:
if c.OpenAIAPIKey != "" {
return c.OpenAIAPIKey
}
case AIProviderOpenRouter:
if c.OpenRouterAPIKey != "" {
return c.OpenRouterAPIKey
}
case AIProviderDeepSeek:
if c.DeepSeekAPIKey != "" {
return c.DeepSeekAPIKey
}
case AIProviderGemini:
if c.GeminiAPIKey != "" {
return c.GeminiAPIKey
}
case AIProviderZai:
if c.ZaiAPIKey != "" {
return c.ZaiAPIKey
}
case AIProviderGroq:
if c.GroqAPIKey != "" {
return c.GroqAPIKey
}
case AIProviderMistral:
if c.MistralAPIKey != "" {
return c.MistralAPIKey
}
case AIProviderCerebras:
if c.CerebrasAPIKey != "" {
return c.CerebrasAPIKey
}
case AIProviderTogether:
if c.TogetherAPIKey != "" {
return c.TogetherAPIKey
}
case AIProviderFireworks:
if c.FireworksAPIKey != "" {
return c.FireworksAPIKey
}
}
return ""
}
// GetBaseURLForProvider returns the base URL for the specified provider
func (c *AIConfig) GetBaseURLForProvider(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
switch provider {
case AIProviderOllama:
if c != nil && c.OllamaBaseURL != "" {
return c.OllamaBaseURL
}
case AIProviderOpenAI:
if c != nil && c.OpenAIBaseURL != "" {
return c.OpenAIBaseURL
}
case AIProviderZai:
if c != nil && c.ZaiBaseURL != "" {
return c.ZaiBaseURL
}
}
if def, ok := LookupAIProviderDefinition(provider); ok {
return def.DefaultBaseURL
}
return ""
}
// IsUsingOAuth is retained for legacy config introspection. OAuth is not a
// supported runtime authentication method for Anthropic provider execution.
func (c *AIConfig) IsUsingOAuth() bool {
return false
}
// ParseModelString parses a model string in "provider:model-name" format
// Returns the provider and model name. If no provider prefix, attempts to detect.
func ParseModelString(model string) (provider, modelName string) {
model = strings.TrimSpace(model)
// Check for explicit provider prefix
for _, def := range AIProviderDefinitions() {
p := def.ID
prefix := p + ":"
if len(model) > len(prefix) && model[:len(prefix)] == prefix {
return p, model[len(prefix):]
}
}
// No prefix - try to detect from model name patterns
switch {
case len(model) >= 6 && model[:6] == "claude":
return AIProviderAnthropic, model
case len(model) >= 3 && (model[:3] == "gpt" || model[:2] == "o1" || model[:2] == "o3" || model[:2] == "o4"):
return AIProviderOpenAI, model
case len(model) >= 8 && model[:8] == "deepseek":
return AIProviderDeepSeek, model
case len(model) >= 6 && model[:6] == "gemini":
return AIProviderGemini, model
case strings.HasPrefix(model, "openai/"), strings.HasPrefix(model, "anthropic/"), strings.HasPrefix(model, "google/"),
strings.HasPrefix(model, "deepseek/"), strings.HasPrefix(model, "meta-llama/"), strings.HasPrefix(model, "mistralai/"),
strings.HasPrefix(model, "x-ai/"), strings.HasPrefix(model, "xai/"), strings.HasPrefix(model, "cohere/"),
strings.HasPrefix(model, "qwen/"):
return AIProviderOpenRouter, model
default:
// Assume Ollama for unrecognized models (local models have varied names)
return AIProviderOllama, model
}
}
// FormatModelString creates a "provider:model-name" format string
func FormatModelString(provider, modelName string) string {
return provider + ":" + modelName
}
// NormalizeQuickstartModelString retires legacy Pulse-hosted quickstart model
// strings. Self-hosted v6 GA requires BYOK or a local model for AI runtime use.
func NormalizeQuickstartModelString(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if strings.EqualFold(model, DefaultAIModelQuickstart) || strings.EqualFold(model, AIProviderQuickstart) {
return ""
}
provider, _ := ParseModelString(model)
if provider == AIProviderQuickstart {
return ""
}
return model
}
// DefaultModelForProvider returns the default "provider:model" string for a given provider name.
// Returns empty string if the provider is unknown.
func DefaultModelForProvider(provider string) string {
def, ok := LookupAIProviderDefinition(provider)
if !ok || def.DefaultModel == "" || def.Protocol == AIProviderProtocolRetired {
return ""
}
return FormatModelString(def.ID, def.DefaultModel)
}
// GetModel returns the explicitly configured model, if any.
func (c *AIConfig) GetModel() string {
if c == nil {
return ""
}
return NormalizeQuickstartModelString(c.Model)
}
// NormalizeQuickstartModelAliases removes retired quickstart model strings in-place.
// Self-hosted v6 GA does not silently fall back to Pulse-hosted AI.
func (c *AIConfig) NormalizeQuickstartModelAliases() bool {
if c == nil {
return false
}
changed := false
normalizeField := func(field *string) {
normalized := NormalizeQuickstartModelString(*field)
if normalized == strings.TrimSpace(*field) {
return
}
*field = normalized
changed = true
}
normalizeField(&c.Model)
normalizeField(&c.ChatModel)
normalizeField(&c.PatrolModel)
normalizeField(&c.DiscoveryModel)
normalizeField(&c.AutoFixModel)
return changed
}
// GetPreferredModelForProvider returns the most relevant configured model for a provider.
// It prefers explicitly selected models for that provider and does not invent a model
// for retired providers.
func (c *AIConfig) GetPreferredModelForProvider(provider string) string {
for _, candidate := range []string{c.Model, c.ChatModel, c.PatrolModel, c.AutoFixModel, c.DiscoveryModel} {
candidate = NormalizeQuickstartModelString(candidate)
if candidate == "" {
continue
}
candidateProvider, _ := ParseModelString(candidate)
if candidateProvider == provider {
return candidate
}
}
return ""
}
// GetChatModel returns the model for interactive chat conversations
// Falls back to the main Model if ChatModel is not set
func (c *AIConfig) GetChatModel() string {
if c.ChatModel != "" {
return NormalizeQuickstartModelString(c.ChatModel)
}
return c.GetModel()
}
// GetPatrolModel returns the model for background patrol analysis
// Falls back to the main Model if PatrolModel is not set
func (c *AIConfig) GetPatrolModel() string {
if c.PatrolModel != "" {
return NormalizeQuickstartModelString(c.PatrolModel)
}
return c.GetModel()
}
// GetDiscoveryModel returns the model for infrastructure discovery
// Falls back to PatrolModel, then to the main Model if DiscoveryModel is not set
// Discovery is high-fan-out background work (one call per container/service),
// so it belongs on the operator's background-work model, not the shared default
func (c *AIConfig) GetDiscoveryModel() string {
if c.DiscoveryModel != "" {
return NormalizeQuickstartModelString(c.DiscoveryModel)
}
return c.GetPatrolModel()
}
// GetAutoFixModel returns the model for automatic remediation actions
// Falls back to PatrolModel, then to the main Model if AutoFixModel is not set
// Auto-fix may warrant a more capable model since it takes actions
func (c *AIConfig) GetAutoFixModel() string {
if c.AutoFixModel != "" {
return NormalizeQuickstartModelString(c.AutoFixModel)
}
return c.GetPatrolModel()
}
// ClearOAuthTokens clears legacy OAuth tokens.
func (c *AIConfig) ClearOAuthTokens() {
c.OAuthAccessToken = ""
c.OAuthRefreshToken = ""
c.OAuthExpiresAt = time.Time{}
}
// ClearAPIKey clears the Anthropic API key.
func (c *AIConfig) ClearAPIKey() {
c.AnthropicAPIKey = ""
}
// GetPatrolInterval returns the patrol interval as a duration.
func (c *AIConfig) GetPatrolInterval() time.Duration {
// Use configured custom minutes when set.
if c.PatrolIntervalMinutes > 0 {
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
}
return 6 * time.Hour // default to 6 hours
}
// IsPatrolEnabled returns true if patrol should run
// Note: Patrol uses local heuristics and doesn't require an AI API key,
// but still requires AI to be enabled as a master switch
func (c *AIConfig) IsPatrolEnabled() bool {
// If AI is disabled globally, patrol is disabled
if !c.Enabled {
return false
}
return c.PatrolEnabled
}
// IsAlertTriggeredAnalysisEnabled returns true if AI should analyze resources when alerts fire
func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool {
// Requires AI to be enabled as a master switch
if !c.Enabled {
return false
}
return c.AlertTriggeredAnalysis
}
func (c *AIConfig) patrolEventTriggerPreferences() (bool, bool) {
if c == nil {
return false, false
}
alertEnabled := c.PatrolAlertTriggersEnabled
anomalyEnabled := c.PatrolAnomalyTriggersEnabled
// Compatibility: older callers and persisted configs may still use only the
// legacy aggregate flag. When neither granular preference is enabled but the
// legacy flag is on, treat both trigger sources as enabled.
if !alertEnabled && !anomalyEnabled && c.PatrolEventTriggersEnabled {
return true, true
}
return alertEnabled, anomalyEnabled
}
// GetPatrolEventTriggerSettings returns the persisted scoped patrol trigger preferences
// without applying the AI master-switch gating used by runtime checks.
func (c *AIConfig) GetPatrolEventTriggerSettings() PatrolEventTriggerSettings {
alertEnabled, anomalyEnabled := c.patrolEventTriggerPreferences()
return PatrolEventTriggerSettings{
AlertTriggersEnabled: alertEnabled,
AnomalyTriggersEnabled: anomalyEnabled,
}
}
// NormalizePatrolEventTriggerSettings synchronizes the legacy aggregate field with the
// canonical split trigger preferences.
func (c *AIConfig) NormalizePatrolEventTriggerSettings() bool {
if c == nil {
return false
}
settings := c.GetPatrolEventTriggerSettings()
changed := false
if c.PatrolAlertTriggersEnabled != settings.AlertTriggersEnabled {
c.PatrolAlertTriggersEnabled = settings.AlertTriggersEnabled
changed = true
}
if c.PatrolAnomalyTriggersEnabled != settings.AnomalyTriggersEnabled {
c.PatrolAnomalyTriggersEnabled = settings.AnomalyTriggersEnabled
changed = true
}
aggregateEnabled := settings.AlertTriggersEnabled || settings.AnomalyTriggersEnabled
if c.PatrolEventTriggersEnabled != aggregateEnabled {
c.PatrolEventTriggersEnabled = aggregateEnabled
changed = true
}
return changed
}
// SetPatrolEventTriggersEnabled updates both scoped patrol trigger sources together.
func (c *AIConfig) SetPatrolEventTriggersEnabled(enabled bool) {
if c == nil {
return
}
c.PatrolAlertTriggersEnabled = enabled
c.PatrolAnomalyTriggersEnabled = enabled
c.PatrolEventTriggersEnabled = enabled
}
// SetPatrolEventTriggerSettings updates the canonical scoped patrol trigger preferences.
func (c *AIConfig) SetPatrolEventTriggerSettings(alertEnabled, anomalyEnabled bool) {
if c == nil {
return
}
c.PatrolAlertTriggersEnabled = alertEnabled
c.PatrolAnomalyTriggersEnabled = anomalyEnabled
c.PatrolEventTriggersEnabled = alertEnabled || anomalyEnabled
}
// IsPatrolEventTriggersEnabled returns true if event-driven patrol triggers (alerts, anomalies) are enabled
func (c *AIConfig) IsPatrolEventTriggersEnabled() bool {
if !c.Enabled {
return false
}
settings := c.GetPatrolEventTriggerSettings()
return settings.AlertTriggersEnabled || settings.AnomalyTriggersEnabled
}
// IsPatrolAlertTriggersEnabled returns true if alert-triggered scoped patrols are enabled.
func (c *AIConfig) IsPatrolAlertTriggersEnabled() bool {
if !c.Enabled {
return false
}
return c.GetPatrolEventTriggerSettings().AlertTriggersEnabled
}
// IsPatrolAnomalyTriggersEnabled returns true if anomaly-triggered scoped patrols are enabled.
func (c *AIConfig) IsPatrolAnomalyTriggersEnabled() bool {
if !c.Enabled {
return false
}
return c.GetPatrolEventTriggerSettings().AnomalyTriggersEnabled
}
// GetRequestTimeout returns the timeout duration for AI requests
// Default is 5 minutes (300 seconds) if not configured
func (c *AIConfig) GetRequestTimeout() time.Duration {
if c.RequestTimeoutSeconds > 0 {
return time.Duration(c.RequestTimeoutSeconds) * time.Second
}
return 300 * time.Second // 5 minutes default
}
// GetControlLevel returns the AI control level, defaulting to read_only if not set.
func (c *AIConfig) GetControlLevel() string {
if c.ControlLevel == "" {
return ControlLevelReadOnly
}
return string(agentcapabilities.NormalizeControlLevel(c.ControlLevel))
}
// EffectiveControlLevelForEntitlement returns the control level that may be
// enforced for the current entitlement state. Stored autonomous preferences are
// preserved in config, but without the autonomous entitlement they run as
// controlled approval mode.
func EffectiveControlLevelForEntitlement(level string, autonomousAllowed bool) string {
cfg := AIConfig{ControlLevel: level}
normalized := cfg.GetControlLevel()
if normalized == ControlLevelAutonomous && !autonomousAllowed {
return ControlLevelControlled
}
return normalized
}
// GetEffectiveControlLevel returns the AI control level that should be exposed
// or enforced for the current entitlement state.
func (c *AIConfig) GetEffectiveControlLevel(autonomousAllowed bool) string {
if c == nil {
return ControlLevelReadOnly
}
return EffectiveControlLevelForEntitlement(c.GetControlLevel(), autonomousAllowed)
}
// IsControlEnabled returns true if AI has any control capability beyond read-only
func (c *AIConfig) IsControlEnabled() bool {
return agentcapabilities.ControlLevelAllowsControlTools(agentcapabilities.ControlLevel(c.GetControlLevel()))
}
// IsAutonomous returns true if AI is configured for autonomous operation (no approval needed)
func (c *AIConfig) IsAutonomous() bool {
return c.GetControlLevel() == ControlLevelAutonomous
}
// IsValidControlLevel checks if a control level string is valid
func IsValidControlLevel(level string) bool {
return agentcapabilities.IsValidControlLevel(level)
}
// GetProtectedGuests returns the list of protected guests (VMIDs or names)
func (c *AIConfig) GetProtectedGuests() []string {
if c.ProtectedGuests == nil {
return []string{}
}
return c.ProtectedGuests
}
// GetPatrolAutonomyLevel returns the patrol autonomy level, defaulting to "monitor" if not set
func (c *AIConfig) GetPatrolAutonomyLevel() string {
if c.PatrolAutonomyLevel == "" {
return PatrolAutonomyMonitor
}
switch c.PatrolAutonomyLevel {
case PatrolAutonomyMonitor, PatrolAutonomyApproval, PatrolAutonomyAssisted, PatrolAutonomyFull:
return c.PatrolAutonomyLevel
// Migration: treat old "autonomous" as new "full"
case "autonomous":
return PatrolAutonomyFull
default:
return PatrolAutonomyMonitor
}
}
// GetEffectivePatrolAutonomy evaluates the requested Patrol mode against the
// server-owned Autopilot acknowledgement and activation evidence. The legacy
// PatrolFullModeUnlocked boolean is intentionally passed only so the evaluator
// can report that it was ignored; it can never authorize full mode.
func (c *AIConfig) GetEffectivePatrolAutonomy(orgID string, now time.Time) (string, unifiedresources.PatrolAutopilotStatus) {
return c.GetEffectivePatrolAutonomyWithPolicy(orgID, unifiedresources.CurrentPatrolAutopilotServerPolicy(now))
}
func (c *AIConfig) GetEffectivePatrolAutonomyWithPolicy(orgID string, policy unifiedresources.PatrolAutopilotServerPolicy) (string, unifiedresources.PatrolAutopilotStatus) {
if c == nil {
contract, _ := unifiedresources.PatrolAutopilotContractForVersion(policy.CurrentVersion)
return PatrolAutonomyMonitor, unifiedresources.PatrolAutopilotStatus{
Code: unifiedresources.PatrolAutopilotStatusAcknowledgementRequired,
CurrentVersion: policy.CurrentVersion,
AcceptedScope: contract.AcceptedScope,
AcceptedLimits: contract.AcceptedLimits,
}
}
return unifiedresources.EvaluatePatrolAutopilot(
c.GetPatrolAutonomyLevel(),
PatrolAutonomyApproval,
strings.TrimSpace(orgID),
c.PatrolFullModeUnlocked,
c.PatrolAutopilotAcknowledgements,
c.PatrolAutopilotRevocations,
c.PatrolAutopilotActivation,
policy,
)
}
func (c *AIConfig) IsPatrolFullModeActive(orgID string, now time.Time) bool {
level, status := c.GetEffectivePatrolAutonomy(orgID, now)
return level == PatrolAutonomyFull && status.Active
}
// GetPatrolInvestigationBudget returns the maximum evidence calls per investigation.
func (c *AIConfig) GetPatrolInvestigationBudget() int {
if c.PatrolInvestigationBudget <= 0 {
return DefaultPatrolInvestigationBudget
}
// Clamp to reasonable range (5-30)
if c.PatrolInvestigationBudget < 5 {
return 5
}
if c.PatrolInvestigationBudget > 30 {
return 30
}
return c.PatrolInvestigationBudget
}
// GetPatrolInvestigationTimeout returns the investigation timeout as a duration
func (c *AIConfig) GetPatrolInvestigationTimeout() time.Duration {
if c.PatrolInvestigationTimeoutSec <= 0 {
return time.Duration(DefaultPatrolInvestigationTimeoutSec) * time.Second
}
// Clamp to reasonable range (60-1800 seconds / 30 minutes)
if c.PatrolInvestigationTimeoutSec < 60 {
return 60 * time.Second
}
if c.PatrolInvestigationTimeoutSec > 1800 {
return 1800 * time.Second
}
return time.Duration(c.PatrolInvestigationTimeoutSec) * time.Second
}
// IsValidPatrolAutonomyLevel checks if a patrol autonomy level string is valid
func IsValidPatrolAutonomyLevel(level string) bool {
switch level {
case PatrolAutonomyMonitor, PatrolAutonomyApproval, PatrolAutonomyAssisted, PatrolAutonomyFull:
return true
default:
return false
}
}
// IsPatrolAutonomyEnabled returns true if patrol has any autonomy beyond monitor mode
func (c *AIConfig) IsPatrolAutonomyEnabled() bool {
level := c.GetPatrolAutonomyLevel()
return level != PatrolAutonomyMonitor
}
// IsDiscoveryEnabled returns whether AI-powered infrastructure discovery is enabled
func (c *AIConfig) IsDiscoveryEnabled() bool {
return c.DiscoveryEnabled
}
// GetDiscoveryInterval returns the interval between automatic discovery scans
// Returns 0 if discovery is manual-only
func (c *AIConfig) GetDiscoveryInterval() time.Duration {
if c.DiscoveryIntervalHours <= 0 {
return 0 // Manual only
}
return time.Duration(c.DiscoveryIntervalHours) * time.Hour
}