feat(engine): optional remote research API backend (env-driven) (#747)

Adds an optional hosted-backend path: when both LAST30DAYS_API_KEY and
LAST30DAYS_API_BASE are set (and --mock is not passed), research runs
through the configured remote API instead of local sources - submit,
poll with progress on stderr, render the server's report. The endpoint
comes only from LAST30DAYS_API_BASE; there is no built-in default, so
with either variable unset the engine runs local sources unchanged.


Claude-Session: https://claude.ai/code/session_012gvxSQgfjp6RDyv6726VeB

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-07-03 22:39:16 -07:00
committed by GitHub
parent 47b924c87f
commit 76215629b9
6 changed files with 796 additions and 4 deletions
+2
View File
@@ -83,6 +83,8 @@ Override the global location with `LAST30DAYS_CONFIG_DIR=/path` (or `LAST30DAYS_
The project-scoped file is useful for **intentional per-client setups**: drop a `.claude/last30days.env` into each client folder (`SCRAPECREATORS_API_KEY`, `INCLUDE_SOURCES`, `LAST30DAYS_MEMORY_DIR`, `BSKY_HANDLE`, etc), then opt in with `LAST30DAYS_TRUST_PROJECT_CONFIG=1` from your shell or `~/.config/last30days/.env`. Folder-mode hosts such as Codex desktop do not trust hidden project config by default, and discovery stops at the git root so unrelated parent folders cannot silently influence runs.
**`LAST30DAYS_API_KEY`** + **`LAST30DAYS_API_BASE`** - optional remote-API backend. Set BOTH to route research through a remote API endpoint instead of running the local sources: `LAST30DAYS_API_BASE` is the endpoint (there is no built-in default), and `LAST30DAYS_API_KEY` is the bearer key for it. When both are set (and `--mock` is not passed), the engine submits the topic to that endpoint, polls with progress on stderr, and prints the server's report; none of the per-source keys below are used for that run. Leave either unset to run local sources exactly as normal. Unlike the other keys here, these two are read only from the **process environment** (export them in your shell or host config) - they are deliberately not loaded from the `.env` files above, so a project-scoped `.env` can never silently redirect research to a remote endpoint.
**Source-by-source** - what each key unlocks:
| Source | Key(s) | Required for | Free tier |
+4 -4
View File
@@ -31,11 +31,11 @@ omit = [
[tool.coverage.report]
skip_empty = true
show_missing = true
# Coverage gate (issue #254). 60% floor intended to rise over time, not a ceiling.
# Baseline measured 2026-06-25 (source = scripts + tests): TOTAL 84%; core modules
# pipeline.py 69%, render.py 76%, schema.py 94%. Gate kept at the 60% floor for headroom.
# Coverage gate (issue #254). Floor intended to rise over time, not a ceiling.
# Baseline measured 2026-07-03 on main before feat/hosted-api-mode
# (source = scripts + tests): TOTAL 84.06%. Gate pinned at that baseline.
# Do not lower without documenting why in the PR (see AGENTS.md Rules).
fail_under = 60
fail_under = 84
omit = [
"skills/last30days/scripts/lib/vendor/*",
"dist/*",
+2
View File
@@ -392,6 +392,8 @@ Set `LAST30DAYS_MEMORY_DIR` before invoking the skill to choose where raw resear
The engine reads `LAST30DAYS_MEMORY_DIR` from either the process env or `~/.config/last30days/.env`, so direct CLI invocations (`python3 scripts/last30days.py ...`) without `--save-dir` will still save when the env var is set. Mirrors the `LAST30DAYS_STORE` env-or-flag convention. Explicit `--save-dir` always wins.
When both `LAST30DAYS_API_KEY` and `LAST30DAYS_API_BASE` are set, the engine runs the research through that configured remote API instead of local sources (unless `--mock` is passed); `LAST30DAYS_API_BASE` is the endpoint and has no built-in default, so leaving either variable unset runs local sources normally. The invocation is unchanged: same flags, `--quick`/`--deep` map to search depth, progress lines still stream on stderr (`[narrate] step=...` plus a compact elapsed/eta line), and the report prints on stdout and saves to the memory dir as usual, so Steps 1-4 proceed normally on the output. No per-source keys or setup-wizard credentials are needed for the search itself in this mode. Two engine exits need specific handling: exit code 3 means the API asked a clarifying question first - the engine prints the question and options on stderr; present them to the user and re-run with the chosen angle folded into the topic. An insufficient-credits failure (HTTP 402) prints the account's balance, the amount needed, and a billing link - relay those lines to the user verbatim; do not fall back to WebSearch-only synthesis.
## Step 0: First-Run Setup Wizard
**CRITICAL: ALWAYS execute Step 0 BEFORE Step 1, even when the user provided a topic.** If the user typed `/last30days Mercer Island`, you MUST run the wizard BEFORE any research. The topic is preserved - research runs immediately after the wizard completes. Do NOT skip the wizard because a topic was provided. It takes about 30 seconds and only runs once, ever.
+22
View File
@@ -1052,6 +1052,28 @@ def main() -> int:
sys.stderr.write(setup_wizard.get_setup_status_text(results) + "\n")
return 0
# Remote API path: when BOTH LAST30DAYS_API_KEY and LAST30DAYS_API_BASE are
# set (and --mock is not), the search runs through the configured remote API
# instead of local sources; no local provider keys are needed (see
# lib/hosted.py). With either env var unset, behavior below is byte-identical
# to local-only runs - there is no built-in endpoint.
if (
topic
and not args.diagnose
and not args.mock
and os.environ.get("LAST30DAYS_API_KEY")
and os.environ.get("LAST30DAYS_API_BASE")
):
from lib import hosted
depth = "deep" if args.deep else "quick" if args.quick else "default"
return hosted.run_hosted(
topic,
depth,
emit=args.emit,
save_dir=args.save_dir,
save_suffix=args.save_suffix or "",
)
requested_sources = resolve_requested_sources(args.search, config)
diag = pipeline.diagnose(config, requested_sources, safe=args.diagnose)
+296
View File
@@ -0,0 +1,296 @@
"""Remote API client for last30days (optional hosted-backend mode).
When both LAST30DAYS_API_KEY and LAST30DAYS_API_BASE are set, the engine
submits the topic to the configured remote API, polls until the run reaches a
terminal status, streams narration progress to stderr, and renders the
server's report. No local provider keys are required in this mode. The
endpoint comes only from LAST30DAYS_API_BASE - there is no built-in default.
Contract (API v1):
POST {base}/search Authorization: Bearer <key>
{"query": ..., "depth": "quick"|"default"|"deep"}
-> 200 {"search_id": "<uuid>", "status": "running"}
-> 200 clarify payload {"needs_clarification": true, ...}
-> 401 {"error"} / 402 {"error","requires_credits",
"balance","needed"} / 429 {"error"}
GET {base}/search?id=<uuid> same auth header; poll until status is
terminal ("complete" | "error"). Running rows carry
"stderr" (narration + engine lines) and "eta_ms";
terminal complete rows carry "synthesis_text" and
"raw_markdown" (stderr stripped).
This module carries ZERO pricing, rate-card, cost, or billing logic.
Balance/credit numbers are only ever displayed verbatim from API responses.
The API key is never printed, logged, or persisted by this module.
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from . import http
from .log import source_log
# Distinct exit code for the clarify gate so the invoking model can tell
# "re-run with a chosen angle" apart from a plain failure (1).
EXIT_CLARIFY = 3
POLL_INITIAL_DELAY = 3.0
POLL_MAX_DELAY = 10.0
POLL_TIMEOUT_SECONDS = 15 * 60
# GET is idempotent: retry a few times across network blips before giving up.
POLL_NETWORK_RETRIES = 3
# Cadence for the compact elapsed/eta progress line (seconds).
PROGRESS_LINE_INTERVAL = 15.0
NARRATE_PREFIX = "[narrate] step="
TERMINAL_STATUSES = {"complete", "error"}
def _err(msg: str) -> None:
source_log("hosted", msg, tty_only=False)
def _api_base() -> str:
# Endpoint comes only from the environment - no built-in default. Hosted
# mode is gated on this being set (see last30days.py), so by the time this
# is called it is populated; an empty value means "not configured".
return (os.environ.get("LAST30DAYS_API_BASE") or "").rstrip("/")
def _billing_url() -> str:
"""Derive a billing link from the configured base, so no URL is hardcoded.
Convention: the base is the API-version root (e.g. ends in /api/v1); drop
that segment and point at the account's billing page."""
base = _api_base()
root = re.sub(r"/api/v\d+$", "", base)
return f"{root}/dashboard/billing"
def _auth_headers() -> dict[str, str]:
# Key is read at call time and placed only in the request header;
# it must never be interpolated into any log or output line.
key = os.environ.get("LAST30DAYS_API_KEY") or ""
return {"Authorization": f"Bearer {key}"}
def submit(query: str, depth: str) -> dict:
"""POST the search. retries=1: a blind POST retry could double-submit."""
return http.post(
f"{_api_base()}/search",
json_data={"query": query, "depth": depth},
headers=_auth_headers(),
retries=1,
)
def poll(search_id: str) -> dict:
"""GET the search row once. Callers own the retry loop (GET is idempotent)."""
return http.get(
f"{_api_base()}/search",
headers=_auth_headers(),
params={"id": search_id},
retries=1,
)
def _parse_error_body(exc: http.HTTPError) -> dict:
if not exc.body:
return {}
try:
parsed = json.loads(exc.body)
except (json.JSONDecodeError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _handle_http_error(exc: http.HTTPError) -> int:
body = _parse_error_body(exc)
if exc.status_code == 401:
_err(
"API key rejected: invalid or revoked. Check "
"LAST30DAYS_API_KEY (and LAST30DAYS_API_BASE), or unset them "
"to fall back to local sources."
)
return 1
if exc.status_code == 402:
_err(f"API: {body.get('error') or 'insufficient credits.'}")
if body.get("balance") is not None or body.get("needed") is not None:
_err(
f"Balance: {body.get('balance')} credits. "
f"Needed for this search: {body.get('needed')} credits."
)
_err(f"Add credits at {_billing_url()}")
return 1
if exc.status_code == 429:
_err(
f"API rate limit hit: "
f"{body.get('error') or 'too many requests.'} "
"Wait a minute and re-run."
)
return 1
_err(f"API request failed: {exc}")
return 1
def _handle_clarify(resp: dict) -> int:
question = resp.get("question") or "The API needs a clarification before searching."
options = resp.get("options") or []
_err(f"Clarification needed before this search runs: {question}")
for index, option in enumerate(options, 1):
label = option if isinstance(option, str) else json.dumps(option)
sys.stderr.write(f" {index}. {label}\n")
sys.stderr.flush()
_err(
"No search was started. Re-run last30days with the chosen angle "
"folded into the topic text."
)
return EXIT_CLARIFY
def _print_new_narration(stderr_blob: str, seen: set[str]) -> bool:
"""Print each '[narrate] step=' line once, verbatim. Returns True if any new."""
printed = False
for line in stderr_blob.splitlines():
if line.startswith(NARRATE_PREFIX) and line not in seen:
seen.add(line)
sys.stderr.write(f"{line}\n")
printed = True
if printed:
sys.stderr.flush()
return printed
def _print_progress_line(elapsed: float, eta_ms) -> None:
line = f"elapsed {int(elapsed)}s"
if isinstance(eta_ms, (int, float)) and eta_ms > 0:
line += f", eta ~{int(eta_ms / 1000)}s"
_err(line)
def _poll_with_retry(search_id: str) -> dict | None:
"""Poll once, retrying transient network failures. None means give up
(a user-facing message has already been printed)."""
last_error: http.HTTPError | None = None
for attempt in range(POLL_NETWORK_RETRIES):
try:
return poll(search_id)
except http.HTTPError as exc:
if exc.status_code is not None and 400 <= exc.status_code < 500 and exc.status_code != 429:
_handle_http_error(exc)
return None
# Network blip / timeout / 5xx / 429: GET is idempotent, retry.
last_error = exc
if attempt < POLL_NETWORK_RETRIES - 1:
time.sleep(POLL_INITIAL_DELAY)
_err(
f"API unreachable while polling search {search_id} "
f"after {POLL_NETWORK_RETRIES} attempts: {last_error}"
)
return None
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "last30days"
def _save_output(topic: str, content: str, emit: str, save_dir: str, suffix: str):
"""Mirror local save_output() naming: <slug>-raw[-suffix].<ext>."""
from datetime import datetime
from pathlib import Path
path = Path(save_dir).expanduser().resolve()
path.mkdir(parents=True, exist_ok=True)
slug = _slugify(topic)
extension = "json" if emit == "json" else "md"
suffix_part = f"-{suffix}" if suffix else ""
out_path = path / f"{slug}-raw{suffix_part}.{extension}"
if out_path.exists():
out_path = path / f"{slug}-raw{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
out_path.write_text(content, encoding="utf-8")
return out_path
def _render_complete(row: dict, topic: str, emit: str, save_dir, save_suffix: str) -> int:
synthesis = row.get("synthesis_text") or ""
raw_markdown = row.get("raw_markdown") or ""
if emit == "json":
payload = {
key: row.get(key)
for key in ("id", "status", "synthesis_text", "raw_markdown")
if key in row
}
rendered = json.dumps(payload, indent=2, sort_keys=True)
save_content = rendered
else:
# The server report is the content source; it already synthesized.
# All markdown-ish emit modes print the synthesis text as-is.
rendered = synthesis or raw_markdown
save_content = raw_markdown or synthesis
if save_dir:
out_path = _save_output(topic, save_content, emit, save_dir, save_suffix)
sys.stderr.write(f"[last30days] Saved output to {out_path}\n")
sys.stderr.flush()
print(rendered)
return 0
def run_hosted(topic: str, depth: str, *, emit: str = "compact",
save_dir=None, save_suffix: str = "") -> int:
"""Submit topic to the remote API, poll to terminal status, render report."""
_err(f"Running via last30days API ({_api_base()}), depth={depth}")
try:
resp = submit(topic, depth)
except http.HTTPError as exc:
return _handle_http_error(exc)
if resp.get("needs_clarification"):
return _handle_clarify(resp)
search_id = resp.get("search_id")
if not search_id:
_err(f"Unexpected API response (no search_id): {json.dumps(resp)[:200]}")
return 1
_err(f"Search submitted (id: {search_id}). Polling for results...")
started = time.monotonic()
delay = POLL_INITIAL_DELAY
seen_narration: set[str] = set()
last_progress_line = 0.0
while True:
elapsed = time.monotonic() - started
if elapsed > POLL_TIMEOUT_SECONDS:
_err(
f"Search did not finish within "
f"{POLL_TIMEOUT_SECONDS // 60} minutes (id: {search_id}). "
"It may still complete server-side; check the dashboard."
)
return 1
time.sleep(delay)
delay = min(delay * 2, POLL_MAX_DELAY)
row = _poll_with_retry(search_id)
if row is None:
return 1
status = row.get("status")
narrated = _print_new_narration(row.get("stderr") or "", seen_narration)
elapsed = time.monotonic() - started
if status not in TERMINAL_STATUSES and (
narrated or elapsed - last_progress_line >= PROGRESS_LINE_INTERVAL or last_progress_line == 0.0
):
_print_progress_line(elapsed, row.get("eta_ms"))
last_progress_line = elapsed
if status == "error":
_err(f"Search failed: {row.get('error') or 'unknown server error'}")
return 1
if status == "complete":
_err(f"Search complete in {int(elapsed)}s.")
return _render_complete(row, topic, emit, save_dir, save_suffix)
# pending | running -> keep polling
+470
View File
@@ -0,0 +1,470 @@
"""Tests for the remote API path (LAST30DAYS_API_KEY + LAST30DAYS_API_BASE).
Fixtures mirror the remote API contract exactly:
POST {base}/search {"query","depth"} -> {"search_id","status"} | clarify payload
GET {base}/search?id=<uuid> -> pending|running|complete|error rows
401 {"error"} / 402 {"error","requires_credits","balance","needed"} / 429 {"error"}
The endpoint is driven entirely through LAST30DAYS_API_BASE; there is no
built-in default. All keys/hosts in tests are obvious dummy values (see
AGENTS.md security hygiene).
"""
import io
import json
import sys
from contextlib import redirect_stderr, redirect_stdout
from unittest import mock
import pytest
import last30days as cli
from lib import hosted, http, schema
TEST_KEY = "sk_live_DUMMY_TEST_KEY_00000"
# Neutral placeholder endpoint - no product host. Ends in /api/v1 to mirror the
# API-version-root convention the billing-link derivation relies on.
TEST_BASE = "https://api.example.test/api/v1"
SEARCH_ID = "3f6c1c2e-9f6a-4a55-8f8a-2d1a9b8c7d6e"
SUBMIT_OK = {"search_id": SEARCH_ID, "status": "running"}
POLL_RUNNING = {
"id": SEARCH_ID,
"status": "running",
"stderr": (
"[narrate] step=planning queries\n"
"[Reddit] fetched 12 threads\n"
"[narrate] step=searching sources\n"
),
"eta_ms": 45000,
}
POLL_COMPLETE = {
"id": SEARCH_ID,
"status": "complete",
"synthesis_text": "## What happened\nSynthesized report body.",
"raw_markdown": "# Raw markdown\nFull dump.",
}
CLARIFY_RESPONSE = {
"needs_clarification": True,
"clarify_class": "ambiguous_entity",
"question": "Which 'mercury' do you mean?",
"options": ["Mercury the planet", "Mercury the element", "Mercury the band"],
"original_query": "mercury",
}
DIAG = {
"available_sources": ["grounding"],
"providers": {"google": True, "openai": False, "xai": False},
"x_backend": None,
"bird_installed": False,
"bird_authenticated": False,
"bird_username": None,
"native_web_backend": "brave",
}
def make_report(topic: str = "test topic") -> schema.Report:
return schema.Report(
topic=topic,
range_from="2026-06-03",
range_to="2026-07-03",
generated_at="2026-07-03T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="gemini",
planner_model="gemini-3.1-flash-lite",
rerank_model="gemini-3.1-flash-lite",
),
query_plan=schema.QueryPlan(
intent="overview",
freshness_mode="balanced_recent",
cluster_mode="themes",
raw_topic=topic,
subqueries=[
schema.SubQuery(
label="primary",
search_query=topic.lower(),
ranking_query=f"What happened with {topic}?",
sources=["grounding"],
)
],
source_weights={"grounding": 1.0},
),
clusters=[],
ranked_candidates=[],
items_by_source={"grounding": []},
errors_by_source={},
)
def run_main(argv):
stdout, stderr = io.StringIO(), io.StringIO()
with mock.patch.object(sys, "argv", ["last30days.py", *argv]):
with redirect_stdout(stdout), redirect_stderr(stderr):
rc = cli.main()
return rc, stdout.getvalue(), stderr.getvalue()
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv("LAST30DAYS_API_KEY", raising=False)
monkeypatch.delenv("LAST30DAYS_API_BASE", raising=False)
monkeypatch.delenv("LAST30DAYS_MEMORY_DIR", raising=False)
# ---------------------------------------------------------------------------
# Mode selection in the CLI entrypoint
# ---------------------------------------------------------------------------
def test_env_unset_runs_local_path_with_no_gateway_http(monkeypatch):
"""Without LAST30DAYS_API_KEY the local engine runs; the remote client and
the API are never touched."""
def no_hosted(*args, **kwargs): # pragma: no cover - failure path
raise AssertionError("remote path must not run when env is unset")
def no_http(*args, **kwargs): # pragma: no cover - failure path
raise AssertionError(f"unexpected HTTP call in local test: {args}")
monkeypatch.setattr(hosted, "run_hosted", no_hosted)
monkeypatch.setattr(http, "request", no_http)
fake_progress = mock.Mock()
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \
mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \
mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
mock.patch.object(cli, "emit_output", return_value="# local rendered"):
rc, out, _err = run_main(["test", "topic"])
assert rc == 0
pipeline_run.assert_called_once()
assert "# local rendered" in out
def test_env_set_routes_to_remote_path(monkeypatch):
# Both vars set -> remote path (KTD-2: key alone no longer activates it).
monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
calls = []
def fake_run_hosted(topic, depth, *, emit, save_dir, save_suffix):
calls.append({"topic": topic, "depth": depth, "emit": emit,
"save_dir": save_dir, "save_suffix": save_suffix})
return 0
monkeypatch.setattr(hosted, "run_hosted", fake_run_hosted)
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "run",
side_effect=AssertionError("local pipeline must not run")):
rc, _out, _err = run_main(["test", "topic"])
assert rc == 0
assert calls == [{
"topic": "test topic",
"depth": "default",
"emit": "compact",
"save_dir": None,
"save_suffix": "",
}]
@pytest.mark.parametrize(
("flag", "expected_depth"),
[(["--quick"], "quick"), ([], "default"), (["--deep"], "deep")],
)
def test_depth_mapping(monkeypatch, flag, expected_depth):
monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
depths = []
monkeypatch.setattr(
hosted, "run_hosted",
lambda topic, depth, **kwargs: depths.append(depth) or 0,
)
with mock.patch.object(cli.env, "get_config", return_value={}):
rc, _out, _err = run_main(["test", "topic", *flag])
assert rc == 0
assert depths == [expected_depth]
def test_mock_flag_stays_local_even_with_key(monkeypatch):
monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
def no_hosted(*args, **kwargs): # pragma: no cover - failure path
raise AssertionError("remote path must not run with --mock")
monkeypatch.setattr(hosted, "run_hosted", no_hosted)
fake_progress = mock.Mock()
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \
mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \
mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
mock.patch.object(cli, "emit_output", return_value="# local rendered"):
rc, _out, _err = run_main(["test", "topic", "--mock"])
assert rc == 0
pipeline_run.assert_called_once()
def test_key_set_but_base_unset_stays_local(monkeypatch):
"""KTD-2 inertness: with only the key set (no LAST30DAYS_API_BASE), hosted
mode does not activate - the local engine runs and no HTTP is attempted.
This is the leak-proofing guarantee: a key alone can never phone anywhere."""
monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
# LAST30DAYS_API_BASE deliberately left unset by the _clean_env fixture.
def no_hosted(*args, **kwargs): # pragma: no cover - failure path
raise AssertionError("remote path must not run without LAST30DAYS_API_BASE")
def no_http(*args, **kwargs): # pragma: no cover - failure path
raise AssertionError(f"unexpected HTTP call when base is unset: {args}")
monkeypatch.setattr(hosted, "run_hosted", no_hosted)
monkeypatch.setattr(http, "request", no_http)
fake_progress = mock.Mock()
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \
mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \
mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
mock.patch.object(cli, "emit_output", return_value="# local rendered"):
rc, out, _err = run_main(["test", "topic"])
assert rc == 0
pipeline_run.assert_called_once()
assert "# local rendered" in out
# ---------------------------------------------------------------------------
# Remote client: submit -> poll -> complete
# ---------------------------------------------------------------------------
@pytest.fixture()
def remote_env(monkeypatch):
monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
monkeypatch.setattr(hosted.time, "sleep", lambda _s: None)
def test_happy_path_submit_poll_complete(remote_env, monkeypatch, capsys):
posts, gets = [], []
def fake_post(url, json_data, headers=None, **kwargs):
posts.append({"url": url, "json": json_data, "headers": headers})
return dict(SUBMIT_OK)
poll_rows = [dict(POLL_RUNNING), dict(POLL_RUNNING), dict(POLL_COMPLETE)]
def fake_get(url, headers=None, params=None, **kwargs):
gets.append({"url": url, "headers": headers, "params": params})
return poll_rows.pop(0)
monkeypatch.setattr(hosted.http, "post", fake_post)
monkeypatch.setattr(hosted.http, "get", fake_get)
rc = hosted.run_hosted("test topic", "default", emit="compact",
save_dir=None, save_suffix="")
out = capsys.readouterr().out
assert rc == 0
# Contract: submit
assert posts == [{
"url": f"{TEST_BASE}/search",
"json": {"query": "test topic", "depth": "default"},
"headers": {"Authorization": f"Bearer {TEST_KEY}"},
}]
# Contract: poll same auth, id param
assert all(g["url"] == f"{TEST_BASE}/search" for g in gets)
assert all(g["params"] == {"id": SEARCH_ID} for g in gets)
assert all(g["headers"] == {"Authorization": f"Bearer {TEST_KEY}"} for g in gets)
assert len(gets) == 3
# Synthesis rendered on stdout
assert "Synthesized report body." in out
def test_happy_path_narration_printed_once_and_no_key_echo(remote_env, monkeypatch, capsys):
monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
poll_rows = [dict(POLL_RUNNING), dict(POLL_RUNNING), dict(POLL_COMPLETE)]
monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0))
rc = hosted.run_hosted("test topic", "default", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 0
# Each narration step printed exactly once even though the stderr blob
# was returned twice by consecutive polls.
assert captured.err.count("[narrate] step=planning queries") == 1
assert captured.err.count("[narrate] step=searching sources") == 1
# Progress line with elapsed/eta shape
assert "eta" in captured.err
# The API key never appears anywhere in output.
assert TEST_KEY not in captured.out
assert TEST_KEY not in captured.err
def test_save_dir_writes_raw_markdown(remote_env, monkeypatch, capsys, tmp_path):
monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
poll_rows = [dict(POLL_COMPLETE)]
monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0))
rc = hosted.run_hosted("Test Topic!", "default", emit="compact",
save_dir=str(tmp_path), save_suffix="")
captured = capsys.readouterr()
assert rc == 0
saved = tmp_path / "test-topic-raw.md"
assert saved.exists()
assert "# Raw markdown" in saved.read_text(encoding="utf-8")
assert "Saved output to" in captured.err
assert TEST_KEY not in captured.err
def test_api_base_override(remote_env, monkeypatch, capsys):
monkeypatch.setenv("LAST30DAYS_API_BASE", "https://staging.example.dev/api/v1/")
urls = []
def fake_post(url, json_data, headers=None, **kwargs):
urls.append(url)
return dict(SUBMIT_OK)
monkeypatch.setattr(hosted.http, "post", fake_post)
monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(POLL_COMPLETE))
rc = hosted.run_hosted("test topic", "quick", emit="compact",
save_dir=None, save_suffix="")
capsys.readouterr()
assert rc == 0
assert urls == ["https://staging.example.dev/api/v1/search"]
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
def test_401_invalid_or_revoked_key(remote_env, monkeypatch, capsys):
def fake_post(*a, **k):
raise http.HTTPError("HTTP 401: Unauthorized", 401,
json.dumps({"error": "Invalid API key"}))
monkeypatch.setattr(hosted.http, "post", fake_post)
rc = hosted.run_hosted("test topic", "default", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 1
assert "invalid or revoked" in captured.err.lower()
assert TEST_KEY not in captured.err
def test_402_shows_balance_needed_and_billing_url(remote_env, monkeypatch, capsys):
body = {"error": "Insufficient credits", "requires_credits": True,
"balance": 40, "needed": 200}
def fake_post(*a, **k):
raise http.HTTPError("HTTP 402: Payment Required", 402, json.dumps(body))
monkeypatch.setattr(hosted.http, "post", fake_post)
rc = hosted.run_hosted("test topic", "deep", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 1
# Balance and needed shown verbatim from the API response.
assert "40" in captured.err
assert "200" in captured.err
# Billing link is derived from the configured base (base minus /api/v1,
# plus /dashboard/billing) - never hardcoded.
assert "https://api.example.test/dashboard/billing" in captured.err
assert TEST_KEY not in captured.err
def test_429_rate_limited(remote_env, monkeypatch, capsys):
def fake_post(*a, **k):
raise http.HTTPError("HTTP 429: Too Many Requests", 429,
json.dumps({"error": "Rate limit exceeded"}))
monkeypatch.setattr(hosted.http, "post", fake_post)
rc = hosted.run_hosted("test topic", "default", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 1
assert "rate limit" in captured.err.lower()
def test_clarify_response_prints_question_options_distinct_exit(remote_env, monkeypatch, capsys):
monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(CLARIFY_RESPONSE))
def no_get(*a, **k): # pragma: no cover - failure path
raise AssertionError("must not poll on clarify")
monkeypatch.setattr(hosted.http, "get", no_get)
rc = hosted.run_hosted("mercury", "default", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == hosted.EXIT_CLARIFY
assert rc not in (0, 1)
assert "Which 'mercury' do you mean?" in captured.err
assert "Mercury the planet" in captured.err
assert "Mercury the band" in captured.err
assert "re-run" in captured.err.lower()
def test_error_status_run_prints_server_message(remote_env, monkeypatch, capsys):
monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
error_row = {"id": SEARCH_ID, "status": "error",
"error": "Synthesis failed upstream"}
monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(error_row))
rc = hosted.run_hosted("test topic", "default", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 1
assert "Synthesis failed upstream" in captured.err
def test_network_timeout_mid_poll_retries_get(remote_env, monkeypatch, capsys):
monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
attempts = []
def flaky_get(*a, **k):
attempts.append(1)
if len(attempts) < 3:
raise http.HTTPError("Connection error: TimeoutError: timed out")
return dict(POLL_COMPLETE)
monkeypatch.setattr(hosted.http, "get", flaky_get)
rc = hosted.run_hosted("test topic", "default", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 0
assert len(attempts) == 3
assert "Synthesized report body." in captured.out
def test_persistent_network_failure_gives_up_with_message(remote_env, monkeypatch, capsys):
monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
def dead_get(*a, **k):
raise http.HTTPError("Connection error: TimeoutError: timed out")
monkeypatch.setattr(hosted.http, "get", dead_get)
rc = hosted.run_hosted("test topic", "default", emit="compact",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 1
assert "poll" in captured.err.lower()
assert TEST_KEY not in captured.err
def test_emit_json_prints_terminal_row_without_stderr(remote_env, monkeypatch, capsys):
monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(POLL_COMPLETE))
rc = hosted.run_hosted("test topic", "default", emit="json",
save_dir=None, save_suffix="")
captured = capsys.readouterr()
assert rc == 0
payload = json.loads(captured.out)
assert payload["status"] == "complete"
assert payload["synthesis_text"].startswith("## What happened")
assert payload["raw_markdown"].startswith("# Raw markdown")
assert "stderr" not in payload
assert TEST_KEY not in captured.out