Previously Ollama always showed as 'Available' even if not set up. Now it only shows as configured when user has entered an OllamaBaseURL.
397 lines
13 KiB
Go
397 lines
13 KiB
Go
package config
|
|
|
|
import "time"
|
|
|
|
// 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 uses OAuth tokens (subscription-based, Pro/Max plans)
|
|
AuthMethodOAuth AuthMethod = "oauth"
|
|
)
|
|
|
|
// AIConfig holds AI feature configuration
|
|
// This is stored in ai.enc (encrypted) in the config directory
|
|
type AIConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Provider string `json:"provider"` // DEPRECATED: legacy single provider field, kept for migration
|
|
APIKey string `json:"api_key"` // DEPRECATED: legacy single API key, kept for migration
|
|
Model string `json:"model"` // Currently selected default model (format: "provider:model-name")
|
|
ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (defaults to Model)
|
|
PatrolModel string `json:"patrol_model,omitempty"` // Model for background patrol (defaults to Model, can be cheaper)
|
|
BaseURL string `json:"base_url"` // DEPRECATED: legacy base URL, kept for migration
|
|
AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval
|
|
CustomContext string `json:"custom_context"` // user-provided context about their infrastructure
|
|
|
|
// 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
|
|
DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key
|
|
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
|
|
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional)
|
|
|
|
// OAuth fields for Claude Pro/Max subscription authentication
|
|
AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or "oauth" (for anthropic only)
|
|
OAuthAccessToken string `json:"oauth_access_token,omitempty"` // OAuth access token (encrypted at rest)
|
|
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // OAuth refresh token (encrypted at rest)
|
|
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time
|
|
|
|
// Patrol settings for background AI monitoring
|
|
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
|
|
PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 360 = 6 hours)
|
|
PatrolSchedulePreset string `json:"patrol_schedule_preset,omitempty"` // User-friendly preset: "15min", "1hr", "6hr", "12hr", "daily", "disabled"
|
|
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol
|
|
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers in patrol
|
|
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol
|
|
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage in patrol
|
|
PatrolAutoFix bool `json:"patrol_auto_fix,omitempty"` // When true, patrol can attempt automatic remediation (default: false, observe only)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// AIProvider constants
|
|
const (
|
|
AIProviderAnthropic = "anthropic"
|
|
AIProviderOpenAI = "openai"
|
|
AIProviderOllama = "ollama"
|
|
AIProviderDeepSeek = "deepseek"
|
|
)
|
|
|
|
// Default models per provider
|
|
const (
|
|
DefaultAIModelAnthropic = "claude-opus-4-5-20251101"
|
|
DefaultAIModelOpenAI = "gpt-4o"
|
|
DefaultAIModelOllama = "llama3"
|
|
DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support
|
|
DefaultOllamaBaseURL = "http://localhost:11434"
|
|
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
|
|
)
|
|
|
|
// ModelInfo represents information about an available model
|
|
// Deprecated: Use providers.ModelInfo instead - models are now fetched dynamically from APIs
|
|
type ModelInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
IsDefault bool `json:"is_default,omitempty"`
|
|
}
|
|
|
|
// GetAvailableModels is deprecated - models are now fetched dynamically from provider APIs
|
|
// This returns nil; use the /api/ai/models endpoint instead which queries the actual API
|
|
func GetAvailableModels(provider string) []ModelInfo {
|
|
return nil
|
|
}
|
|
|
|
// NewDefaultAIConfig returns an AIConfig with sensible defaults
|
|
func NewDefaultAIConfig() *AIConfig {
|
|
return &AIConfig{
|
|
Enabled: false,
|
|
Provider: AIProviderAnthropic,
|
|
Model: DefaultAIModelAnthropic,
|
|
AuthMethod: AuthMethodAPIKey,
|
|
// Patrol defaults - enabled when AI is enabled
|
|
// Default to 6 hour intervals (much more token-efficient than 15 min)
|
|
PatrolEnabled: true,
|
|
PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency
|
|
PatrolSchedulePreset: "6hr",
|
|
PatrolAnalyzeNodes: true,
|
|
PatrolAnalyzeGuests: true,
|
|
PatrolAnalyzeDocker: true,
|
|
PatrolAnalyzeStorage: true,
|
|
// Alert-triggered analysis is highly token-efficient - enabled by default
|
|
AlertTriggeredAnalysis: true,
|
|
}
|
|
}
|
|
|
|
// 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.Enabled {
|
|
return false
|
|
}
|
|
|
|
// Check multi-provider credentials first (new format)
|
|
if c.HasProvider(AIProviderAnthropic) || c.HasProvider(AIProviderOpenAI) ||
|
|
c.HasProvider(AIProviderDeepSeek) || c.HasProvider(AIProviderOllama) {
|
|
return true
|
|
}
|
|
|
|
// Fall back to legacy single-provider check for backward compatibility
|
|
switch c.Provider {
|
|
case AIProviderAnthropic:
|
|
if c.AuthMethod == AuthMethodOAuth {
|
|
return c.OAuthAccessToken != ""
|
|
}
|
|
return c.APIKey != ""
|
|
case AIProviderOpenAI, AIProviderDeepSeek:
|
|
return c.APIKey != ""
|
|
case AIProviderOllama:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// HasProvider returns true if the specified provider has credentials configured
|
|
func (c *AIConfig) HasProvider(provider string) bool {
|
|
switch provider {
|
|
case AIProviderAnthropic:
|
|
// Anthropic can use API key OR OAuth
|
|
if c.AuthMethod == AuthMethodOAuth && c.OAuthAccessToken != "" {
|
|
return true
|
|
}
|
|
return c.AnthropicAPIKey != ""
|
|
case AIProviderOpenAI:
|
|
return c.OpenAIAPIKey != ""
|
|
case AIProviderDeepSeek:
|
|
return c.DeepSeekAPIKey != ""
|
|
case AIProviderOllama:
|
|
// Ollama is only "configured" if user has explicitly set a base URL
|
|
return c.OllamaBaseURL != ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// GetConfiguredProviders returns a list of all providers with credentials configured
|
|
func (c *AIConfig) GetConfiguredProviders() []string {
|
|
var providers []string
|
|
if c.HasProvider(AIProviderAnthropic) {
|
|
providers = append(providers, AIProviderAnthropic)
|
|
}
|
|
if c.HasProvider(AIProviderOpenAI) {
|
|
providers = append(providers, AIProviderOpenAI)
|
|
}
|
|
if c.HasProvider(AIProviderDeepSeek) {
|
|
providers = append(providers, AIProviderDeepSeek)
|
|
}
|
|
if c.HasProvider(AIProviderOllama) {
|
|
providers = append(providers, AIProviderOllama)
|
|
}
|
|
return providers
|
|
}
|
|
|
|
// GetAPIKeyForProvider returns the API key for the specified provider
|
|
func (c *AIConfig) GetAPIKeyForProvider(provider string) string {
|
|
switch provider {
|
|
case AIProviderAnthropic:
|
|
if c.AnthropicAPIKey != "" {
|
|
return c.AnthropicAPIKey
|
|
}
|
|
// Fall back to legacy API key if provider matches
|
|
if c.Provider == AIProviderAnthropic {
|
|
return c.APIKey
|
|
}
|
|
case AIProviderOpenAI:
|
|
if c.OpenAIAPIKey != "" {
|
|
return c.OpenAIAPIKey
|
|
}
|
|
if c.Provider == AIProviderOpenAI {
|
|
return c.APIKey
|
|
}
|
|
case AIProviderDeepSeek:
|
|
if c.DeepSeekAPIKey != "" {
|
|
return c.DeepSeekAPIKey
|
|
}
|
|
if c.Provider == AIProviderDeepSeek {
|
|
return c.APIKey
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetBaseURLForProvider returns the base URL for the specified provider
|
|
func (c *AIConfig) GetBaseURLForProvider(provider string) string {
|
|
switch provider {
|
|
case AIProviderOllama:
|
|
if c.OllamaBaseURL != "" {
|
|
return c.OllamaBaseURL
|
|
}
|
|
// Fall back to legacy BaseURL if provider matches
|
|
if c.Provider == AIProviderOllama && c.BaseURL != "" {
|
|
return c.BaseURL
|
|
}
|
|
return DefaultOllamaBaseURL
|
|
case AIProviderOpenAI:
|
|
if c.OpenAIBaseURL != "" {
|
|
return c.OpenAIBaseURL
|
|
}
|
|
return "" // Uses default OpenAI URL
|
|
case AIProviderDeepSeek:
|
|
return DefaultDeepSeekBaseURL
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsUsingOAuth returns true if OAuth authentication is configured for Anthropic
|
|
func (c *AIConfig) IsUsingOAuth() bool {
|
|
return c.AuthMethod == AuthMethodOAuth && c.OAuthAccessToken != ""
|
|
}
|
|
|
|
// 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) {
|
|
// Check for explicit provider prefix
|
|
for _, p := range []string{AIProviderAnthropic, AIProviderOpenAI, AIProviderDeepSeek, AIProviderOllama} {
|
|
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
|
|
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
|
|
}
|
|
|
|
// GetBaseURL returns the base URL, using defaults where appropriate
|
|
// DEPRECATED: Use GetBaseURLForProvider instead
|
|
func (c *AIConfig) GetBaseURL() string {
|
|
if c.BaseURL != "" {
|
|
return c.BaseURL
|
|
}
|
|
switch c.Provider {
|
|
case AIProviderOllama:
|
|
return DefaultOllamaBaseURL
|
|
case AIProviderDeepSeek:
|
|
return DefaultDeepSeekBaseURL
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetModel returns the model, using defaults where appropriate
|
|
func (c *AIConfig) GetModel() string {
|
|
if c.Model != "" {
|
|
return c.Model
|
|
}
|
|
switch c.Provider {
|
|
case AIProviderAnthropic:
|
|
return DefaultAIModelAnthropic
|
|
case AIProviderOpenAI:
|
|
return DefaultAIModelOpenAI
|
|
case AIProviderOllama:
|
|
return DefaultAIModelOllama
|
|
case AIProviderDeepSeek:
|
|
return DefaultAIModelDeepSeek
|
|
default:
|
|
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 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 c.PatrolModel
|
|
}
|
|
return c.GetModel()
|
|
}
|
|
|
|
// ClearOAuthTokens clears OAuth tokens (used when switching back to API key auth)
|
|
func (c *AIConfig) ClearOAuthTokens() {
|
|
c.OAuthAccessToken = ""
|
|
c.OAuthRefreshToken = ""
|
|
c.OAuthExpiresAt = time.Time{}
|
|
}
|
|
|
|
// ClearAPIKey clears the API key (used when switching to OAuth auth)
|
|
func (c *AIConfig) ClearAPIKey() {
|
|
c.APIKey = ""
|
|
}
|
|
|
|
// GetPatrolInterval returns the patrol interval as a duration
|
|
// Uses the preset if set, otherwise falls back to custom minutes
|
|
func (c *AIConfig) GetPatrolInterval() time.Duration {
|
|
// If preset is set, use it
|
|
if c.PatrolSchedulePreset != "" {
|
|
switch c.PatrolSchedulePreset {
|
|
case "15min":
|
|
return 15 * time.Minute
|
|
case "1hr":
|
|
return 1 * time.Hour
|
|
case "6hr":
|
|
return 6 * time.Hour
|
|
case "12hr":
|
|
return 12 * time.Hour
|
|
case "daily":
|
|
return 24 * time.Hour
|
|
case "disabled":
|
|
return 0 // Signal that scheduled patrol is disabled
|
|
}
|
|
}
|
|
|
|
// Fall back to custom minutes if set
|
|
// BUT: If PatrolIntervalMinutes is the old default (15), migrate to new default (360 = 6hr)
|
|
// This provides better token efficiency for existing installations
|
|
if c.PatrolIntervalMinutes > 0 {
|
|
// Migrate old 15-minute default to new 6-hour default
|
|
if c.PatrolIntervalMinutes == 15 && c.PatrolSchedulePreset == "" {
|
|
return 6 * time.Hour
|
|
}
|
|
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
|
|
}
|
|
|
|
return 6 * time.Hour // default to 6 hours
|
|
}
|
|
|
|
// PresetToMinutes converts a patrol schedule preset to minutes
|
|
func PresetToMinutes(preset string) int {
|
|
switch preset {
|
|
case "15min":
|
|
return 15
|
|
case "1hr":
|
|
return 60
|
|
case "6hr":
|
|
return 360
|
|
case "12hr":
|
|
return 720
|
|
case "daily":
|
|
return 1440
|
|
case "disabled":
|
|
return 0
|
|
default:
|
|
return 360 // default 6hr
|
|
}
|
|
}
|
|
|
|
// IsPatrolEnabled returns true if patrol should run
|
|
// Note: Patrol uses local heuristics and doesn't require an AI API key
|
|
func (c *AIConfig) IsPatrolEnabled() bool {
|
|
// If preset is "disabled", patrol is disabled
|
|
if c.PatrolSchedulePreset == "disabled" {
|
|
return false
|
|
}
|
|
return c.PatrolEnabled
|
|
}
|
|
|
|
// IsAlertTriggeredAnalysisEnabled returns true if AI should analyze resources when alerts fire
|
|
func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool {
|
|
return c.AlertTriggeredAnalysis
|
|
}
|
|
|