返回 last30days-skill
hosted.py
根目录 / skills / last30days / scripts / lib / hosted.py
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
322 lines PYTHON