""" Per-service runtime config helpers. Config files live on the shared `app_config` Docker volume at /config/. Each service has its own JSON file. Atomic write pattern: write to .tmp in same dir, then os.replace() so services never read a partial file. """ import copy import json import os from pathlib import Path from pydantic import BaseModel _CONFIG_DIR = Path(os.environ.get("APP_CONFIG_DIR", "/config")) # ── AI service config schemas ────────────────────────────────────────────────── class AnthropicConfig(BaseModel): api_key: str = "" model: str = "claude-haiku-4-5-20251001" class OllamaConfig(BaseModel): base_url: str = "http://host.docker.internal:11434/v1" model: str = "llama3.2" api_key: str = "ollama" class LMStudioConfig(BaseModel): base_url: str = "http://host.docker.internal:1234/v1" model: str = "local-model" api_key: str = "lm-studio" class AIServiceConfig(BaseModel): provider: str = "lmstudio" timeout_seconds: int = 60 max_retries: int = 2 anthropic: AnthropicConfig = AnthropicConfig() ollama: OllamaConfig = OllamaConfig() lmstudio: LMStudioConfig = LMStudioConfig() # ── Doc service config schemas ───────────────────────────────────────────────── class DocumentsConfig(BaseModel): max_pdf_bytes: int = 20 * 1024 * 1024 class DocServiceConfig(BaseModel): documents: DocumentsConfig = DocumentsConfig() # ── Masking ──────────────────────────────────────────────────────────────────── def _mask_key(key: str) -> str: if not key or len(key) <= 8: return "••••" return key[:7] + "••••" def _mask_ai_config(data: dict) -> dict: masked = copy.deepcopy(data) for provider in ("anthropic", "ollama", "lmstudio"): if provider in masked and "api_key" in masked[provider]: masked[provider]["api_key"] = _mask_key(masked[provider]["api_key"]) return masked # ── Load / Save ──────────────────────────────────────────────────────────────── def _config_path(service: str) -> Path: return _CONFIG_DIR / f"{service}_config.json" def load_service_config(service: str) -> dict: path = _config_path(service) if not path.exists(): if service == "ai_service": return AIServiceConfig().model_dump() if service == "doc_service": return DocServiceConfig().model_dump() return {} with path.open() as f: return json.load(f) def save_service_config(service: str, data: dict) -> None: path = _config_path(service) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2)) os.replace(tmp, path) # AI service helpers def load_ai_service_config() -> AIServiceConfig: raw = load_service_config("ai_service") return AIServiceConfig.model_validate(raw) def save_ai_service_config(config: AIServiceConfig) -> None: save_service_config("ai_service", config.model_dump()) def load_ai_service_config_masked() -> dict: raw = load_service_config("ai_service") return _mask_ai_config(raw) # Doc service helpers def load_doc_service_config() -> DocServiceConfig: raw = load_service_config("doc_service") return DocServiceConfig.model_validate(raw) def save_doc_service_config(config: DocServiceConfig) -> None: save_service_config("doc_service", config.model_dump()) def load_doc_service_config_masked() -> dict: return load_service_config("doc_service") def _merge_api_key(new_key: str, existing_key: str) -> str: """If new_key is empty or a masked value, keep the existing key.""" if not new_key or "••••" in new_key: return existing_key return new_key