Files
pulse/internal/config/ai.go
T
2026-07-24 12:35:31 +01:00

1130 lines
43 KiB
Go

package config
import (
"fmt"
"math"
"net/url"
"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 is intentionally empty. Omitting keep_alive lets
// each Ollama server apply its own operator-configured default. Pulse only
// overrides that policy when the operator saves an explicit value.
DefaultOllamaKeepAlive = ""
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,
// Empty inherits the Ollama server's keep_alive policy.
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. Empty, including
// the default for a nil config, means callers 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:
// A custom OpenAI-compatible endpoint may be intentionally keyless
// (for example llama.cpp, LocalAI, or LM Studio). The official OpenAI
// endpoint still requires a key.
return strings.TrimSpace(c.OpenAIAPIKey) != "" || IsCustomOpenAICompatibleEndpoint(c.OpenAIBaseURL)
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
}
}
// ProviderRequiresAPIKey reports whether the selected provider route requires
// an API key. OpenAI-compatible custom endpoints may be keyless; every hosted
// provider route keeps the registry's credential requirement.
func (c *AIConfig) ProviderRequiresAPIKey(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
def, ok := LookupAIProviderDefinition(provider)
if !ok || !def.RequiresAPIKey {
return false
}
if provider == AIProviderOpenAI && c != nil && IsCustomOpenAICompatibleEndpoint(c.OpenAIBaseURL) {
return false
}
return true
}
// IsCustomOpenAICompatibleEndpoint distinguishes an operator-supplied
// compatible server from an explicitly saved official OpenAI URL. Saving the
// official host must never turn OpenAI into a keyless provider.
func IsCustomOpenAICompatibleEndpoint(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Hostname() == "" {
return false
}
host := strings.TrimSuffix(parsed.Hostname(), ".")
return !strings.EqualFold(host, "api.openai.com")
}
// RemoveProvider deletes all provider-owned configuration and any model
// selections routed through that provider. It is deliberately distinct from
// the legacy clear-key fields, which only rotate one credential.
func (c *AIConfig) RemoveProvider(provider string) error {
if c == nil {
return fmt.Errorf("Pulse Assistant config is nil")
}
provider = strings.ToLower(strings.TrimSpace(provider))
def, ok := LookupAIProviderDefinition(provider)
if !ok || !def.UserConfigurable {
return fmt.Errorf("unknown provider %q", provider)
}
switch provider {
case AIProviderAnthropic:
c.AnthropicAPIKey = ""
c.ClearOAuthTokens()
case AIProviderOpenAI:
c.OpenAIAPIKey = ""
c.OpenAIBaseURL = ""
case AIProviderOpenRouter:
c.OpenRouterAPIKey = ""
case AIProviderDeepSeek:
c.DeepSeekAPIKey = ""
case AIProviderGemini:
c.GeminiAPIKey = ""
case AIProviderZai:
c.ZaiAPIKey = ""
c.ZaiBaseURL = ""
case AIProviderGroq:
c.GroqAPIKey = ""
case AIProviderMistral:
c.MistralAPIKey = ""
case AIProviderCerebras:
c.CerebrasAPIKey = ""
case AIProviderTogether:
c.TogetherAPIKey = ""
case AIProviderFireworks:
c.FireworksAPIKey = ""
case AIProviderOllama:
c.OllamaBaseURL = ""
c.OllamaUsername = ""
c.OllamaPassword = ""
c.OllamaKeepAlive = DefaultOllamaKeepAlive
case AIProviderCodexSubscription:
c.CodexSubscriptionEnabled = false
case AIProviderClaudeSubscription:
c.ClaudeSubscriptionEnabled = false
}
clearModel := func(model *string) {
if strings.TrimSpace(*model) == "" {
return
}
modelProvider, _ := ParseModelString(*model)
if modelProvider == provider {
*model = ""
}
}
clearModel(&c.Model)
clearModel(&c.ChatModel)
clearModel(&c.PatrolModel)
clearModel(&c.DiscoveryModel)
clearModel(&c.AutoFixModel)
if len(c.GetConfiguredProviders()) == 0 {
c.Enabled = false
}
return nil
}
// 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
}