Guest User

Untitled

a guest
Jun 23rd, 2026
116
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 10.65 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. Parse a Cursor usage-events CSV into per-pool, per-model token and
  4. API-equivalent cost breakdown.
  5.  
  6. Pools (Cursor Pro):
  7.  · Auto + Composer  — c25/auto before the auto→API cutover (Pro sub)
  8.  · API credits      — Claude/GPT/etc anytime + c25/auto after cutover
  9.  · Free bonus       — Kind=Free rows
  10.  · No charge        — errored/aborted rows
  11.  
  12. Usage:
  13.    python3 cursor_usage.py [csv]
  14.    python3 cursor_usage.py --auto-cutoff 2026-06-13T00:00:00Z [csv]
  15. """
  16.  
  17. from __future__ import annotations
  18.  
  19. import argparse
  20. import csv
  21. import datetime as dt
  22. import sys
  23. from collections import defaultdict
  24. from typing import Optional
  25.  
  26. # (input, cache_write, cache_read, output) — $ per 1M tokens.
  27. # Longer prefixes first so "composer-2.5-fast" isn't caught by "composer-2.5".
  28. PRICING: dict[str, tuple[float, float, float, float]] = {
  29.     "composer-2.5-fast":                 (3.00,   3.00,  0.50,  15.00),
  30.     "composer-2.5":                      (0.50,   0.50,  0.20,   2.50),
  31.     "auto":                              (1.25,   1.25,  0.25,   6.00),
  32.     "claude-fable-5-thinking-high":      (10.00, 12.50,  1.00,  50.00),
  33.     "claude-fable-5":                    (10.00, 12.50,  1.00,  50.00),
  34.     "claude-4.6-sonnet-medium-thinking": (3.00,   3.75,  0.30,  15.00),
  35.     "claude-4.6-sonnet":                 (3.00,   3.75,  0.30,  15.00),
  36.     "claude-opus-4.8":                   (5.00,   6.25,  0.50,  25.00),
  37.     "claude-4.6-opus":                   (5.00,   6.25,  0.50,  25.00),
  38.     "gemini-3.1-pro":                    (2.00,   2.00,  0.20,  12.00),
  39.     "gemini-3.5-flash":                  (1.50,   1.50,  0.15,   9.00),
  40.     "gpt-5.3-codex":                     (1.75,   1.75,  0.175, 14.00),
  41.     "gpt-5.5":                           (5.00,   5.00,  0.50,  30.00),
  42.     "grok-build-0.1":                    (1.00,   1.00,  0.20,   2.00),
  43. }
  44.  
  45. AUTO_CUTOFF_FALLBACK = "2026-06-13T00:00:00.000Z"
  46. UNPRICED_TAG = "(unpriced)"
  47. NO_CHARGE_KINDS = {"Errored, No Charge", "Aborted, Not Charged"}
  48. AUTO_COMPOSER_PREFIXES = ("auto", "composer-2.5")
  49.  
  50. POOLS = ("auto_composer", "api_credit", "free_bonus", "no_charge")
  51. POOL_LABELS = {
  52.     "auto_composer": "Auto + Composer pool  (Pro subscription)",
  53.     "api_credit":    "API credit pool",
  54.     "free_bonus":    "Free bonus  (Kind = Free)",
  55.     "no_charge":     "Errored / aborted  (no charge)",
  56. }
  57.  
  58. Stat = dict[str, int | float]
  59.  
  60.  
  61. def load_events(path: str) -> list[dict]:
  62.     with open(path, newline="", encoding="utf-8") as f:
  63.         rows = list(csv.DictReader(f))
  64.     rows.sort(key=lambda r: r["Date"])
  65.     return rows
  66.  
  67.  
  68. def _int_field(event: dict, key: str) -> int:
  69.     return int((event.get(key) or "0").strip() or 0)
  70.  
  71.  
  72. def parse_tokens(event: dict) -> dict[str, int]:
  73.     return {
  74.         "in_wcache": _int_field(event, "Input (w/ Cache Write)"),
  75.         "in":        _int_field(event, "Input (w/o Cache Write)"),
  76.         "cache":     _int_field(event, "Cache Read"),
  77.         "out":       _int_field(event, "Output Tokens"),
  78.     }
  79.  
  80.  
  81. def price_for(model: str) -> tuple[str, Optional[tuple[float, float, float, float]]]:
  82.     m = (model or "").lower().strip()
  83.     if m in PRICING:
  84.         return m, PRICING[m]
  85.     for key, rate in PRICING.items():
  86.         if m.startswith(key):
  87.             return key, rate
  88.     return model or UNPRICED_TAG, None
  89.  
  90.  
  91. def api_equiv_cost(
  92.     tokens: dict[str, int], rate: Optional[tuple[float, float, float, float]]
  93. ) -> float:
  94.     if rate is None:
  95.         return 0.0
  96.     ri, rw, rc, ro = rate
  97.     return (
  98.         tokens["in_wcache"] / 1e6 * rw
  99.         + tokens["in"]        / 1e6 * ri
  100.         + tokens["cache"]     / 1e6 * rc
  101.         + tokens["out"]       / 1e6 * ro
  102.     )
  103.  
  104.  
  105. def is_auto_composer(model: str) -> bool:
  106.     m = (model or "").lower()
  107.     return any(m.startswith(p) for p in AUTO_COMPOSER_PREFIXES)
  108.  
  109.  
  110. def detect_auto_cutoff(
  111.     events: list[dict],
  112.     min_neighbors: int = 30,
  113.     min_after: int = 5,
  114. ) -> tuple[str, str]:
  115.     """Find when c25/auto stopped drawing from the Auto pool."""
  116.     daily: dict[str, int] = defaultdict(int)
  117.     for e in events:
  118.         if e["Kind"] not in ("Included", "Free"):
  119.             continue
  120.         if not is_auto_composer(e["Model"]):
  121.             continue
  122.         daily[e["Date"][:10]] += 1
  123.  
  124.     if not daily:
  125.         return AUTO_CUTOFF_FALLBACK, "no c25/auto events — using fallback date"
  126.  
  127.     days = sorted(daily)
  128.     candidates: list[tuple[str, int, int, int]] = []
  129.  
  130.     for i in range(len(days) - 1):
  131.         d_prev, d_next = days[i], days[i + 1]
  132.         gap = (
  133.             dt.datetime.strptime(d_next, "%Y-%m-%d")
  134.             - dt.datetime.strptime(d_prev, "%Y-%m-%d")
  135.         ).days
  136.         if gap > 1:
  137.             gap_start = (
  138.                 dt.datetime.strptime(d_prev, "%Y-%m-%d") + dt.timedelta(days=1)
  139.             ).strftime("%Y-%m-%d")
  140.             candidates.append((gap_start, daily[d_prev], daily[d_next], gap - 1))
  141.  
  142.     qualified = [
  143.         c for c in candidates if c[1] >= min_neighbors and c[2] >= min_after
  144.     ]
  145.     if not qualified:
  146.         return AUTO_CUTOFF_FALLBACK, "no qualifying gap — using fallback date"
  147.  
  148.     # Latest qualifying gap: auto pool usually drains late in the period.
  149.     gap_start, prev_n, next_n, gap_len = max(qualified, key=lambda c: (c[0], c[1] + c[2]))
  150.     cutover = f"{gap_start}T00:00:00.000Z"
  151.     reason = (
  152.         f"{gap_start}  ({gap_len}-day gap after {prev_n}-event day, "
  153.         f"resumed with {next_n} events)"
  154.     )
  155.     return cutover, reason
  156.  
  157.  
  158. def classify(events: list[dict], cutover: str) -> list[dict]:
  159.     rows: list[dict] = []
  160.     for e in events:
  161.         tokens = parse_tokens(e)
  162.         model_key, rate = price_for(e["Model"])
  163.         cost = api_equiv_cost(tokens, rate)
  164.         kind = e["Kind"]
  165.  
  166.         if kind == "Free":
  167.             pool = "free_bonus"
  168.         elif kind in NO_CHARGE_KINDS:
  169.             pool, cost = "no_charge", 0.0
  170.         elif is_auto_composer(e["Model"]) and e["Date"] < cutover:
  171.             pool = "auto_composer"
  172.         else:
  173.             pool = "api_credit"
  174.  
  175.         total = tokens["in_wcache"] + tokens["in"] + tokens["cache"] + tokens["out"]
  176.         rows.append({
  177.             "pool": pool,
  178.             "model": model_key,
  179.             "events": 1,
  180.             **tokens,
  181.             "total": total,
  182.             "cost": cost,
  183.         })
  184.     return rows
  185.  
  186.  
  187. def aggregate(rows: list[dict]) -> dict[tuple[str, str], Stat]:
  188.     out: dict[tuple[str, str], Stat] = defaultdict(
  189.         lambda: {"events": 0, "in_wcache": 0, "in": 0, "cache": 0, "out": 0, "total": 0, "cost": 0.0}
  190.     )
  191.     for r in rows:
  192.         k = (r["pool"], r["model"])
  193.         for field in ("events", "in_wcache", "in", "cache", "out", "total"):
  194.             out[k][field] += r[field]  # type: ignore[operator]
  195.         out[k]["cost"] += r["cost"]  # type: ignore[operator]
  196.     return out
  197.  
  198.  
  199. def fmt_tokens(n: int | float) -> str:
  200.     n = int(n)
  201.     if n >= 1_000_000_000:
  202.         return f"{n / 1e9:.2f}B"
  203.     if n >= 1_000_000:
  204.         return f"{n / 1e6:.2f}M"
  205.     if n >= 1_000:
  206.         return f"{n / 1e3:.1f}K"
  207.     return str(n)
  208.  
  209.  
  210. def fmt_money(n: float) -> str:
  211.     if n >= 100:
  212.         return f"${n:,.2f}"
  213.     return f"${n:.2f}"
  214.  
  215.  
  216. def empty_stat() -> Stat:
  217.     return {"events": 0, "in_wcache": 0, "in": 0, "cache": 0, "out": 0, "total": 0, "cost": 0.0}
  218.  
  219.  
  220. def add_stat(a: Stat, b: Stat) -> Stat:
  221.     return {k: a[k] + b[k] for k in a}  # type: ignore[operator]
  222.  
  223.  
  224. def render(
  225.     by_pool_model: dict[tuple[str, str], Stat],
  226.     events: list[dict],
  227.     cutover: str,
  228.     cutover_note: str,
  229. ) -> None:
  230.     period_start = events[0]["Date"][:10]
  231.     period_end = events[-1]["Date"][:10]
  232.     model_w = 36
  233.     width = 96
  234.  
  235.     print()
  236.     print("═" * width)
  237.     print("  Cursor Usage Report")
  238.     print("═" * width)
  239.     print(f"  Period     {period_start}  →  {period_end}   ({len(events):,} events)")
  240.     print(f"  Cutover    {cutover[:10]}   {cutover_note}")
  241.     print("═" * width)
  242.  
  243.     grand = empty_stat()
  244.     header = (
  245.         f"  {'Model':<{model_w}} {'Events':>7} {'Input':>9} {'Cache':>9} "
  246.         f"{'Output':>9} {'Total':>9} {'API-equiv':>10}"
  247.     )
  248.  
  249.     for pool in POOLS:
  250.         models = sorted(m for (p, m) in by_pool_model if p == pool)
  251.         print()
  252.         print(f"  {POOL_LABELS[pool]}")
  253.         print("  " + "─" * (width - 2))
  254.  
  255.         if not models:
  256.             print("  (none)")
  257.             continue
  258.  
  259.         print(header)
  260.         print("  " + "─" * (width - 2))
  261.  
  262.         sub = empty_stat()
  263.         for model in models:
  264.             d = by_pool_model[(pool, model)]
  265.             print(
  266.                 f"  {model:<{model_w}} {d['events']:>7,} "
  267.                 f"{fmt_tokens(d['in'] + d['in_wcache']):>9} "
  268.                 f"{fmt_tokens(d['cache']):>9} "
  269.                 f"{fmt_tokens(d['out']):>9} "
  270.                 f"{fmt_tokens(d['total']):>9} "
  271.                 f"{fmt_money(d['cost']):>10}"
  272.             )
  273.             sub = add_stat(sub, d)
  274.  
  275.         print("  " + "─" * (width - 2))
  276.         print(
  277.             f"  {'Subtotal':<{model_w}} {sub['events']:>7,} "
  278.             f"{fmt_tokens(sub['in'] + sub['in_wcache']):>9} "
  279.             f"{fmt_tokens(sub['cache']):>9} "
  280.             f"{fmt_tokens(sub['out']):>9} "
  281.             f"{fmt_tokens(sub['total']):>9} "
  282.             f"{fmt_money(sub['cost']):>10}"
  283.         )
  284.         grand = add_stat(grand, sub)
  285.  
  286.     print()
  287.     print("═" * width)
  288.     print(
  289.         f"  {'Grand total':<{model_w}} {grand['events']:>7,} "
  290.         f"{fmt_tokens(grand['in'] + grand['in_wcache']):>9} "
  291.         f"{fmt_tokens(grand['cache']):>9} "
  292.         f"{fmt_tokens(grand['out']):>9} "
  293.         f"{fmt_tokens(grand['total']):>9} "
  294.         f"{fmt_money(grand['cost']):>10}"
  295.     )
  296.     print("═" * width)
  297.     print()
  298.     print("  API-equiv = cache-inclusive cost at published list prices.")
  299.     print()
  300.  
  301.  
  302. def main() -> int:
  303.     p = argparse.ArgumentParser(description="Cursor usage-events CSV breakdown.")
  304.     p.add_argument("csv", nargs="?", default="usage-events-2026-06-22.csv")
  305.     p.add_argument("--auto-cutoff", metavar="ISO", help="Override auto→API cutover timestamp.")
  306.     p.add_argument("--no-auto-detect", action="store_true", help="Skip cutover detection.")
  307.     args = p.parse_args()
  308.  
  309.     events = load_events(args.csv)
  310.  
  311.     if args.auto_cutoff:
  312.         cutover, note = args.auto_cutoff, "user override"
  313.     elif args.no_auto_detect:
  314.         cutover, note = AUTO_CUTOFF_FALLBACK, "fallback (detection disabled)"
  315.     else:
  316.         cutover, note = detect_auto_cutoff(events)
  317.  
  318.     render(aggregate(classify(events, cutover)), events, cutover, note)
  319.     return 0
  320.  
  321.  
  322. if __name__ == "__main__":
  323.     sys.exit(main())
  324.  
Advertisement
Add Comment
Please, Sign In to add comment