""" Settings management for SageUtils. This module provides a centralized way to manage settings with: - Default values and types - Validation and type checking - Automatic migration of missing settings - Clear documentation of all available settings """ from typing import Any, Dict, Optional from .config_manager import ConfigManager from .settings_crypto import ( decrypt_sensitive_value, encrypt_sensitive_value, is_encrypted_value, is_sensitive_setting_key, ) from .logger import get_logger logger = get_logger('settings') # Define the schema for all SageUtils settings SETTINGS_SCHEMA = { # LLM Integration Settings "enable_lmstudio_rest": { "default": False, "type": bool, "description": "Enable LM Studio REST v1 LLM integration" }, "enable_ollama_rest": { "default": False, "type": bool, "description": "Enable Ollama REST LLM integration (HTTP API, no SDK required)" }, "enable_openai": { "default": False, "type": bool, "description": "Enable OpenAI (or compatible) LLM integration" }, "openai_api_key": { "default": "", "type": str, "description": "API key for OpenAI or compatible endpoint (can also be set via OPENAI_API_KEY env var)" }, "openai_use_custom_url": { "default": False, "type": bool, "description": "Use a custom base URL for the OpenAI-compatible endpoint" }, "openai_base_url": { "default": "", "type": str, "description": "Custom base URL for OpenAI-compatible endpoint (e.g., 'http://localhost:8080' for LocalAI)" }, "ollama_use_custom_url": { "default": False, "type": bool, "description": "Use a custom URL for Ollama instead of the default" }, "ollama_custom_url": { "default": "", "type": str, "description": "Custom URL for Ollama service (e.g., 'http://localhost:11434')" }, "ollama_api_key": { "default": "", "type": str, "description": "API key for Ollama endpoint authentication (can also be set via OLLAMA_API_KEY env var)" }, "lmstudio_use_custom_url": { "default": False, "type": bool, "description": "Use a custom URL for LM Studio instead of the default" }, "lmstudio_custom_url": { "default": "", "type": str, "description": "Custom URL for LM Studio service (e.g., 'http://localhost:1234')" }, "lmstudio_api_token": { "default": "", "type": str, "description": "API token for LM Studio REST endpoint (can also be set via LMSTUDIO_API_TOKEN env var)" }, "default_llm_provider": { "default": "lmstudio_rest", "type": str, "description": "Default LLM provider for the LLM sidebar and provider-switching LLM v3 nodes", "valid_values": ["lmstudio", "ollama", "lmstudio_rest", "ollama_rest", "openai", "native"] }, "llm_raise_node_exceptions": { "default": False, "type": bool, "description": "When enabled, LLM nodes re-raise provider load/generation exceptions after logging" }, # Sidebar Tab Visibility Settings "show_models_tab": { "default": True, "type": bool, "description": "Show Models tab in sidebar" }, "show_files_tab": { "default": True, "type": bool, "description": "Show Files tab in sidebar" }, "show_search_tab": { "default": True, "type": bool, "description": "Show Search (Civitai) tab in sidebar" }, "show_gallery_tab": { "default": True, "type": bool, "description": "Show Gallery tab in sidebar" }, "show_prompts_tab": { "default": True, "type": bool, "description": "Show Prompts (Prompt Builder) tab in sidebar" }, "show_llm_tab": { "default": True, "type": bool, "description": "Show LLM tab in sidebar" } } def is_known_setting(key: str) -> bool: """Return True when the key is defined in SETTINGS_SCHEMA.""" return key in SETTINGS_SCHEMA def get_setting_schema_default(key: str) -> Any: """Get default value for a known setting key.""" return SETTINGS_SCHEMA[key]["default"] class SettingsValidator: """Validates settings values against the schema.""" @staticmethod def _coerce_value(expected_type: type, value: Any) -> Any: """Coerce a value to the expected settings type.""" if expected_type == bool: if isinstance(value, str): return value.lower() in ('true', '1', 'yes', 'on') return bool(value) if expected_type == int: return int(value) if expected_type == float: return float(value) if expected_type == str: return str(value) raise TypeError(f"Cannot convert {type(value)} to {expected_type}") @staticmethod def validate_value(key: str, value: Any, schema_entry: Dict[str, Any]) -> Any: """Validate a single setting value against its schema.""" expected_type = schema_entry.get("type", str) valid_values = schema_entry.get("valid_values") # Type checking if not isinstance(value, expected_type): try: value = SettingsValidator._coerce_value(expected_type, value) except (ValueError, TypeError): logger.warning(f"Setting '{key}': Invalid type. Expected {expected_type.__name__}, got {type(value).__name__}. Using default.") return schema_entry["default"] # Valid values checking if valid_values and value not in valid_values: logger.warning(f"Setting '{key}': Invalid value '{value}'. Must be one of {valid_values}. Using default.") return schema_entry["default"] return value class SageSettings: """Enhanced settings manager for SageUtils with validation and defaults.""" def __init__(self): self._config_manager = ConfigManager("config") self._settings: Dict[str, Any] = {} self._validator = SettingsValidator() self.load_and_validate() @staticmethod def _is_known_setting(key: str) -> bool: """Return True when the key is defined in SETTINGS_SCHEMA.""" return is_known_setting(key) @staticmethod def _schema_default(key: str) -> Any: """Get default value for a known setting key.""" return get_setting_schema_default(key) def load_and_validate(self) -> None: """Load settings from config manager and validate against schema.""" # Load current settings raw_settings = self._config_manager.load() or {} # Decrypt sensitive values for in-memory use. current_settings: Dict[str, Any] = {} settings_updated = False for key, raw_value in raw_settings.items(): current_settings[key] = decrypt_sensitive_value(key, raw_value) # Migrate existing plaintext secrets to encrypted-at-rest values on next save. if ( is_sensitive_setting_key(key) and isinstance(raw_value, str) and raw_value.strip() and not is_encrypted_value(raw_value) ): settings_updated = True logger.info(f"Setting '{key}' will be migrated to encrypted storage.") # Start with defaults and update with current values self._settings = {} for key, schema_entry in SETTINGS_SCHEMA.items(): default_value = schema_entry["default"] if key in current_settings: # Validate existing setting validated_value = self._validator.validate_value( key, current_settings[key], schema_entry ) self._settings[key] = validated_value # Check if validation changed the value if validated_value != current_settings[key]: settings_updated = True logger.info(f"Setting '{key}' corrected to: {validated_value}") else: # Use default for missing settings self._settings[key] = default_value settings_updated = True logger.info(f"Setting '{key}' added with default value: {default_value}") # Remove any settings not in schema (cleanup old/deprecated settings) for key in raw_settings: if key not in SETTINGS_SCHEMA: logger.warning(f"Removing deprecated setting: '{key}'") settings_updated = True # Save if any changes were made if settings_updated: self.save() logger.info("Settings updated and saved.") def get(self, key: str, default: Any = None) -> Any: """Get a setting value with optional default fallback.""" if self._is_known_setting(key): return self._settings.get(key, self._schema_default(key)) return self._settings.get(key, default) def set(self, key: str, value: Any) -> bool: """Set a setting value with validation.""" if not self._is_known_setting(key): logger.warning(f"Setting unknown key '{key}'. Consider adding it to SETTINGS_SCHEMA.") self._settings[key] = value return True schema_entry = SETTINGS_SCHEMA[key] validated_value = self._validator.validate_value(key, value, schema_entry) if self._settings.get(key) != validated_value: self._settings[key] = validated_value logger.info(f"Setting '{key}' updated to: {validated_value}") return True return False def save(self) -> bool: """Save current settings to file.""" try: persisted_settings: Dict[str, Any] = {} for key, value in self._settings.items(): persisted_settings[key] = encrypt_sensitive_value(key, value) self._config_manager.data = persisted_settings return self._config_manager.save() except Exception as e: logger.error(f"Failed to save settings: {e}") return False # Used in settings and server_routes. def reset_to_defaults(self) -> None: """Reset all settings to their default values.""" self._settings = {key: self._schema_default(key) for key in SETTINGS_SCHEMA} self.save() logger.info("All settings reset to defaults.") def get_setting_info(self, key: str) -> Optional[Dict[str, Any]]: """Get information about a setting including description and current value.""" if not self._is_known_setting(key): return None schema_entry = SETTINGS_SCHEMA[key].copy() # Get current value, falling back to default if not set schema_entry["current_value"] = self._settings.get(key, self._schema_default(key)) # Convert type to string representation for JSON serialization if "type" in schema_entry: schema_entry["type"] = schema_entry["type"].__name__ return schema_entry # Used in settings and server_routes. def list_all_settings(self) -> Dict[str, Dict[str, Any]]: """Get information about all settings.""" result = {} for key in SETTINGS_SCHEMA: setting_info = self.get_setting_info(key) if setting_info is not None: # The setting_info is already JSON-safe from get_setting_info() result[key] = setting_info return result def is_feature_enabled(self, feature: str) -> bool: """Check if a feature is enabled. Convenience method for boolean settings.""" return bool(self.get(feature, False)) @property def all_settings(self) -> Dict[str, Any]: """Get a copy of all current settings.""" return self._settings.copy() # Global settings instance _settings_instance: Optional[SageSettings] = None def get_settings() -> SageSettings: """Get the global settings instance, creating it if necessary.""" global _settings_instance if _settings_instance is None: _settings_instance = SageSettings() return _settings_instance def get_setting(key: str, default: Any = None) -> Any: """Convenience function to get a setting value.""" return get_settings().get(key, default) def set_setting(key: str, value: Any) -> bool: """Convenience function to set a setting value.""" return get_settings().set(key, value) def is_feature_enabled(feature: str) -> bool: """Convenience function to check if a feature is enabled.""" return get_settings().is_feature_enabled(feature) def save_settings() -> bool: """Convenience function to save settings.""" return get_settings().save() # Backwards compatibility - provide the same interface as before def get_sage_config() -> Dict[str, Any]: """Get settings in the same format as the old sage_config.""" return get_settings().all_settings # For backwards compatibility, we'll also update the old config_manager module # to use the new settings system def _update_config_manager(): """Update the config_manager module to use the new settings system.""" try: from . import config_manager # Replace the old settings_manager data with our new system config_manager.settings_manager.data = get_settings().all_settings config_manager.sage_config = get_settings().all_settings except ImportError: pass # Module might not be available during import