"""Service for persisting and loading system settings from the database. Settings are stored in KeyValueSqlModel with key="system_settings". API keys are stored separately with key="api_keys" and encrypted at rest using Fernet if SETTINGS_ENCRYPTION_KEY is set, otherwise stored in plain text. On startup, persisted settings are loaded into os.environ so the rest of the application can read them transparently. """ import os from typing import Optional try: from cryptography.fernet import Fernet HAS_FERNET = True except ImportError: HAS_FERNET = False from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from models.sql.key_value import KeyValueSqlModel SETTINGS_KEY = "system_settings" API_KEYS_KEY = "api_keys" # Env vars that map to settings SETTING_ENV_MAP = { "llm_provider": "LLM", "llm_model": None, # Handled specially per-provider "image_provider": "IMAGE_PROVIDER", } MODEL_ENV_MAP = { "anthropic": "ANTHROPIC_MODEL", "openai": "OPENAI_MODEL", "google": "GOOGLE_MODEL", "ollama": "OLLAMA_MODEL", "custom": "CUSTOM_MODEL", } API_KEY_ENV_MAP = { "anthropic_api_key": "ANTHROPIC_API_KEY", "openai_api_key": "OPENAI_API_KEY", "google_api_key": "GOOGLE_API_KEY", } def _get_fernet(): """Return Fernet cipher if encryption key is configured and cryptography is available.""" if not HAS_FERNET: return None key = os.getenv("SETTINGS_ENCRYPTION_KEY") if key: try: return Fernet(key.encode() if isinstance(key, str) else key) except Exception: pass return None def _encrypt(value: str) -> str: f = _get_fernet() if f: return f.encrypt(value.encode()).decode() return value def _decrypt(value: str) -> str: f = _get_fernet() if f: try: return f.decrypt(value.encode()).decode() except Exception: return value # Fallback: might be plain text from before encryption was enabled return value async def _get_kv(session: AsyncSession, key: str) -> Optional[dict]: """Get a key-value record.""" stmt = select(KeyValueSqlModel).where(KeyValueSqlModel.key == key) result = await session.execute(stmt) row = result.scalar_one_or_none() return row.value if row else None async def _set_kv(session: AsyncSession, key: str, value: dict) -> None: """Upsert a key-value record.""" stmt = select(KeyValueSqlModel).where(KeyValueSqlModel.key == key) result = await session.execute(stmt) row = result.scalar_one_or_none() if row: row.value = value from sqlalchemy.orm.attributes import flag_modified flag_modified(row, "value") else: row = KeyValueSqlModel(key=key, value=value) session.add(row) await session.commit() async def load_settings(session: AsyncSession) -> dict: """Load persisted settings from DB and apply to os.environ. Returns the settings dict. Falls back to env vars if nothing persisted. """ settings = await _get_kv(session, SETTINGS_KEY) or {} api_keys = await _get_kv(session, API_KEYS_KEY) or {} # Apply settings to env if "llm_provider" in settings: os.environ["LLM"] = settings["llm_provider"] if "llm_model" in settings: provider = settings.get("llm_provider", os.getenv("LLM", "anthropic")) env_key = MODEL_ENV_MAP.get(provider) if env_key: os.environ[env_key] = settings["llm_model"] if "image_provider" in settings: os.environ["IMAGE_PROVIDER"] = settings["image_provider"] # Apply API keys (decrypt first) for setting_key, env_key in API_KEY_ENV_MAP.items(): encrypted_val = api_keys.get(setting_key) if encrypted_val: os.environ[env_key] = _decrypt(encrypted_val) return settings async def save_settings(session: AsyncSession, data: dict) -> list: """Persist settings to DB and update os.environ. Handles both regular settings and API keys. Returns list of changed field names. """ changed = [] # Load existing current_settings = await _get_kv(session, SETTINGS_KEY) or {} current_api_keys = await _get_kv(session, API_KEYS_KEY) or {} # Process regular settings for field in ("llm_provider", "llm_model", "image_provider"): if field in data and data[field] is not None: current_settings[field] = data[field] changed.append(field) # Also update env immediately if field == "llm_provider": os.environ["LLM"] = data[field] elif field == "image_provider": os.environ["IMAGE_PROVIDER"] = data[field] elif field == "llm_model": provider = data.get("llm_provider", current_settings.get("llm_provider", os.getenv("LLM", "anthropic"))) env_key = MODEL_ENV_MAP.get(provider) if env_key: os.environ[env_key] = data[field] # Process API keys (encrypt before storing) for setting_key, env_key in API_KEY_ENV_MAP.items(): if setting_key in data and data[setting_key] is not None: current_api_keys[setting_key] = _encrypt(data[setting_key]) os.environ[env_key] = data[setting_key] changed.append(setting_key) # Persist if any(f in changed for f in ("llm_provider", "llm_model", "image_provider")): await _set_kv(session, SETTINGS_KEY, current_settings) if any(f in changed for f in API_KEY_ENV_MAP): await _set_kv(session, API_KEYS_KEY, current_api_keys) return changed