|
| 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