"""In-memory + JSON-file LLM profile manager. Profiles are kept in a dict keyed by profile_id and written to a JSON file on every mutation, so they survive server restarts. The pattern mirrors TaskManager but without threading concerns beyond a simple lock (profiles are only mutated by API calls in FastAPI request handlers). """ from __future__ import annotations import json import threading import uuid from datetime import datetime, timezone from pathlib import Path from webapp.models import LLMProfile _DEFAULT_STORE = Path(__file__).resolve().parents[2] / "configs" / "llm_profiles.json" def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() class ProfileManager: """Manages LLM profiles with in-memory cache and JSON file persistence.""" def __init__(self, store_path: Path = _DEFAULT_STORE) -> None: self._store_path = store_path self._lock = threading.Lock() self._profiles: dict[str, LLMProfile] = {} self._load() # ------------------------------------------------------------------ # # Public API # ------------------------------------------------------------------ # def list_all(self) -> list[LLMProfile]: """Return all profiles sorted by creation time.""" with self._lock: return sorted(self._profiles.values(), key=lambda p: p.created_at) def get(self, profile_id: str) -> LLMProfile | None: """Return one profile by id, or None if not found.""" with self._lock: return self._profiles.get(profile_id) def create( self, name: str, model: str, base_url: str, api_key: str, timeout_seconds: int = 30, ) -> LLMProfile: """Create and persist a new profile, returning it.""" now = _now_iso() profile = LLMProfile( profile_id=uuid.uuid4().hex[:12], name=name, model=model, base_url=base_url, api_key=api_key, timeout_seconds=timeout_seconds, created_at=now, updated_at=now, ) with self._lock: self._profiles[profile.profile_id] = profile self._persist() return profile def update( self, profile_id: str, name: str, model: str, base_url: str, api_key: str, timeout_seconds: int = 30, ) -> LLMProfile | None: """Update an existing profile in-place; returns None if not found.""" with self._lock: existing = self._profiles.get(profile_id) if existing is None: return None updated = existing.model_copy(update={ "name": name, "model": model, "base_url": base_url, "api_key": api_key, "timeout_seconds": timeout_seconds, "updated_at": _now_iso(), }) self._profiles[profile_id] = updated self._persist() return updated def delete(self, profile_id: str) -> bool: """Remove a profile; returns True if deleted, False if not found.""" with self._lock: if profile_id not in self._profiles: return False del self._profiles[profile_id] self._persist() return True # ------------------------------------------------------------------ # # Persistence helpers # ------------------------------------------------------------------ # def _load(self) -> None: """Load profiles from the JSON store file, ignoring missing/corrupt files.""" if not self._store_path.exists(): return try: data = json.loads(self._store_path.read_text(encoding="utf-8")) for raw in data.get("profiles", []): p = LLMProfile.model_validate(raw) self._profiles[p.profile_id] = p except Exception: # noqa: BLE001 pass # Corrupt store — start fresh def _persist(self) -> None: """Write current profiles to the JSON store file (called under lock).""" self._store_path.parent.mkdir(parents=True, exist_ok=True) payload = {"profiles": [p.model_dump() for p in self._profiles.values()]} self._store_path.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) # Module-level singleton shared by FastAPI routes. profile_manager = ProfileManager()