Skip to content

Commit 3f14af6

Browse files
source-archive: per-run cost report (#299)
New cost.py estimates a capture run's spend by backend and per archived site (self-hosted CloakBrowser/Playwright/PDF are free; Hyperbrowser/Firecrawl are priced by the configured proxy mode). The capture CLI prints the breakdown and writes reports/<run_id>_cost.json. Estimates from public pricing; only successful captures are priced.
1 parent 0018956 commit 3f14af6

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

  • code_tests/unit_tests/test_agents_and_tools/test_source_archive
  • forecasting_tools/agents_and_tools/source_archive
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
4+
from forecasting_tools.agents_and_tools.source_archive.cost import (
5+
estimate_run_cost,
6+
price_per_capture,
7+
)
8+
from forecasting_tools.agents_and_tools.source_archive.models import StoredCapture
9+
from forecasting_tools.agents_and_tools.source_archive.pipeline import (
10+
CaptureOutcome,
11+
PipelineSummary,
12+
)
13+
14+
15+
def _cap(url: str, fetcher: str) -> StoredCapture:
16+
return StoredCapture(url=url, url_hash="h", content_hash="c", fetcher=fetcher)
17+
18+
19+
def _stored(url: str, fetcher: str) -> CaptureOutcome:
20+
return CaptureOutcome(url=url, status="stored", stored=_cap(url, fetcher))
21+
22+
23+
def test_free_backends_cost_nothing():
24+
cfg = ArchiveConfig()
25+
for f in ("cloakbrowser", "playwright", "pdf", ""):
26+
assert price_per_capture(f, cfg) == 0.0
27+
28+
29+
def test_paid_backends_priced_by_config():
30+
cfg = ArchiveConfig(hyperbrowser_use_proxy=True, firecrawl_proxy="basic")
31+
assert price_per_capture("hyperbrowser", cfg) == 10 * 0.001
32+
assert price_per_capture("firecrawl", cfg) == 1 * 0.00083
33+
34+
cheap = ArchiveConfig(hyperbrowser_use_proxy=False, firecrawl_proxy="auto")
35+
assert price_per_capture("hyperbrowser", cheap) == 1 * 0.001
36+
assert price_per_capture("firecrawl", cheap) == 5 * 0.00083
37+
38+
39+
def test_estimate_run_cost_breakdown():
40+
cfg = ArchiveConfig(hyperbrowser_use_proxy=True, firecrawl_proxy="basic")
41+
summary = PipelineSummary(
42+
outcomes=[
43+
_stored("u1", "cloakbrowser"),
44+
_stored("u2", "cloakbrowser"),
45+
_stored("u3", "hyperbrowser"),
46+
_stored("u4", "firecrawl"),
47+
CaptureOutcome(
48+
url="u5", status="cache_hit", stored=_cap("u5", "cloakbrowser")
49+
),
50+
CaptureOutcome(url="u6", status="error", reason="boom"),
51+
]
52+
)
53+
rc = estimate_run_cost(summary, cfg, run_id="r1")
54+
55+
assert rc.archived == 5 # 4 stored + 1 cache_hit; the error doesn't count
56+
assert rc.paid_captures == 2 # hyperbrowser + firecrawl
57+
assert rc.total_usd == round(0.01 + 0.00083, 4) # 0.0108 (4-dp rounding)
58+
by = {b.backend: b for b in rc.by_backend}
59+
assert by["cloakbrowser"].captures == 2 and by["cloakbrowser"].total_usd == 0.0
60+
assert by["hyperbrowser"].captures == 1 and by["hyperbrowser"].unit_usd == 0.01

forecasting_tools/agents_and_tools/source_archive/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,19 @@ def _cmd_capture(args, config: ArchiveConfig) -> int:
110110
summary = capture_urls_concurrent(urls, store, config, build_default_fetcher)
111111
print(summary)
112112

113+
from forecasting_tools.agents_and_tools.source_archive import cost as cost_mod
114+
115+
run_cost = cost_mod.estimate_run_cost(summary, config, run_id=args.run_id)
116+
print(run_cost)
117+
113118
run_id = args.run_id or (records[0].run_id if records else None)
114119
if run_id:
115120
from forecasting_tools.agents_and_tools.source_archive import reports
116121

117122
reports.write_run_report(store.blobs, run_id, summary, config)
118123
print(f"Wrote run outcomes -> {config.s3_prefix}/reports/{run_id}.json")
124+
cost_mod.write_cost_report(store.blobs, run_id, run_cost, config)
125+
print(f"Wrote cost report -> {config.s3_prefix}/reports/{run_id}_cost.json")
119126

120127
# Failures leave no cache entry, so re-running retries exactly them. Write a
121128
# retry manifest (with provenance) so coming back — e.g. with hyperbrowser
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Estimate what a capture run cost — per backend and per archived site.
2+
3+
Self-hosted browsers (CloakBrowser / Playwright) and local PDF parsing are ~free;
4+
the managed backends (Hyperbrowser, Firecrawl) bill per page. This turns a run's
5+
outcomes into a cost breakdown so an operator can see what the paid backends are
6+
costing per site archived.
7+
8+
Costs are **estimates** from each vendor's public pricing applied to the
9+
configured proxy mode — we record the backend that produced each capture, not the
10+
live credit count — so treat them as close approximations, not billed amounts.
11+
Only *successful* captures are priced; a paid backend call that then failed the
12+
quality gate isn't attributed to a backend here (so this slightly under-counts).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
from collections import Counter
19+
20+
from pydantic import BaseModel
21+
22+
from forecasting_tools.agents_and_tools.source_archive.config import ArchiveConfig
23+
24+
# $ per vendor credit (public list pricing, 2026-06).
25+
_FIRECRAWL_CREDIT_USD = 0.00083
26+
_HYPERBROWSER_CREDIT_USD = 0.001
27+
28+
# Backends that run on our own machine — no per-page charge.
29+
_FREE_BACKENDS = {"cloakbrowser", "playwright", "pdf", ""}
30+
31+
32+
def price_per_capture(fetcher: str, config: ArchiveConfig) -> float:
33+
"""Estimated $ for one successful capture by ``fetcher`` under ``config``."""
34+
f = (fetcher or "").lower()
35+
if f in _FREE_BACKENDS:
36+
return 0.0
37+
if f == "hyperbrowser":
38+
credits = 10 if config.hyperbrowser_use_proxy else 1
39+
return credits * _HYPERBROWSER_CREDIT_USD
40+
if f.startswith("firecrawl"):
41+
basic = (config.firecrawl_proxy or "basic").lower() in ("", "basic")
42+
return (1 if basic else 5) * _FIRECRAWL_CREDIT_USD
43+
return 0.0 # unknown backend — assume free rather than invent a number
44+
45+
46+
class BackendCost(BaseModel):
47+
backend: str
48+
captures: int
49+
unit_usd: float
50+
total_usd: float
51+
52+
53+
class RunCost(BaseModel):
54+
run_id: str | None = None
55+
archived: int = 0 # sites we now hold (stored + deduped + cache_hit)
56+
paid_captures: int = 0 # captures via a paid backend this run
57+
total_usd: float = 0.0
58+
usd_per_archived: float = 0.0
59+
by_backend: list[BackendCost] = []
60+
61+
def __str__(self) -> str:
62+
lines = [
63+
f"RunCost(run_id={self.run_id}, archived={self.archived}, "
64+
f"paid_captures={self.paid_captures}, total=${self.total_usd:.4f}, "
65+
f"$/archived=${self.usd_per_archived:.5f})",
66+
f" {'backend':<14}{'captures':>9}{'$/capture':>12}{'$ total':>10}",
67+
]
68+
for b in self.by_backend:
69+
lines.append(
70+
f" {b.backend:<14}{b.captures:>9}{b.unit_usd:>12.5f}{b.total_usd:>10.4f}"
71+
)
72+
return "\n".join(lines)
73+
74+
75+
def estimate_run_cost(
76+
summary, config: ArchiveConfig, run_id: str | None = None
77+
) -> RunCost:
78+
"""Estimate a :class:`PipelineSummary`'s cost, broken down by backend.
79+
80+
Newly fetched captures (``stored`` / ``deduped``) are priced by the backend
81+
that produced them; ``cache_hit`` re-uses cost nothing (no fetch happened).
82+
"""
83+
counts: Counter[str] = Counter()
84+
for o in summary.outcomes:
85+
if o.status in ("stored", "deduped") and o.stored is not None:
86+
counts[(o.stored.fetcher or "unknown")] += 1
87+
88+
by_backend: list[BackendCost] = []
89+
total = 0.0
90+
paid = 0
91+
for backend, n in sorted(counts.items()):
92+
unit = price_per_capture(backend, config)
93+
sub = unit * n
94+
total += sub
95+
if unit > 0:
96+
paid += n
97+
by_backend.append(
98+
BackendCost(
99+
backend=backend,
100+
captures=n,
101+
unit_usd=round(unit, 6),
102+
total_usd=round(sub, 4),
103+
)
104+
)
105+
106+
archived = sum(
107+
1 for o in summary.outcomes if o.status in ("stored", "deduped", "cache_hit")
108+
)
109+
return RunCost(
110+
run_id=run_id,
111+
archived=archived,
112+
paid_captures=paid,
113+
total_usd=round(total, 4),
114+
usd_per_archived=round(total / archived, 6) if archived else 0.0,
115+
by_backend=by_backend,
116+
)
117+
118+
119+
def cost_report_key(run_id: str, config: ArchiveConfig) -> str:
120+
return f"{config.s3_prefix.rstrip('/')}/reports/{run_id}_cost.json"
121+
122+
123+
def write_cost_report(store, run_id: str, cost: RunCost, config: ArchiveConfig) -> str:
124+
"""Persist the cost breakdown next to the run report (``reports/<id>_cost.json``)."""
125+
key = cost_report_key(run_id, config)
126+
store.put(
127+
key,
128+
json.dumps(cost.model_dump(), indent=2).encode("utf-8"),
129+
content_type="application/json",
130+
)
131+
return key

0 commit comments

Comments
 (0)