"""OSINT Dashboard — non-secret app settings (keyv-style Postgres table). Model choice lives here so the summarizer container can read it from Postgres. Only whitelisted names are stored — this is not a generic dump. Storage: the table is created lazily with ``CREATE TABLE IF NOT EXISTS`` on first use in each process (same bootstrap pattern as ``api_keys``). """ from __future__ import annotations import asyncio import os import time from datetime import datetime, timezone import httpx from sqlalchemy import Column, DateTime, String, Table, Text, func, select, text import keystore from database import async_session, engine, metadata DEFAULT_NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1" DEFAULT_SUMMARY_MODEL = "Hermes-4.3-36B" SETTING_SUMMARY_MODEL = "SUMMARY_MODEL" ALLOWED_SETTINGS = frozenset({SETTING_SUMMARY_MODEL}) MODELS_CACHE_TTL_S = 600.0 MODELS_TIMEOUT_S = 8.0 DEFAULT_MODELS_USER_AGENT = "osint-dashboard-news-summarizer" FALLBACK_MODELS = [ "Hermes-4.3-36B", "Hermes-4-70B", "google/gemini-2.5-flash", "anthropic/claude-haiku-4.5", "openai/gpt-4.1-mini", "x-ai/grok-4", ] app_settings = Table( "app_settings", metadata, Column("name", String(128), primary_key=True), Column("value", Text, nullable=False), Column("updated_at", DateTime(timezone=True), server_default=func.now(), nullable=False), ) _CREATE_TABLE_SQL = text( """ CREATE TABLE IF NOT EXISTS app_settings ( name VARCHAR(128) PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) _ensure_lock = asyncio.Lock() _ensured = False _models_cache: tuple[float, dict] | None = None class SettingsError(ValueError): """Raised when a setting name or value fails validation.""" async def ensure_app_settings_table() -> None: """Create the app_settings table if it doesn't exist (idempotent, per process).""" global _ensured if _ensured: return async with _ensure_lock: if _ensured: return async with engine.begin() as conn: await conn.execute(_CREATE_TABLE_SQL) _ensured = True def nous_base_url() -> str: """Read-only Nous inference base URL (env, never writable from the UI).""" raw = (os.getenv("NOUS_BASE_URL") or "").strip().rstrip("/") return raw or DEFAULT_NOUS_BASE_URL def _validate_summary_model(value: str) -> str: stripped = (value or "").strip() if not stripped or len(stripped) > 128: raise SettingsError("summary_model must be 1–128 chars, not whitespace-only") return stripped async def get_summary_model() -> str: """Stored SUMMARY_MODEL, else env, else Hermes-4.3-36B.""" await ensure_app_settings_table() async with async_session() as session: row = ( await session.execute( select(app_settings).where(app_settings.c.name == SETTING_SUMMARY_MODEL) ) ).mappings().one_or_none() if row and row["value"]: return row["value"] return os.getenv("SUMMARY_MODEL", DEFAULT_SUMMARY_MODEL) async def set_summary_model(value: str) -> dict: """Upsert SUMMARY_MODEL and return the public settings payload.""" value = _validate_summary_model(value) now = datetime.now(timezone.utc) await ensure_app_settings_table() async with async_session() as session: existing = ( await session.execute( select(app_settings).where(app_settings.c.name == SETTING_SUMMARY_MODEL) ) ).mappings().one_or_none() if existing: await session.execute( app_settings.update() .where(app_settings.c.name == SETTING_SUMMARY_MODEL) .values(value=value, updated_at=now) ) else: await session.execute( app_settings.insert().values( name=SETTING_SUMMARY_MODEL, value=value, updated_at=now ) ) await session.commit() return await get_app_settings() async def get_app_settings() -> dict: return { "summary_model": await get_summary_model(), "nous_base_url": nous_base_url(), } def _fallback_payload() -> dict: return { "source": "fallback", "models": [{"id": mid} for mid in FALLBACK_MODELS], } async def _nous_api_key() -> str | None: """Keystore first, then env. Any lookup failure is treated as missing.""" try: stored = await keystore.get_api_key("NOUS_API_KEY") except Exception: stored = None if stored and str(stored).strip(): return str(stored).strip() env = (os.getenv("NOUS_API_KEY") or "").strip() return env or None def _models_user_agent() -> str: return os.getenv("OSINT_USER_AGENT") or DEFAULT_MODELS_USER_AGENT def _parse_models_payload(body: object) -> list[dict[str, str]]: if isinstance(body, dict): raw = body.get("data", body.get("models", [])) elif isinstance(body, list): raw = body else: raw = [] out: list[dict[str, str]] = [] for item in raw or []: if isinstance(item, str) and item.strip(): out.append({"id": item.strip()}) elif isinstance(item, dict): mid = item.get("id") or item.get("name") if mid: out.append({"id": str(mid)}) return out async def _http_get(url: str, *, headers: dict[str, str], timeout: float) -> httpx.Response: async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: return await client.get(url) async def list_models() -> dict: """Live ``GET {base}/models`` when a key is present; otherwise curated fallback. Never raises to the caller for missing key or upstream failure. """ global _models_cache key = await _nous_api_key() if not key: return _fallback_payload() now = time.monotonic() hit = _models_cache if hit and now - hit[0] < MODELS_CACHE_TTL_S: return hit[1] url = f"{nous_base_url()}/models" headers = { "Authorization": f"Bearer {key}", "User-Agent": _models_user_agent(), "Accept": "application/json", } try: resp = await _http_get(url, headers=headers, timeout=MODELS_TIMEOUT_S) resp.raise_for_status() models = _parse_models_payload(resp.json()) if not models: return _fallback_payload() payload = {"source": "live", "models": models} _models_cache = (now, payload) return payload except Exception: return _fallback_payload()