| 1 | """Remote API client for last30days (optional hosted-backend mode). |
| 2 | |
| 3 | When both LAST30DAYS_API_KEY and LAST30DAYS_API_BASE are set, the engine |
| 4 | submits the topic to the configured remote API, polls until the run reaches a |
| 5 | terminal status, streams narration progress to stderr, and renders the |
| 6 | server's report. No local provider keys are required in this mode. The |
| 7 | endpoint comes only from LAST30DAYS_API_BASE - there is no built-in default. |
| 8 | |
| 9 | Contract (API v1): |
| 10 | POST {base}/search Authorization: Bearer <key> |
| 11 | {"query": ..., "depth": "quick"|"default"|"deep", |
| 12 | "register"?: "exec"|"dev"|"creator"|"eli5"} |
| 13 | -> 200 {"search_id": "<uuid>", "status": "running"} |
| 14 | -> 200 clarify payload {"needs_clarification": true, ...} |
| 15 | -> 401 {"error"} / 402 {"error","requires_credits", |
| 16 | "balance","needed"} / 429 {"error"} |
| 17 | GET {base}/search?id=<uuid> same auth header; poll until status is |
| 18 | terminal ("complete" | "error"). Running rows carry |
| 19 | "stderr" (narration + engine lines) and "eta_ms"; |
| 20 | terminal complete rows carry "synthesis_text" and |
| 21 | "raw_markdown" (stderr stripped). |
| 22 | |
| 23 | This module carries ZERO pricing, rate-card, cost, or billing logic. |
| 24 | Balance/credit numbers are only ever displayed verbatim from API responses. |
| 25 | The API key is never printed, logged, or persisted by this module. |
| 26 | """ |
| 27 | |
| 28 | from __future__ import annotations |
| 29 | |
| 30 | import json |
| 31 | import os |
| 32 | import re |
| 33 | import sys |
| 34 | import time |
| 35 | |
| 36 | from . import env, http |
| 37 | from .log import source_log |
| 38 | |
| 39 | # Distinct exit code for the clarify gate so the invoking model can tell |
| 40 | # "re-run with a chosen angle" apart from a plain failure (1). |
| 41 | EXIT_CLARIFY = 3 |
| 42 | |
| 43 | POLL_INITIAL_DELAY = 3.0 |
| 44 | POLL_MAX_DELAY = 10.0 |
| 45 | POLL_TIMEOUT_SECONDS = 15 * 60 |
| 46 | # GET is idempotent: retry a few times across network blips before giving up. |
| 47 | POLL_NETWORK_RETRIES = 3 |
| 48 | # Cadence for the compact elapsed/eta progress line (seconds). |
| 49 | PROGRESS_LINE_INTERVAL = 15.0 |
| 50 | |
| 51 | NARRATE_PREFIX = "[narrate] step=" |
| 52 | TERMINAL_STATUSES = {"complete", "error"} |
| 53 | |
| 54 | |
| 55 | def _err(msg: str) -> None: |
| 56 | source_log("hosted", msg, tty_only=False) |
| 57 | |
| 58 | |
| 59 | def _api_base() -> str: |
| 60 | # Endpoint comes only from the environment - no built-in default. Hosted |
| 61 | # mode is gated on this being set (see last30days.py), so by the time this |
| 62 | # is called it is populated; an empty value means "not configured". |
| 63 | return (os.environ.get("LAST30DAYS_API_BASE") or "").rstrip("/") |
| 64 | |
| 65 | |
| 66 | def _billing_url() -> str: |
| 67 | """Derive a billing link from the configured base, so no URL is hardcoded. |
| 68 | Convention: the base is the API-version root (e.g. ends in /api/v1); drop |
| 69 | that segment and point at the account's billing page.""" |
| 70 | base = _api_base() |
| 71 | root = re.sub(r"/api/v\d+$", "", base) |
| 72 | return f"{root}/dashboard/billing" |
| 73 | |
| 74 | |
| 75 | def _auth_headers() -> dict[str, str]: |
| 76 | # Key is read at call time and placed only in the request header; |
| 77 | # it must never be interpolated into any log or output line. |
| 78 | key = env.read_secret_env("LAST30DAYS_API_KEY") or "" |
| 79 | return {"Authorization": f"Bearer {key}"} |
| 80 | |
| 81 | |
| 82 | def submit(query: str, depth: str, register: str = "default") -> dict: |
| 83 | """POST the search. retries=1: a blind POST retry could double-submit.""" |
| 84 | payload = {"query": query, "depth": depth} |
| 85 | if register != "default": |
| 86 | payload["register"] = register |
| 87 | return http.post( |
| 88 | f"{_api_base()}/search", |
| 89 | json_data=payload, |
| 90 | headers=_auth_headers(), |
| 91 | retries=1, |
| 92 | ) |
| 93 | |
| 94 | |
| 95 | def poll(search_id: str) -> dict: |
| 96 | """GET the search row once. Callers own the retry loop (GET is idempotent).""" |
| 97 | return http.get( |
| 98 | f"{_api_base()}/search", |
| 99 | headers=_auth_headers(), |
| 100 | params={"id": search_id}, |
| 101 | retries=1, |
| 102 | ) |
| 103 | |
| 104 | |
| 105 | def _parse_error_body(exc: http.HTTPError) -> dict: |
| 106 | if not exc.body: |
| 107 | return {} |
| 108 | try: |
| 109 | parsed = json.loads(exc.body) |
| 110 | except (json.JSONDecodeError, TypeError): |
| 111 | return {} |
| 112 | return parsed if isinstance(parsed, dict) else {} |
| 113 | |
| 114 | |
| 115 | def _handle_http_error(exc: http.HTTPError) -> int: |
| 116 | body = _parse_error_body(exc) |
| 117 | if exc.status_code == 401: |
| 118 | _err( |
| 119 | "API key rejected: invalid or revoked. Check " |
| 120 | "LAST30DAYS_API_KEY (and LAST30DAYS_API_BASE), or unset them " |
| 121 | "to fall back to local sources." |
| 122 | ) |
| 123 | return 1 |
| 124 | if exc.status_code == 402: |
| 125 | _err(f"API: {body.get('error') or 'insufficient credits.'}") |
| 126 | if body.get("balance") is not None or body.get("needed") is not None: |
| 127 | _err( |
| 128 | f"Balance: {body.get('balance')} credits. " |
| 129 | f"Needed for this search: {body.get('needed')} credits." |
| 130 | ) |
| 131 | _err(f"Add credits at {_billing_url()}") |
| 132 | return 1 |
| 133 | if exc.status_code == 429: |
| 134 | _err( |
| 135 | f"API rate limit hit: " |
| 136 | f"{body.get('error') or 'too many requests.'} " |
| 137 | "Wait a minute and re-run." |
| 138 | ) |
| 139 | return 1 |
| 140 | _err(f"API request failed: {exc}") |
| 141 | return 1 |
| 142 | |
| 143 | |
| 144 | def _handle_clarify(resp: dict) -> int: |
| 145 | question = resp.get("question") or "The API needs a clarification before searching." |
| 146 | options = resp.get("options") or [] |
| 147 | _err(f"Clarification needed before this search runs: {question}") |
| 148 | for index, option in enumerate(options, 1): |
| 149 | label = option if isinstance(option, str) else json.dumps(option) |
| 150 | sys.stderr.write(f" {index}. {label}\n") |
| 151 | sys.stderr.flush() |
| 152 | _err( |
| 153 | "No search was started. Re-run last30days with the chosen angle " |
| 154 | "folded into the topic text." |
| 155 | ) |
| 156 | return EXIT_CLARIFY |
| 157 | |
| 158 | |
| 159 | def _print_new_narration(stderr_blob: str, seen: set[str]) -> bool: |
| 160 | """Print each '[narrate] step=' line once, verbatim. Returns True if any new.""" |
| 161 | printed = False |
| 162 | for line in stderr_blob.splitlines(): |
| 163 | if line.startswith(NARRATE_PREFIX) and line not in seen: |
| 164 | seen.add(line) |
| 165 | sys.stderr.write(f"{line}\n") |
| 166 | printed = True |
| 167 | if printed: |
| 168 | sys.stderr.flush() |
| 169 | return printed |
| 170 | |
| 171 | |
| 172 | def _print_progress_line(elapsed: float, eta_ms) -> None: |
| 173 | line = f"elapsed {int(elapsed)}s" |
| 174 | if isinstance(eta_ms, (int, float)) and eta_ms > 0: |
| 175 | line += f", eta ~{int(eta_ms / 1000)}s" |
| 176 | _err(line) |
| 177 | |
| 178 | |
| 179 | def _poll_with_retry(search_id: str) -> dict | None: |
| 180 | """Poll once, retrying transient network failures. None means give up |
| 181 | (a user-facing message has already been printed).""" |
| 182 | last_error: http.HTTPError | None = None |
| 183 | for attempt in range(POLL_NETWORK_RETRIES): |
| 184 | try: |
| 185 | return poll(search_id) |
| 186 | except http.HTTPError as exc: |
| 187 | if exc.status_code is not None and 400 <= exc.status_code < 500 and exc.status_code != 429: |
| 188 | _handle_http_error(exc) |
| 189 | return None |
| 190 | # Network blip / timeout / 5xx / 429: GET is idempotent, retry. |
| 191 | last_error = exc |
| 192 | if attempt < POLL_NETWORK_RETRIES - 1: |
| 193 | time.sleep(POLL_INITIAL_DELAY) |
| 194 | _err( |
| 195 | f"API unreachable while polling search {search_id} " |
| 196 | f"after {POLL_NETWORK_RETRIES} attempts: {last_error}" |
| 197 | ) |
| 198 | return None |
| 199 | |
| 200 | |
| 201 | def _slugify(value: str) -> str: |
| 202 | slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") |
| 203 | return slug or "last30days" |
| 204 | |
| 205 | |
| 206 | def _save_output(topic: str, content: str, emit: str, save_dir: str, suffix: str): |
| 207 | """Mirror local save_output() naming: <slug>-raw[-suffix].<ext>.""" |
| 208 | from datetime import datetime |
| 209 | from pathlib import Path |
| 210 | |
| 211 | path = Path(save_dir).expanduser().resolve() |
| 212 | path.mkdir(parents=True, exist_ok=True) |
| 213 | slug = _slugify(topic) |
| 214 | extension = "json" if emit == "json" else "md" |
| 215 | suffix_part = f"-{suffix}" if suffix else "" |
| 216 | base = path / f"{slug}-raw{suffix_part}.{extension}" |
| 217 | date_str = datetime.now().strftime('%Y-%m-%d') |
| 218 | candidates = [base] |
| 219 | candidates.append(path / f"{slug}-raw{suffix_part}-{date_str}.{extension}") |
| 220 | for i in range(1, 100): |
| 221 | candidates.append(path / f"{slug}-raw{suffix_part}-{date_str}-{i}.{extension}") |
| 222 | encoded = content.encode("utf-8") |
| 223 | for candidate in candidates: |
| 224 | try: |
| 225 | fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) |
| 226 | except FileExistsError: |
| 227 | continue |
| 228 | with os.fdopen(fd, "wb") as f: |
| 229 | f.write(encoded) |
| 230 | return candidate |
| 231 | # Fallback: all 101 candidates existed (extremely unlikely). |
| 232 | raise RuntimeError( |
| 233 | f"_save_output: could not find a unique filename after 101 attempts in {path}" |
| 234 | ) |
| 235 | |
| 236 | |
| 237 | def _render_complete(row: dict, topic: str, emit: str, save_dir, save_suffix: str) -> int: |
| 238 | synthesis = row.get("synthesis_text") or "" |
| 239 | raw_markdown = row.get("raw_markdown") or "" |
| 240 | if emit == "json": |
| 241 | payload = { |
| 242 | key: row.get(key) |
| 243 | for key in ("id", "status", "synthesis_text", "raw_markdown") |
| 244 | if key in row |
| 245 | } |
| 246 | rendered = json.dumps(payload, indent=2, sort_keys=True) |
| 247 | save_content = rendered |
| 248 | else: |
| 249 | # The server report is the content source; it already synthesized. |
| 250 | # All markdown-ish emit modes print the synthesis text as-is. |
| 251 | rendered = synthesis or raw_markdown |
| 252 | save_content = raw_markdown or synthesis |
| 253 | if save_dir: |
| 254 | out_path = _save_output(topic, save_content, emit, save_dir, save_suffix) |
| 255 | sys.stderr.write(f"[last30days] Saved output to {out_path}\n") |
| 256 | sys.stderr.flush() |
| 257 | print(rendered) |
| 258 | return 0 |
| 259 | |
| 260 | |
| 261 | def run_hosted( |
| 262 | topic: str, |
| 263 | depth: str, |
| 264 | *, |
| 265 | emit: str = "compact", |
| 266 | save_dir=None, |
| 267 | save_suffix: str = "", |
| 268 | register: str = "default", |
| 269 | ) -> int: |
| 270 | """Submit topic to the remote API, poll to terminal status, render report.""" |
| 271 | _err(f"Running via last30days API ({_api_base()}), depth={depth}") |
| 272 | try: |
| 273 | resp = submit(topic, depth, register=register) |
| 274 | except http.HTTPError as exc: |
| 275 | return _handle_http_error(exc) |
| 276 | |
| 277 | if resp.get("needs_clarification"): |
| 278 | return _handle_clarify(resp) |
| 279 | |
| 280 | search_id = resp.get("search_id") |
| 281 | if not search_id: |
| 282 | _err(f"Unexpected API response (no search_id): {json.dumps(resp)[:200]}") |
| 283 | return 1 |
| 284 | _err(f"Search submitted (id: {search_id}). Polling for results...") |
| 285 | |
| 286 | started = time.monotonic() |
| 287 | delay = POLL_INITIAL_DELAY |
| 288 | seen_narration: set[str] = set() |
| 289 | last_progress_line = 0.0 |
| 290 | while True: |
| 291 | elapsed = time.monotonic() - started |
| 292 | if elapsed > POLL_TIMEOUT_SECONDS: |
| 293 | _err( |
| 294 | f"Search did not finish within " |
| 295 | f"{POLL_TIMEOUT_SECONDS // 60} minutes (id: {search_id}). " |
| 296 | "It may still complete server-side; check the dashboard." |
| 297 | ) |
| 298 | return 1 |
| 299 | time.sleep(delay) |
| 300 | delay = min(delay * 2, POLL_MAX_DELAY) |
| 301 | |
| 302 | row = _poll_with_retry(search_id) |
| 303 | if row is None: |
| 304 | return 1 |
| 305 | |
| 306 | status = row.get("status") |
| 307 | narrated = _print_new_narration(row.get("stderr") or "", seen_narration) |
| 308 | elapsed = time.monotonic() - started |
| 309 | if status not in TERMINAL_STATUSES and ( |
| 310 | narrated or elapsed - last_progress_line >= PROGRESS_LINE_INTERVAL or last_progress_line == 0.0 |
| 311 | ): |
| 312 | _print_progress_line(elapsed, row.get("eta_ms")) |
| 313 | last_progress_line = elapsed |
| 314 | |
| 315 | if status == "error": |
| 316 | _err(f"Search failed: {row.get('error') or 'unknown server error'}") |
| 317 | return 1 |
| 318 | if status == "complete": |
| 319 | _err(f"Search complete in {int(elapsed)}s.") |
| 320 | return _render_complete(row, topic, emit, save_dir, save_suffix) |
| 321 | # pending | running -> keep polling |
| 322 |