"""The assistant's provider: where the model is, and the key that opens it. One file on the data volume beside ``cloud.json``, for the same reasons: it is a credential, it must survive a rebuild, and it must never be in the flows repository. The key is encrypted with the cipher the secrets store uses, so both turn unreadable together when ``SECRET_KEY`` changes. Nothing here exists until a superuser writes it, and its absence is what keeps every AI surface off. """ from __future__ import annotations import json import logging import os from dataclasses import asdict, dataclass from typing import Any from cryptography.fernet import InvalidToken from fluksio.core.config import settings from fluksio.flow.secrets import fernet logger = logging.getLogger(__name__) @dataclass(frozen=True) class AIConfig: #: An OpenAI-compatible base, e.g. ``https://api.openai.com/v1``. api_base: str api_key: str model: str #: How many model-and-tools rounds one turn may take before it is stopped. max_rounds: int = 12 @property def ready(self) -> bool: return bool(self.api_base and self.api_key and self.model) def public(self) -> dict[str, Any]: """The secret-free view the settings screen gets.""" key = self.api_key masked = None if key: masked = f"{key[:3]}…{key[-4:]}" if len(key) > 8 else "••••" return { "api_base": self.api_base, "model": self.model, "max_rounds": self.max_rounds, "api_key_set": bool(key), "api_key_masked": masked, } def load() -> AIConfig | None: path = settings.AI_CONFIG_FILE try: data = json.loads(path.read_text()) except OSError: return None except ValueError: logger.exception("Could not read %s; the assistant stays off", path) return None sealed = data.pop("api_key_enc", "") key = "" if sealed: try: key = fernet(settings.SECRET_KEY).decrypt(sealed.encode()).decode() except InvalidToken: logger.error( "%s was written with a different SECRET_KEY; enter the key again", path, ) try: return AIConfig(api_key=key, **data) except TypeError: logger.exception("Could not read %s; the assistant stays off", path) return None def save(config: AIConfig) -> None: path = settings.AI_CONFIG_FILE path.parent.mkdir(parents=True, exist_ok=True) data = asdict(config) key = data.pop("api_key") data["api_key_enc"] = ( fernet(settings.SECRET_KEY).encrypt(key.encode()).decode() if key else "" ) # Written through a temporary file so a crash mid-write cannot leave a # half-parsed credential behind, and never group- or world-readable. tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2)) os.chmod(tmp, 0o600) tmp.replace(path) def delete() -> None: settings.AI_CONFIG_FILE.unlink(missing_ok=True)