"""Paperclip REST API helper. All HTTP calls from legal-ai backend (FastAPI) to Paperclip should go through ``pc_request`` so that auth + audit headers are applied consistently. The bash counterpart for agents lives at ``scripts/pc.sh``. Notes ----- * Uses ``PAPERCLIP_BOARD_API_KEY`` (long-lived) — these are *board* actions (wakeups, comments-as-user) initiated from outside a heartbeat, not agent actions. Board API keys are not JWTs, so they do **not** carry a ``run_id`` claim; pass ``run_id=`` explicitly when you have one (rare for board flows). * For agent actions inside a heartbeat run, agents use the bash helper with the auto-injected ``PAPERCLIP_API_KEY`` JWT — those carry ``run_id`` in claims, so the header is informational/future-proofing. """ from __future__ import annotations import logging import os from typing import Any import httpx logger = logging.getLogger(__name__) PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100") PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "") DEFAULT_TIMEOUT = 15.0 def _build_headers(run_id: str | None, has_body: bool) -> dict[str, str]: if not PAPERCLIP_BOARD_API_KEY: raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot call Paperclip API") headers = { "Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}", "X-Paperclip-Run-Id": run_id or "", } if has_body: headers["Content-Type"] = "application/json" return headers async def pc_request( method: str, path: str, *, json: dict[str, Any] | None = None, run_id: str | None = None, timeout: float = DEFAULT_TIMEOUT, raise_on_error: bool = False, ) -> httpx.Response: """Make a Paperclip REST request. Parameters ---------- method : str HTTP method (GET, POST, PATCH, DELETE). path : str Path relative to PAPERCLIP_API_URL (must start with ``/``). json : dict, optional Request body — sent as JSON. run_id : str, optional Heartbeat run ID for audit trail (X-Paperclip-Run-Id header). Rare for board actions; provide when initiating from inside a run. timeout : float httpx timeout (default 15s). raise_on_error : bool If True, calls ``response.raise_for_status()`` on 4xx/5xx. Returns ------- httpx.Response """ headers = _build_headers(run_id, has_body=json is not None) url = f"{PAPERCLIP_API_URL}{path}" async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.request(method, url, headers=headers, json=json) if raise_on_error: resp.raise_for_status() return resp