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:
@@ -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
@@ -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/*",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user