claude-finops 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ from collections import defaultdict
17
17
  from datetime import date, datetime, timedelta, timezone
18
18
 
19
19
  from .pricing import Pricing
20
+ from . import localtime
20
21
  from .segments import is_compaction
21
22
 
22
23
  from .paths import DB_PATH, SETTINGS_PATH, LOCAL_SETTINGS_PATH
@@ -164,6 +165,13 @@ def _d(s):
164
165
  return datetime.strptime(s, "%Y-%m-%d").date()
165
166
 
166
167
 
168
+ def like(term):
169
+ r"""A LIKE pattern (used with ESCAPE '\') matching `term` literally, so % and _
170
+ in what you type are not wildcards."""
171
+ t = str(term).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
172
+ return f"%{t}%"
173
+
174
+
167
175
  def _cumsum(values):
168
176
  total = 0.0
169
177
  for v in values:
@@ -201,7 +209,15 @@ class Analytics:
201
209
  _today = None # tests set this; production uses the clock
202
210
 
203
211
  def today(self):
204
- return self._today or datetime.now(timezone.utc).date()
212
+ return self._today or localtime.today()
213
+
214
+ def data_end(self):
215
+ """The last day the warehouse can speak for: today, or the day it was last synced if
216
+ that is earlier. Days after a sync are not idle, they are unread, so rate windows
217
+ end here instead of counting them as zero."""
218
+ built = localtime.local(self.meta.get("built_at"))
219
+ today = self.today()
220
+ return min(today, built.date()) if built and not self._today else today
205
221
 
206
222
  @property
207
223
  def db(self):
@@ -337,6 +353,11 @@ class Analytics:
337
353
  p).get("n", 0)
338
354
  tot["tool_calls"] = self.one(
339
355
  f"SELECT COALESCE(SUM(r.tool_call_count),0) n FROM requests r WHERE {w}", p).get("n", 0)
356
+ # Requests with tokens but no price (an agent without price data, such as Cursor,
357
+ # or a model missing from pricing.json): they count, but at $0.
358
+ tot["unpriced_requests"] = self.one(
359
+ f"SELECT COUNT(*) n FROM requests r WHERE {w} AND r.est_cost_usd = 0 AND r.billable_tokens > 0",
360
+ p).get("n", 0)
340
361
 
341
362
  bp = self.billing_period()
342
363
  today = bp["today"]
@@ -362,8 +383,12 @@ class Analytics:
362
383
 
363
384
  # ---------------- burn rate & limits ----------------
364
385
  def burn(self, f=None):
386
+ # The billing period is its own window: the page's date range must not move it,
387
+ # or picking "last 7 days" would quietly change what the period projects to.
388
+ f = dict(f or {}, start=None, end=None)
365
389
  w, p = self.where(f)
366
390
  bp = self.billing_period()
391
+ today, synced = self.today(), self.data_end()
367
392
  rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens,
368
393
  COUNT(*) requests FROM requests r
369
394
  WHERE {w} AND r.day >= ? AND r.day <= ? GROUP BY 1 ORDER BY 1""",
@@ -371,23 +396,37 @@ class Analytics:
371
396
  used_cost = sum(r["cost"] for r in rows)
372
397
  used_tokens = sum(r["tokens"] for r in rows)
373
398
  used_req = sum(r["requests"] for r in rows)
374
- elapsed = max(bp["elapsed_days"], 1)
399
+ # Days after the last sync are unread, not idle: the period average and the
400
+ # projection only count the days the warehouse actually covers.
401
+ unread = max((today - max(synced, _d(bp["start"]) - timedelta(days=1))).days, 0)
402
+ elapsed = max(bp["elapsed_days"] - unread, 1)
375
403
  daily_avg = used_cost / elapsed
376
404
 
377
405
  # Trailing rates are measured over the last N CALENDAR days, not only the
378
406
  # slice inside the billing period — early in a period that slice is too short
379
407
  # to be a rate. Idle days count as zero, because these rates get multiplied by
380
408
  # calendar days remaining. This keeps burn and forecast on one methodology.
381
- yesterday = (self.today() - timedelta(days=1)).isoformat()
382
- last7 = self.daily_series(f, days=7, end=yesterday)
383
- last14 = self.daily_series(f, days=14, end=yesterday)
409
+ # The window ends on the last complete day the warehouse has read.
410
+ rate_end = (synced - timedelta(days=1)).isoformat()
411
+ last7 = self.daily_series(f, days=7, end=rate_end)
412
+ last14 = self.daily_series(f, days=14, end=rate_end)
384
413
  avg7 = (sum(r["cost"] for r in last7) / len(last7)) if last7 else 0.0
385
414
  tok_avg7 = (sum(r["tokens"] for r in last7) / len(last7)) if last7 else 0.0
386
415
  # the projection rate matches Analytics.forecast()'s "expected" scenario
387
416
  burn = (sum(r["cost"] for r in last14) / len(last14)) if last14 else daily_avg
388
417
  tok_burn = (sum(r["tokens"] for r in last14) / len(last14)) if last14 else 0.0
389
- projected = used_cost + burn * bp["remaining_days"]
418
+ req_burn = (sum(r["requests"] for r in last14) / len(last14)) if last14 else used_req / elapsed
419
+ ahead = bp["remaining_days"] + unread
420
+ projected = used_cost + burn * ahead
390
421
  tok_daily = used_tokens / elapsed
422
+ # Zero-filled so the daily chart keeps idle days as gaps, not as missing bars.
423
+ by_day = {r["day"]: r for r in rows}
424
+ series, cur = [], _d(bp["start"])
425
+ while cur <= min(synced, _d(bp["end"])):
426
+ k = cur.isoformat()
427
+ series.append(by_day.get(k) or {"day": k, "cost": 0.0, "tokens": 0, "requests": 0})
428
+ cur += timedelta(days=1)
429
+ rows = series
391
430
 
392
431
  lim = self.settings["limits"]
393
432
  out = {
@@ -399,7 +438,8 @@ class Analytics:
399
438
  "burn_rate_cost_per_day": burn,
400
439
  "burn_rate_window_days": len(last14),
401
440
  "projected_period_cost": projected,
402
- "projected_period_tokens": used_tokens + tok_burn * bp["remaining_days"],
441
+ "projected_period_tokens": used_tokens + tok_burn * ahead,
442
+ "synced_through": synced.isoformat(), "unread_days": unread,
403
443
  "forecast_basis": "forecast",
404
444
  "forecast_note": ("Projection uses the %d-calendar-day mean daily spend, idle days "
405
445
  "included as zero — the same rate as the "
@@ -410,7 +450,7 @@ class Analytics:
410
450
  for key, used_val, rate, label in (
411
451
  ("monthly_cost_allowance_usd", used_cost, burn, "cost"),
412
452
  ("monthly_token_allowance", used_tokens, (tok_burn or tok_daily), "tokens"),
413
- ("monthly_request_allowance", used_req, used_req / elapsed, "requests"),
453
+ ("monthly_request_allowance", used_req, req_burn, "requests"),
414
454
  ):
415
455
  allowance = lim.get(key)
416
456
  if not allowance:
@@ -419,7 +459,7 @@ class Analytics:
419
459
  remaining = allowance - used_val
420
460
  pct = 100.0 * used_val / allowance
421
461
  days_left = (remaining / rate) if rate > 0 and remaining > 0 else None
422
- proj = used_val + rate * bp["remaining_days"]
462
+ proj = used_val + rate * ahead
423
463
  out["allowances"][label] = {
424
464
  "configured": True, "allowance": allowance, "used": used_val,
425
465
  "remaining": remaining, "used_pct": round(pct, 1),
@@ -446,29 +486,64 @@ class Analytics:
446
486
  # ---------------- timeline ----------------
447
487
  def timeline(self, f=None, grain="day"):
448
488
  w, p = self.where(f)
449
- col = "r.day" if grain == "day" else "substr(r.ts,1,13)"
450
- return self.q(f"""
489
+ col = "r.day" if grain == "day" else self._hour_bucket("r.day", "r.hour", "r.ts")
490
+ rows = self.q(f"""
451
491
  SELECT {col} bucket, COUNT(*) requests,
452
492
  COUNT(DISTINCT r.session_id) sessions,
453
- COUNT(DISTINCT r.prompt_id) prompts,
454
493
  SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
455
494
  SUM(r.cache_read_tokens) cache_read_tokens,
456
495
  SUM(r.cache_write_tokens) cache_write_tokens,
457
496
  SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
458
497
  AVG(r.context_tokens) avg_context
459
498
  FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
499
+ # A prompt is counted once, in the bucket of its first request, so the buckets
500
+ # add up to the prompt total even when a prompt runs past midnight.
501
+ first = "MIN(r.day)" if grain == "day" else f"MIN({self._hour_bucket('r.day', 'r.hour', 'r.ts')})"
502
+ prompts = {x["b"]: x["n"] for x in self.q(f"""
503
+ SELECT b, COUNT(*) n FROM (SELECT {first} b FROM requests r
504
+ WHERE {w} AND r.day <> '' AND r.prompt_id IS NOT NULL GROUP BY r.prompt_id) GROUP BY b""", p)}
505
+ for r in rows:
506
+ r["prompts"] = prompts.get(r["bucket"], 0)
507
+ if grain != "day" or not rows:
508
+ return rows
509
+ # Idle days are part of the range: fill them with zeros so charts keep the gaps.
510
+ by_day = {r["bucket"]: r for r in rows}
511
+ start = _d((f or {}).get("start") or rows[0]["bucket"])
512
+ end = _d((f or {}).get("end") or rows[-1]["bucket"])
513
+ end = min(end, max(self.data_end(), _d(rows[-1]["bucket"])))
514
+ out, cur = [], start
515
+ while cur <= end:
516
+ k = cur.isoformat()
517
+ out.append(by_day.get(k) or {"bucket": k, "requests": 0, "sessions": 0, "prompts": 0,
518
+ "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0,
519
+ "cache_write_tokens": 0, "tokens": 0, "cost": 0.0,
520
+ "avg_context": None})
521
+ cur += timedelta(days=1)
522
+ return out
523
+
524
+ def _local_cols(self):
525
+ """True when day/hour were written in local time (see finops/localtime.py)."""
526
+ return self.meta.get("day_basis") == "local"
527
+
528
+ def _hour_bucket(self, day, hour, ts):
529
+ if self._local_cols():
530
+ return f"({day} || 'T' || printf('%02d', {hour}))"
531
+ return f"strftime('%Y-%m-%dT%H', {ts}, 'localtime')" # warehouse from an older version
460
532
 
461
533
  def heatmap(self, f=None):
462
- """Spend by weekday x hour, in this machine's local time (Monday first).
534
+ """Spend by weekday x hour, in your local time (Monday first).
463
535
 
464
536
  Transcripts stamp UTC; bucketing on that would put a 10am IST session at
465
- 4am. SQLite's 'localtime' modifier applies the local offset, half-hour
466
- zones included. Date filters still apply to UTC days like every view.
537
+ 4am. The day and hour columns are written in local time when the warehouse is
538
+ built (honouring CLAUDE_FINOPS_TZ); a warehouse built by an older version falls
539
+ back to SQLite's 'localtime', the computer's own zone.
467
540
  """
468
541
  w, p = self.where(f)
542
+ wd = "strftime('%w', r.day)" if self._local_cols() else "strftime('%w', r.ts, 'localtime')"
543
+ hr = "r.hour" if self._local_cols() else "strftime('%H', r.ts, 'localtime')"
469
544
  rows = self.q(f"""
470
- SELECT CAST(strftime('%w', r.ts, 'localtime') AS INTEGER) wd,
471
- CAST(strftime('%H', r.ts, 'localtime') AS INTEGER) hr,
545
+ SELECT CAST({wd} AS INTEGER) wd,
546
+ CAST({hr} AS INTEGER) hr,
472
547
  COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
473
548
  COUNT(*) requests
474
549
  FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
@@ -480,7 +555,7 @@ class Analytics:
480
555
  c = grid[((r["wd"] + 6) % 7, r["hr"])]
481
556
  c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
482
557
  return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
483
- "tz": time.strftime("%Z"), "cost_basis": "estimated"}
558
+ "tz": os.environ.get("CLAUDE_FINOPS_TZ") or time.strftime("%Z"), "cost_basis": "estimated"}
484
559
 
485
560
  # ---------------- models ----------------
486
561
  def models(self, f=None):
@@ -507,9 +582,12 @@ class Analytics:
507
582
  COUNT(*) n, AVG(r.context_tokens) avg_ctx
508
583
  FROM requests r WHERE {w}
509
584
  GROUP BY r.model, r.priced_as, r.unpriced_long_context""", p):
510
- u = util.setdefault(v["model"], {"num": 0.0, "den": 0, "over": 0, "long": 0})
585
+ u = util.setdefault(v["model"], {"num": 0.0, "den": 0, "over": 0, "long": 0, "nowin": 0})
511
586
  win = self.pricing.context_window(v["priced_as"] or v["model"])
512
- if v["u"] or not win:
587
+ if not win:
588
+ u["nowin"] += v["n"] # no known window: neither "over" nor a utilisation
589
+ continue
590
+ if v["u"]:
513
591
  u["over"] += v["n"]
514
592
  continue
515
593
  u["num"] += v["n"] * 100.0 * (v["avg_ctx"] or 0) / win
@@ -521,6 +599,9 @@ class Analytics:
521
599
  r["tier"] = self.pricing.tier(r["model"])
522
600
  r["context_window"] = self.pricing.context_window(r["model"])
523
601
  r["pricing_known"] = bool(r["model_known"])
602
+ # an unlisted Claude model priced as its newest listed sibling (pricing.py)
603
+ r["priced_as_family"] = (None if r["pricing_known"]
604
+ else self.pricing.rates(r["model"]).get("fallback_name"))
524
605
  r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
525
606
  r["token_pct"] = round(100.0 * r["tokens"] / tt, 1)
526
607
  r["cost_per_1k_output"] = (1000.0 * r["cost"] / r["output_tokens"]) if r["output_tokens"] else None
@@ -530,16 +611,21 @@ class Analytics:
530
611
  u = util.get(r["model"], {})
531
612
  r["utilization_pct"] = round(u["num"] / u["den"], 1) if u.get("den") else None
532
613
  r["over_window_requests"] = u.get("over", 0)
614
+ r["unknown_window_requests"] = u.get("nowin", 0)
533
615
  r["long_context_requests"] = u.get("long", 0)
534
616
  priced = [r for r in rows if r["tokens"] and r["tier"] != "none"]
617
+ # "Most token-efficient" and "best cost per output" compare paid models with a
618
+ # real sample: one request on a free local model would otherwise win both.
619
+ total_req = sum(r["requests"] for r in rows) or 1
620
+ rated = [r for r in priced if r["cost"] > 0 and r["requests"] >= max(20, total_req * 0.005)]
535
621
  superlatives = {}
536
622
  if priced:
537
623
  superlatives = {
538
624
  "most_expensive": max(priced, key=lambda r: r["cost"])["model"],
539
625
  "most_used": max(priced, key=lambda r: r["requests"])["model"],
540
- "most_token_efficient": max(priced, key=lambda r: r["output_per_input"])["model"],
626
+ "most_token_efficient": max(rated or priced, key=lambda r: r["output_per_input"])["model"],
541
627
  "best_cost_per_output": min(
542
- [r for r in priced if r["cost_per_1k_output"]],
628
+ [r for r in (rated or priced) if r["cost_per_1k_output"]],
543
629
  key=lambda r: r["cost_per_1k_output"], default={}).get("model"),
544
630
  }
545
631
  return {"rows": rows, "superlatives": superlatives, "basis": "estimated"}
@@ -556,12 +642,15 @@ class Analytics:
556
642
  SUM(r.est_cost_usd) cost, GROUP_CONCAT(DISTINCT r.model) models
557
643
  FROM requests r JOIN projects pr ON pr.id = r.project_id
558
644
  WHERE {w} GROUP BY pr.id ORDER BY cost DESC""", p)
645
+ # Files touched by the prompts in range, not every file the project ever touched.
646
+ files = {x["project_id"]: x["n"] for x in self.q(f"""
647
+ SELECT ft.project_id, COUNT(DISTINCT ft.path) n FROM files_touched ft
648
+ WHERE ft.prompt_id IN (SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
649
+ GROUP BY ft.project_id""", p)}
559
650
  for r in rows:
560
651
  r["avg_cost_per_session"] = r["cost"] / r["sessions"] if r["sessions"] else 0
561
652
  r["avg_cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else None
562
- r["files_touched"] = self.one(
563
- "SELECT COUNT(DISTINCT path) n FROM files_touched WHERE project_id=?",
564
- (r["project_id"],))["n"]
653
+ r["files_touched"] = files.get(r["project_id"], 0)
565
654
  b = self.settings["budgets"].get("per_project_usd", {}).get(r["name"])
566
655
  r["budget_usd"] = b
567
656
  r["budget_used_pct"] = round(100.0 * r["cost"] / b, 1) if b else None
@@ -615,13 +704,17 @@ class Analytics:
615
704
  f"JOIN sessions s ON s.id = r.session_id "
616
705
  f"JOIN projects pr ON pr.id = s.project_id WHERE {w}", p)["n"]
617
706
 
707
+ @staticmethod
708
+ def _prompt_search(search):
709
+ if not search:
710
+ return "", []
711
+ return (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? ESCAPE '\\' "
712
+ "OR pr.session_id LIKE ? ESCAPE '\\' OR pr.category LIKE ? ESCAPE '\\')",
713
+ [like(search)] * 3)
714
+
618
715
  def prompts_total(self, f=None, search=None):
619
716
  w, p = self.where(f)
620
- extra, ep = "", []
621
- if search:
622
- extra = (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? "
623
- "OR pr.session_id LIKE ? OR pr.category LIKE ?)")
624
- ep = [f"%{search}%"] * 3
717
+ extra, ep = self._prompt_search(search)
625
718
  return self.one(f"SELECT COUNT(DISTINCT r.prompt_id) n FROM requests r "
626
719
  f"WHERE {w}{extra} AND r.prompt_id IS NOT NULL", p + ep)["n"]
627
720
 
@@ -630,11 +723,9 @@ class Analytics:
630
723
  ob = {"cost": "pcost DESC", "tokens": "ptokens DESC", "recent": "pr.ts DESC",
631
724
  "cheapest": "pcost ASC", "efficiency": "efficiency DESC",
632
725
  "length": "pr.char_len DESC"}.get(order, "pcost DESC")
633
- extra, ep = "", []
634
- if search:
635
- extra = (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? "
636
- "OR pr.session_id LIKE ? OR pr.category LIKE ?)")
637
- ep = [f"%{search}%"] * 3
726
+ extra, ep = self._prompt_search(search)
727
+ # "Cheapest" ranks prompts you paid for: $0 ones ran on free/local models.
728
+ having = "HAVING SUM(r.est_cost_usd) > 0" if order == "cheapest" else ""
638
729
  # Aggregate requests per prompt first, rank, and only then join the (large) prompt
639
730
  # text for the page being returned. Grouping with the text attached was ~2s a call.
640
731
  if order in ("recent", "length"):
@@ -653,7 +744,7 @@ class Analytics:
653
744
  GROUP_CONCAT(DISTINCT r.model) models,
654
745
  (CAST(SUM(r.output_tokens) AS REAL) / MAX(SUM(r.billable_tokens),1)) efficiency
655
746
  FROM requests r WHERE {w}{extra} AND r.prompt_id IS NOT NULL
656
- GROUP BY r.prompt_id {inner_ob} {inner_lim})
747
+ GROUP BY r.prompt_id {having} {inner_ob} {inner_lim})
657
748
  SELECT pr.id prompt_id, pr.uuid, pr.ts, pr.day, pr.text, pr.char_len, pr.word_len,
658
749
  pr.category, pr.category_confidence, pr.category_evidence, pr.source,
659
750
  pr.session_id, proj.name project, proj.id project_id, s.title session_title,
@@ -682,12 +773,23 @@ class Analytics:
682
773
  r["category_evidence"] = []
683
774
  return rows
684
775
 
685
- def prompt_detail(self, pid):
776
+ def _in_range(self, f, col, val):
777
+ """The same item's totals inside the selected dates, when a range is set."""
778
+ if not f or not (f.get("start") or f.get("end")):
779
+ return None
780
+ rng = {"start": f.get("start"), "end": f.get("end")}
781
+ w, p = self.where(rng)
782
+ return self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) cost, COUNT(*) requests,
783
+ COUNT(DISTINCT r.prompt_id) prompts, COALESCE(SUM(r.billable_tokens),0) tokens
784
+ FROM requests r WHERE {w} AND r.{col} = ?""", p + [val])
785
+
786
+ def prompt_detail(self, pid, f=None):
686
787
  p = self.one("""SELECT pr.*, proj.name project, s.title session_title, s.git_branch
687
788
  FROM prompts pr JOIN projects proj ON proj.id=pr.project_id
688
789
  LEFT JOIN sessions s ON s.id=pr.session_id WHERE pr.id=?""", (pid,))
689
790
  if not p:
690
791
  return {"error": "not found"}
792
+ p["in_range"] = self._in_range(f, "prompt_id", pid)
691
793
  p["requests"] = self.q(
692
794
  "SELECT ts, model, effort, input_tokens, output_tokens, thinking_tokens,"
693
795
  " cache_read_tokens, cache_write_tokens, billable_tokens, context_tokens,"
@@ -708,11 +810,12 @@ class Analytics:
708
810
  p["category_evidence"] = []
709
811
  return p
710
812
 
711
- def session_detail(self, sid):
813
+ def session_detail(self, sid, f=None):
712
814
  s = self.one("""SELECT s.*, pr.name project FROM sessions s
713
815
  JOIN projects pr ON pr.id=s.project_id WHERE s.id=?""", (sid,))
714
816
  if not s:
715
817
  return {"error": "not found"}
818
+ s["in_range"] = self._in_range(f, "session_id", sid)
716
819
  s["prompts"] = self.q("""
717
820
  SELECT pr.id prompt_id, pr.ts, pr.category, substr(pr.text,1,220) preview,
718
821
  pr.char_len, pr.billable_tokens ptokens, pr.est_cost_usd pcost,
@@ -740,8 +843,16 @@ class Analytics:
740
843
  AVG(pr.char_len) avg_prompt_chars
741
844
  FROM prompts pr JOIN requests r ON r.prompt_id = pr.id
742
845
  WHERE {w} GROUP BY pr.category ORDER BY cost DESC""", p)
846
+ # Averages per prompt, not per request: joined to requests, a prompt with 200
847
+ # requests would otherwise count 200 times in its category's average length.
848
+ per_prompt = {x["category"]: x for x in self.q(f"""
849
+ SELECT pr.category, AVG(pr.category_confidence) confidence, AVG(pr.char_len) avg_prompt_chars
850
+ FROM prompts pr WHERE pr.id IN (SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
851
+ GROUP BY pr.category""", p)}
743
852
  tc = sum(r["cost"] for r in rows) or 1
744
853
  for r in rows:
854
+ pp = per_prompt.get(r["category"]) or {}
855
+ r["confidence"], r["avg_prompt_chars"] = pp.get("confidence"), pp.get("avg_prompt_chars")
745
856
  r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
746
857
  r["cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else 0
747
858
  return {"rows": rows, "basis": "estimated",
@@ -849,16 +960,17 @@ class Analytics:
849
960
  WHERE s.id IN ({ph})""", ids)}
850
961
 
851
962
  def downsample(traj):
963
+ """Evenly spaced points, each with its real 1-based request number."""
852
964
  n = len(traj)
853
- if n <= trajectory_points:
854
- return traj
855
- step = n / trajectory_points
856
- return [traj[int(i * step)] for i in range(trajectory_points)]
965
+ idx = range(n) if n <= trajectory_points else \
966
+ [int(i * n / trajectory_points) for i in range(trajectory_points)]
967
+ return [traj[i] for i in idx], [i + 1 for i in idx]
857
968
 
858
969
  out_sessions = []
859
970
  for s in ranked:
860
971
  m = meta.get(s["session_id"], {})
861
- traj = downsample(s["traj"])
972
+ traj, traj_idx = downsample(s["traj"])
973
+ cum = list(_cumsum(cost for _, cost in s["traj"]))
862
974
  out_sessions.append({
863
975
  "session_id": s["session_id"], "title": m.get("title"), "project": m.get("project"),
864
976
  "requests": s["requests"], "cost_usd": s["cost_usd"], "max_context": s["max_context"],
@@ -870,7 +982,8 @@ class Analytics:
870
982
  "cost_after_pct": {str(t): (round(100.0 * s["cost_after"][t] / s["cost_usd"], 1)
871
983
  if s["cost_usd"] else 0.0) for t in thresholds},
872
984
  "context_trajectory": [c for c, _ in traj],
873
- "cumulative_cost": [round(x, 4) for x in _cumsum(cost for _, cost in traj)],
985
+ "trajectory_index": traj_idx,
986
+ "cumulative_cost": [round(cum[i - 1], 4) for i in traj_idx],
874
987
  "basis": "actual",
875
988
  })
876
989
  return {
@@ -961,8 +1074,9 @@ class Analytics:
961
1074
  % (sum(r["n"] for r in unpriced), ", ".join(sorted({r["model"] for r in unpriced})))
962
1075
  if unpriced else ""),
963
1076
  "unknown_message": (
964
- "%d requests used a claude-* model with no entry in pricing.json at "
965
- "all, so their cost is a fallback guess. Add pricing.json entries for: %s."
1077
+ "%d requests used a claude-* model with no entry in pricing.json, so they "
1078
+ "are priced as the newest listed model of the same family (or $0 when there "
1079
+ "is none). Add pricing.json entries for: %s."
966
1080
  % (unknown_requests, ", ".join(unknown_models))
967
1081
  if unknown_rows else ""),
968
1082
  "basis": "estimated",
@@ -1070,6 +1184,8 @@ class Analytics:
1070
1184
  "reads": t["cr"], "writes": t["cw"],
1071
1185
  "cost_split": cache_cost,
1072
1186
  "breakeven_margin": breakeven_margin,
1187
+ # unclamped: the margin above is capped at ±1 for scoring, this is the real ratio
1188
+ "breakeven_ratio": ((discount - premium) / cache_cost_total) if cache_cost_total else None,
1073
1189
  "cost_with_cache": t["cost"], "cost_without_cache": t["cost_nc"],
1074
1190
  # Named for what it is. This used to be "estimated_savings_usd", and the
1075
1191
  # UI called it a saving; it is the gap to a run that never happened.
@@ -1111,14 +1227,28 @@ class Analytics:
1111
1227
  WHERE {w} GROUP BY s.id HAVING MAX(r.context_tokens) >= ?
1112
1228
  ORDER BY cost DESC LIMIT 20""", p + [thr])
1113
1229
  agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
1114
- windows = [v.get("context_window") for v in self.pricing.models.values() if v.get("context_window")]
1230
+ # Utilisation against each request's own model window, weighted by requests —
1231
+ # not against the largest window anywhere in the price table (a Gemini window
1232
+ # would set the scale for Claude-only usage).
1233
+ num = den = 0.0
1234
+ wins = defaultdict(int)
1235
+ for v in self.q(f"""SELECT COALESCE(r.priced_as, r.model) m, COUNT(*) n, AVG(r.context_tokens) a
1236
+ FROM requests r WHERE {w} GROUP BY 1""", p):
1237
+ win = self.pricing.context_window(v["m"])
1238
+ if win:
1239
+ num += v["n"] * (v["a"] or 0) / win
1240
+ den += v["n"]
1241
+ wins[win] += v["n"]
1242
+ util_pct = round(100.0 * num / den, 1) if den else None
1243
+ typical_window = max(wins, key=wins.get) if wins else None
1115
1244
  return {
1116
1245
  "buckets": buckets, "avg_context": agg["a"], "max_context": agg["m"],
1117
1246
  "threshold": thr,
1118
1247
  "large_context_requests": big["n"], "large_context_cost": big["c"],
1119
1248
  "large_context_cost_pct": round(100.0 * big["c"] / total_cost, 1),
1120
1249
  "heavy_sessions": heavy_sessions,
1121
- "typical_context_window": max(windows) if windows else None,
1250
+ "typical_context_window": typical_window,
1251
+ "context_utilization_pct": util_pct,
1122
1252
  "basis": "actual token counts, estimated cost",
1123
1253
  }
1124
1254
 
@@ -1154,41 +1284,74 @@ class Analytics:
1154
1284
  "affected": {key: [e[key] for e in evidence if e.get(key)]},
1155
1285
  "recommended_action": action, "basis": "estimated"})
1156
1286
 
1157
- # 1. very long prompts — excess is the share of spend attributable to
1158
- # re-sending the oversized prompt text on every turn of the same request.
1159
- rows = self.q(f"""SELECT pr.id prompt_id, pr.char_len, substr(pr.text,1,160) preview,
1160
- pr.session_id, pr.est_cost_usd cost, pr.billable_tokens tokens,
1161
- pr.request_count requests
1162
- FROM prompts pr WHERE {pfilter} AND pr.char_len >= ?
1163
- ORDER BY cost DESC LIMIT 15""", p + [rules["long_prompt_chars"]])
1287
+ # Who wrote a prompt matters for the advice: a person typed it, a script sent it
1288
+ # through the SDK, or Claude Code wrote it itself (a continuation summary after
1289
+ # compaction). "Stop pasting" is only advice for the first.
1290
+ human = ("(pr.source IS NULL OR pr.source NOT IN ('sdk')) "
1291
+ "AND pr.text NOT LIKE 'This session is being continued%'")
1292
+ sdk = "pr.source = 'sdk'"
1293
+
1294
+ # 1. very long prompts — no excess claimed; split by who sent them.
1164
1295
  budget_chars = rules["long_prompt_chars"]
1165
- for r in rows:
1166
- r["excess"] = 0.0
1167
- if rows:
1168
- add("high", "long_prompts",
1169
- f"{len(rows)} very long prompts (>{budget_chars:,} chars)",
1170
- "Long pasted prompts inflate the cached prefix re-sent on every following turn.",
1171
- rows, "Move large pasted context into a file and reference it, or summarize first.",
1172
- "prompt_id",
1173
- "none claimed — flagged for review only")
1296
+ for kind, cond, title, detail, action in (
1297
+ ("long_prompts", human, "very long prompts you sent",
1298
+ "Long pasted prompts inflate the cached prefix re-sent on every following turn.",
1299
+ "Move large pasted context into a file and reference it, or summarize first."),
1300
+ ("long_sdk_prompts", sdk, "very long prompts sent by scripts (SDK)",
1301
+ "A script or pipeline sends these through the Claude Code SDK; each one starts a "
1302
+ "turn with that whole text.",
1303
+ "Trim what the script sends per call: pass file paths instead of file contents, "
1304
+ "and keep the stable part first so it is cached."),
1305
+ ):
1306
+ rows = self.q(f"""SELECT pr.id prompt_id, pr.char_len, substr(pr.text,1,160) preview,
1307
+ pr.session_id, pr.est_cost_usd cost, pr.billable_tokens tokens,
1308
+ pr.request_count requests
1309
+ FROM prompts pr WHERE {pfilter} AND pr.char_len >= ? AND {cond}
1310
+ ORDER BY cost DESC""", p + [budget_chars])
1311
+ for r in rows:
1312
+ r["excess"] = 0.0
1313
+ if rows:
1314
+ add("medium", kind, f"{len(rows)} {title} (≥{budget_chars:,} chars)",
1315
+ detail, rows, action, "prompt_id", "none claimed — flagged for review only")
1174
1316
 
1175
1317
  # 2. duplicate prompts — excess is the cost of the repeats, not the first ask,
1176
1318
  # counted only within a single session so cross-session coincidences don't count.
1177
- dups = self.q(f"""SELECT pr.norm_hash, pr.session_id, COUNT(*) n, substr(MIN(pr.text),1,160) preview,
1178
- SUM(pr.est_cost_usd) cost, SUM(pr.billable_tokens) tokens,
1179
- MIN(pr.est_cost_usd) first_cost, GROUP_CONCAT(pr.id) prompt_ids
1180
- FROM prompts pr WHERE {pfilter} AND pr.char_len > 25
1181
- GROUP BY pr.norm_hash, pr.session_id HAVING n > 1
1182
- ORDER BY cost DESC LIMIT 15""", p)
1183
- for d in dups:
1184
- d["prompt_id"] = int(d["prompt_ids"].split(",")[0])
1185
- d["affected_ids"] = [int(x) for x in d["prompt_ids"].split(",")]
1186
- d["excess"] = max(d["cost"] - d["first_cost"], 0) # repeats only
1319
+ window = timedelta(minutes=rules.get("duplicate_window_minutes", 60))
1320
+ occ = self.q(f"""SELECT pr.id, pr.norm_hash, pr.session_id, pr.ts, substr(pr.text,1,160) preview,
1321
+ pr.est_cost_usd cost, pr.billable_tokens tokens
1322
+ FROM prompts pr WHERE {pfilter} AND pr.char_len > 25 AND {human}
1323
+ AND (pr.norm_hash, pr.session_id) IN (
1324
+ SELECT norm_hash, session_id FROM prompts GROUP BY 1, 2 HAVING COUNT(*) > 1)
1325
+ ORDER BY pr.session_id, pr.norm_hash, pr.ts""", p)
1326
+ groups = defaultdict(list)
1327
+ for o in occ:
1328
+ groups[(o["norm_hash"], o["session_id"])].append(o)
1329
+ dups = []
1330
+ for (_, sid), items in groups.items():
1331
+ # A repeat is an ask sent again within the window of the previous one; the
1332
+ # first ask of each run is the real work and is never counted as excess.
1333
+ ids, repeat_cost, prev = [], 0.0, None
1334
+ for o in items:
1335
+ t = localtime.parse(o["ts"])
1336
+ if prev is not None and t and prev["t"] and t - prev["t"] <= window:
1337
+ if not ids:
1338
+ ids.append(prev["id"])
1339
+ ids.append(o["id"])
1340
+ repeat_cost += o["cost"] or 0.0
1341
+ prev = {"t": t, "id": o["id"]}
1342
+ if ids:
1343
+ inc = [o for o in items if o["id"] in ids]
1344
+ dups.append({"norm_hash": items[0]["norm_hash"], "session_id": sid, "n": len(ids),
1345
+ "preview": items[0]["preview"], "cost": sum(o["cost"] or 0 for o in inc),
1346
+ "tokens": sum(o["tokens"] or 0 for o in inc), "prompt_id": ids[0],
1347
+ "affected_ids": ids, "excess": repeat_cost})
1348
+ dups.sort(key=lambda d: -d["cost"])
1187
1349
  if dups:
1188
- add("high", "duplicate_prompts", f"{len(dups)} prompts repeated more than once",
1350
+ add("high", "duplicate_prompts",
1351
+ f"{len(dups)} prompts repeated within {rules.get('duplicate_window_minutes', 60)} minutes",
1189
1352
  "The same request was sent again, re-paying for context each time.",
1190
1353
  dups, "Reuse the earlier answer, or capture the recurring request as a slash command.",
1191
- "prompt_id", "cost of the repeat occurrences, excluding the first ask")
1354
+ "prompt_id", "cost of the repeats, excluding the first ask of each run")
1192
1355
 
1193
1356
  # 3. low-yield sessions — excess is what the session cost ABOVE what the same
1194
1357
  # output would have cost at your own median session efficiency.
@@ -1210,7 +1373,7 @@ class Analytics:
1210
1373
  proj.name project FROM sessions s JOIN projects proj ON proj.id=s.project_id
1211
1374
  WHERE {sfilter} AND s.billable_tokens > ?
1212
1375
  AND (CAST(s.output_tokens AS REAL)/MAX(s.billable_tokens,1)) < ?
1213
- ORDER BY cost DESC LIMIT 15""",
1376
+ ORDER BY cost DESC""",
1214
1377
  p + [rules["huge_session_tokens"], cutoff])
1215
1378
  for r in low:
1216
1379
  r["excess"] = 0.0
@@ -1248,7 +1411,7 @@ class Analytics:
1248
1411
  AND pr.output_tokens < ? AND pr.tool_calls = 0 AND pr.est_cost_usd > 0
1249
1412
  AND EXISTS (SELECT 1 FROM requests r2 WHERE r2.prompt_id=pr.id
1250
1413
  AND r2.model IN ({ph}))
1251
- ORDER BY cost DESC LIMIT 15""",
1414
+ ORDER BY cost DESC""",
1252
1415
  p + [rules["simple_task_output_tokens"]] + frontier)
1253
1416
  for r in small:
1254
1417
  r["excess"] = 0.0
@@ -1257,7 +1420,9 @@ class Analytics:
1257
1420
  f"{len(small)} frontier-model prompts produced under "
1258
1421
  f"{rules['simple_task_output_tokens']} output tokens",
1259
1422
  "Short, simple turns running on the most expensive model tier.",
1260
- small, "Route short lookups and confirmations to a cheaper model tier.",
1423
+ small, "For standalone lookups, start a fresh session on a cheaper model. "
1424
+ "Switching models mid-session is not cheaper: the new model has to "
1425
+ "write the whole conversation into its own cache first.",
1261
1426
  "prompt_id", "none claimed")
1262
1427
 
1263
1428
  # 5. tool loops — excess is the share of the loop beyond the threshold.
@@ -1265,11 +1430,11 @@ class Analytics:
1265
1430
  loops = self.q(f"""SELECT pr.id prompt_id, substr(pr.text,1,160) preview, pr.session_id,
1266
1431
  pr.tool_calls tools, pr.est_cost_usd cost, pr.billable_tokens tokens
1267
1432
  FROM prompts pr WHERE {pfilter} AND pr.tool_calls > ?
1268
- ORDER BY cost DESC LIMIT 15""", p + [loop_calls])
1433
+ ORDER BY cost DESC""", p + [loop_calls])
1269
1434
  for r in loops:
1270
1435
  r["excess"] = 0.0
1271
1436
  if loops:
1272
- add("medium", "tool_loops", f"{len(loops)} prompts triggered {loop_calls}+ tool calls",
1437
+ add("medium", "tool_loops", f"{len(loops)} prompts made more than {loop_calls} tool calls",
1273
1438
  "Long agentic loops re-send the whole conversation each step, so cost grows super-linearly.",
1274
1439
  loops, "Split the task, or give more precise instructions up front.", "prompt_id",
1275
1440
  "none claimed")
@@ -1296,7 +1461,7 @@ class Analytics:
1296
1461
  if net > 0:
1297
1462
  r["excess"] = net
1298
1463
  flagged.append(r)
1299
- poor = flagged[:15]
1464
+ poor = flagged
1300
1465
  if poor:
1301
1466
  add("medium", "poor_cache_reuse", f"{len(poor)} sessions wrote cache they barely reused",
1302
1467
  "Cache writes cost more than plain input; they only pay off when read back repeatedly.",
@@ -1309,7 +1474,7 @@ class Analytics:
1309
1474
  s.request_count requests, s.est_cost_usd cost
1310
1475
  FROM sessions s JOIN projects proj ON proj.id=s.project_id
1311
1476
  WHERE {sfilter} AND s.duration_s > ? AND s.request_count < 30
1312
- ORDER BY s.duration_s DESC LIMIT 10""",
1477
+ ORDER BY s.duration_s DESC""",
1313
1478
  p + [rules["idle_gap_minutes"] * 60 * 4])
1314
1479
  for r in idle:
1315
1480
  r["excess"] = 0.0
@@ -1357,7 +1522,7 @@ class Analytics:
1357
1522
  len(e.get("affected_ids") or [1]), 1))
1358
1523
  elif e.get("session_id"):
1359
1524
  s_excess[e["session_id"]] = max(s_excess.get(e["session_id"], 0.0), ex)
1360
- counted_sessions = set(s_excess)
1525
+ counted_sessions = {k for k, v in s_excess.items() if v > 0}
1361
1526
  outside = 0.0
1362
1527
  if p_excess:
1363
1528
  owner = {r["id"]: r["session_id"] for r in self.q(
@@ -1366,7 +1531,13 @@ class Analytics:
1366
1531
  outside = sum(v for pid_, v in p_excess.items()
1367
1532
  if owner.get(pid_) not in counted_sessions)
1368
1533
  excess = min(sum(s_excess.values()) + outside, exposed)
1534
+ high = sum(fd["est_cost_usd"] for fd in findings if fd["severity"] == "high")
1535
+ for fd in findings:
1536
+ fd["count"] = len(fd["evidence"])
1537
+ fd["evidence"] = fd["evidence"][:15]
1538
+ fd["affected"] = {k: v[:15] for k, v in fd["affected"].items()}
1369
1539
  return {"findings": findings,
1540
+ "high_severity_pct": round(min(100.0 * high / total, 100.0), 1) if total else 0,
1370
1541
  "estimated_excess_usd": excess,
1371
1542
  "excess_pct": round(100.0 * excess / total, 1) if total else 0,
1372
1543
  "exposed_cost_usd": exposed,
@@ -1472,42 +1643,58 @@ class Analytics:
1472
1643
  "estimated_savings_usd": None, "estimated_savings_pct": None,
1473
1644
  "caveat": "No saving is claimed: what an uncached run would have cost is a "
1474
1645
  "counterfactual, not money you avoided.",
1475
- "basis": "actual",
1646
+ "basis": "estimated",
1476
1647
  })
1477
1648
 
1478
- ctx = self.context_analysis(f)
1479
- if ctx["large_context_cost_pct"] > 15:
1649
+ # Same definition as Context hygiene and the scorecard: main-conversation turns
1650
+ # only (subagents run on their own prefix), at or above the threshold.
1651
+ thr = self.settings["waste_rules"]["large_context_tokens"]
1652
+ hy = self.hygiene(f, top=0)
1653
+ big = hy["above"].get(str(thr))
1654
+ if big is None:
1655
+ w2, p2 = self.where(f)
1656
+ n = self.one(f"""SELECT COUNT(*) n, COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
1657
+ WHERE {w2} AND r.is_sidechain=0 AND r.context_tokens >= ?""", p2 + [thr])
1658
+ big = {"requests": n["n"], "cost_usd": n["c"],
1659
+ "share_pct": round(100.0 * n["c"] / hy["cost_usd"], 1) if hy["cost_usd"] else 0.0}
1660
+ if big["share_pct"] > 15:
1480
1661
  recs.append({
1481
1662
  "type": "context_reduction", "confidence": "observed",
1482
- "title": f"{ctx['large_context_cost_pct']}% of spend comes from >"
1483
- f"{ctx['threshold']//1000}K-context requests",
1484
- "detail": (f"{ctx['large_context_requests']:,} requests re-sent a large prefix "
1663
+ "title": f"{big['share_pct']}% of spend comes from requests at or above "
1664
+ f"{thr//1000}K context",
1665
+ "detail": (f"{big['requests']:,} main-conversation requests re-sent a large prefix "
1485
1666
  f"on every turn. /compact or a fresh session resets it; how much "
1486
1667
  f"that would have saved depends on what the work needed, and is "
1487
1668
  f"not estimated here."),
1488
- "scope": f"{ctx['large_context_requests']:,} requests",
1489
- "actual_cost_usd": ctx["large_context_cost"],
1669
+ "scope": f"{big['requests']:,} requests",
1670
+ "actual_cost_usd": big["cost_usd"],
1490
1671
  "estimated_alternative_cost_usd": None,
1491
1672
  "estimated_savings_usd": None, "estimated_savings_pct": None,
1492
1673
  "caveat": "Observed share of spend. No reduction is assumed.",
1493
- "basis": "actual",
1674
+ "basis": "estimated",
1494
1675
  })
1495
1676
  recs.sort(key=lambda r: -(r.get("actual_cost_usd") or 0))
1496
1677
  return {"recommendations": recs, "basis": "actual"}
1497
1678
 
1498
1679
  # ---------------- forecast ----------------
1499
1680
  def forecast(self, f=None):
1681
+ # A forecast always looks forward from your latest data: the page's date range
1682
+ # would otherwise turn "August" into a $0 forecast for the current period.
1683
+ f = dict(f or {}, start=None, end=None)
1500
1684
  w, p = self.where(f)
1501
1685
  bp = self.billing_period()
1502
1686
  rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens
1503
1687
  FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
1504
1688
  if not rows:
1505
1689
  return {"available": False, "message": "No usage in the selected range."}
1506
- # calendar days, idle days as zero, excluding today (still partial) — the rate
1507
- # below is multiplied by calendar days remaining, so a per-active-day mean
1508
- # would overstate every scenario and a partial today would understate it
1509
- yesterday = (self.today() - timedelta(days=1)).isoformat()
1510
- recent = [r for r in self.daily_series(f, days=14, end=yesterday)] # complete days only
1690
+ # calendar days, idle days as zero, excluding the last read day (still partial) —
1691
+ # the rate below is multiplied by calendar days remaining, so a per-active-day
1692
+ # mean would overstate every scenario and a partial day would understate it.
1693
+ # Days after the last sync are unread, not idle: they are projected, not zeroed.
1694
+ synced = self.data_end()
1695
+ unread = max((self.today() - max(synced, _d(bp["start"]) - timedelta(days=1))).days, 0)
1696
+ last_full = (synced - timedelta(days=1)).isoformat()
1697
+ recent = [r for r in self.daily_series(f, days=14, end=last_full)] # complete days only
1511
1698
  priced = [r["cost"] for r in recent]
1512
1699
  sample_days = sum(1 for c in priced if c > 0)
1513
1700
  mean = statistics.fmean(priced) if priced else 0.0
@@ -1515,14 +1702,15 @@ class Analytics:
1515
1702
  in_period = [r for r in rows if bp["start"] <= r["day"] <= bp["end"]]
1516
1703
  used = sum(r["cost"] for r in in_period)
1517
1704
  used_tok = sum(r["tokens"] for r in in_period)
1518
- left = bp["remaining_days"]
1705
+ left = bp["remaining_days"] + unread
1519
1706
  insufficient = sample_days < 7
1520
1707
 
1521
1708
  def band(rate, spread=0.0):
1522
1709
  # spend on different days is treated as independent, so the spread of a
1523
1710
  # sum over `left` days grows with sqrt(left), not left
1524
- return {"daily_rate": rate,
1525
- "end_of_period_cost": used + rate * left + spread * (left ** 0.5)}
1711
+ end = used + rate * left + spread * (left ** 0.5)
1712
+ # the daily rate each scenario implies, so the table doesn't repeat one number
1713
+ return {"daily_rate": (end - used) / left if left else rate, "end_of_period_cost": end}
1526
1714
 
1527
1715
  scenarios = {"expected": band(mean)}
1528
1716
  if not insufficient:
@@ -1537,14 +1725,15 @@ class Analytics:
1537
1725
  "available": True,
1538
1726
  "method": ("mean of the last 14 complete calendar days (idle days as zero); "
1539
1727
  "bands are ±1 sd × sqrt(days remaining)"),
1540
- "sample_days": sample_days,
1728
+ "sample_days": sample_days, "window_days": len(recent),
1729
+ "synced_through": synced.isoformat(), "unread_days": unread,
1541
1730
  "insufficient_history": insufficient,
1542
1731
  "daily_mean": mean, "daily_stdev": sd,
1543
1732
  "period_used": used, "period_used_tokens": used_tok,
1544
1733
  "remaining_days": left,
1545
1734
  "scenarios": scenarios,
1546
1735
  "end_of_period_tokens": used_tok + tok_mean * left,
1547
- "estimated_monthly_cost": used + mean * left,
1736
+ "estimated_monthly_cost": mean * 30,
1548
1737
  "basis": "forecast",
1549
1738
  }
1550
1739
  allowance = self.settings["limits"].get("monthly_cost_allowance_usd")
@@ -1603,36 +1792,43 @@ class Analytics:
1603
1792
  out["lines"].append(self._session_budget_line(f, bp, line))
1604
1793
  else:
1605
1794
  out["lines"].append(line("Per-session tokens", None, 0, None, unit="tokens"))
1795
+ # Per-project and per-model lines follow the agent selection like every other line.
1796
+ wa, pa = self.where({"agents": (f or {}).get("agents") or []})
1606
1797
  for proj, bud in (b.get("per_project_usd") or {}).items():
1607
- act = self.one("""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
1798
+ act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
1608
1799
  JOIN projects pr ON pr.id=r.project_id
1609
- WHERE pr.name=? AND r.day>=? AND r.day<=?""",
1610
- (proj, bp["start"], bp["end"]))["c"]
1800
+ WHERE {wa} AND pr.name=? AND r.day>=? AND r.day<=?""",
1801
+ pa + [proj, bp["start"], bp["end"]])["c"]
1611
1802
  out["lines"].append(line(f"Project: {proj}", bud, act, None))
1612
1803
  for model, bud in (b.get("per_model_usd") or {}).items():
1613
- act = self.one("""SELECT COALESCE(SUM(est_cost_usd),0) c FROM requests
1614
- WHERE model=? AND day>=? AND day<=?""",
1615
- (model, bp["start"], bp["end"]))["c"]
1804
+ act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
1805
+ WHERE {wa} AND r.model=? AND r.day>=? AND r.day<=?""",
1806
+ pa + [model, bp["start"], bp["end"]])["c"]
1616
1807
  out["lines"].append(line(f"Model: {self.pricing.display_name(model)}", bud, act, None))
1617
1808
  return out
1618
1809
 
1619
1810
  def budget_suggestions(self, f=None):
1620
1811
  """Figures the Budgets form offers as suggestions: recent spend and session sizes.
1621
1812
 
1622
- Last 30 days up to today, under the current filter; session sizes are Claude Code
1623
- sessions' whole-session billable tokens (a sorted sample, at most 2,000 values),
1624
- so the page can say how many past sessions a budget would have caught.
1813
+ Your last 30 days of data (ending on the last synced day, whatever date range the
1814
+ page shows), under the other filters; session sizes are Claude Code sessions'
1815
+ whole-session billable tokens for sessions active in those 30 days (a sorted sample,
1816
+ at most 2,000 values), so the page can say how many recent sessions a budget would
1817
+ have caught.
1625
1818
  """
1626
- w, p = self.where(f)
1627
- start = (self.today() - timedelta(days=29)).isoformat()
1819
+ w, p = self.where(dict(f or {}, start=None, end=None))
1820
+ end = self.data_end().isoformat()
1821
+ start = (self.data_end() - timedelta(days=29)).isoformat()
1628
1822
  days = [r["c"] for r in self.q(
1629
1823
  f"SELECT r.day, SUM(r.est_cost_usd) c FROM requests r WHERE {w} AND r.day >= ? "
1630
- f"GROUP BY r.day", p + [start])]
1824
+ f"AND r.day <= ? GROUP BY r.day", p + [start, end])]
1825
+ days += [0.0] * (30 - len(days)) # idle days are part of the 30
1631
1826
  tot = self.one(f"SELECT COALESCE(SUM(r.est_cost_usd),0) c, COALESCE(SUM(r.billable_tokens),0) t "
1632
- f"FROM requests r WHERE {w} AND r.day >= ?", p + [start])
1827
+ f"FROM requests r WHERE {w} AND r.day >= ? AND r.day <= ?", p + [start, end])
1633
1828
  sizes = [r["t"] for r in self.q(
1634
1829
  "SELECT billable_tokens t FROM sessions WHERE agent='claude' AND billable_tokens > 0 "
1635
- "ORDER BY t")]
1830
+ "AND id IN (SELECT DISTINCT session_id FROM requests WHERE day >= ? AND day <= ?) "
1831
+ "ORDER BY t", (start, end))]
1636
1832
  if len(sizes) > 2000:
1637
1833
  step = len(sizes) / 2000.0
1638
1834
  sizes = [sizes[int(i * step)] for i in range(2000)]
@@ -1674,8 +1870,11 @@ class Analytics:
1674
1870
  w, p = self.where(f)
1675
1871
  cfg = self.settings["anomaly"]
1676
1872
  found = []
1677
- yesterday = (self.today() - timedelta(days=1)).isoformat()
1678
- series = [d for d in self.daily_series(dict(f or {}, agents=["claude"]), end=yesterday)]
1873
+ # complete days only: the last synced day may be partial
1874
+ last_full = self.data_end() - timedelta(days=1)
1875
+ if (f or {}).get("end"):
1876
+ last_full = min(last_full, _d(f["end"]))
1877
+ series = [d for d in self.daily_series(f, end=last_full.isoformat())]
1679
1878
  priced = [d for d in series if d["cost"] > 0]
1680
1879
  if len(priced) >= 14:
1681
1880
  # Median/MAD, not mean/stdev: unpriced $0 days from agents without pricing
@@ -1693,7 +1892,7 @@ class Analytics:
1693
1892
  if score >= cfg.get("daily_robust_z", 3.5) and ratio >= cfg["daily_ratio"]:
1694
1893
  found.append({
1695
1894
  "severity": "high", "type": "daily_spike", "date": d["day"],
1696
- "title": f"{d['day']} spend was {ratio:.1f}x your daily average",
1895
+ "title": f"{d['day']} spend was {ratio:.1f}x your typical (median) day",
1697
1896
  "detail": f"${d['cost']:,.2f} vs a ${med:,.2f} median priced day (robust z={score:.1f}).",
1698
1897
  "metric_value": d["cost"], "baseline": med, "ratio": round(ratio, 2),
1699
1898
  "drilldown": {"filter": {"start": d["day"], "end": d["day"]}},
@@ -1731,7 +1930,7 @@ class Analytics:
1731
1930
  })
1732
1931
  # week-over-week model shift
1733
1932
  if self.last_day:
1734
- end = self.today() - timedelta(days=1) # anchor on yesterday, not last_day
1933
+ end = last_full # the last complete day you have data for, within the range
1735
1934
  cur_s = (end - timedelta(days=6)).isoformat()
1736
1935
  prev_s, prev_e = (end - timedelta(days=13)).isoformat(), (end - timedelta(days=7)).isoformat()
1737
1936
  for m in self.q(f"SELECT DISTINCT r.model FROM requests r WHERE {w}", p):
@@ -1751,7 +1950,8 @@ class Analytics:
1751
1950
  "drilldown": {"filter": {"models": [model], "start": cur_s}},
1752
1951
  "basis": "estimated",
1753
1952
  })
1754
- found.sort(key=lambda x: -x.get("ratio", 0))
1953
+ sev = {"high": 0, "medium": 1, "low": 2}
1954
+ found.sort(key=lambda x: (sev.get(x["severity"], 3), -x.get("ratio", 0)))
1755
1955
  return {"anomalies": found[:25], "basis": "estimated"}
1756
1956
 
1757
1957
  # ---------------- scorecard ----------------
@@ -1775,9 +1975,12 @@ class Analytics:
1775
1975
  if margin is None:
1776
1976
  dim("Cache break-even", 50, "No cache activity in range", 1.0)
1777
1977
  else:
1978
+ ratio = eff["cache"].get("breakeven_ratio") or margin
1778
1979
  dim("Cache break-even", 50 + margin * 50,
1779
- f"Caching returned {margin*100:.0f}% of its cost as read discount net of "
1780
- "write premium.", 1.0)
1980
+ (f"Caching saved {ratio:.1f}× what it cost (read discount net of write premium)."
1981
+ if ratio >= 1 else
1982
+ f"Caching returned {ratio*100:.0f}% of its cost as read discount net of write premium."),
1983
+ 1.0)
1781
1984
 
1782
1985
  ml = next((l for l in bud["lines"] if l["name"] == "Monthly spend"), None)
1783
1986
  if ml and ml.get("configured"):
@@ -1796,7 +1999,7 @@ class Analytics:
1796
1999
  return {
1797
2000
  "score": total,
1798
2001
  "dimensions": dims,
1799
- "what_is_good": [f"{d['name']}: {d['detail']}" for d in strong if d["score"] >= 60],
2002
+ "what_is_good": [f"{d['name']}: {d['detail']}" for d in strong if d["score"] >= 70],
1800
2003
  "needs_attention": [f"{d['name']}: {d['detail']}" for d in weak if d["score"] < 70],
1801
2004
  "biggest_opportunity": (
1802
2005
  {"title": top["title"], "detail": top["detail"],
@@ -1818,15 +2021,19 @@ class Analytics:
1818
2021
  ov = self.overview(f)
1819
2022
 
1820
2023
  for a in anos["anomalies"][:2]:
1821
- actions.append({"priority": 1, "kind": "anomaly", "text": a["title"],
2024
+ actions.append({"priority": {"high": 1, "medium": 2}.get(a["severity"], 3),
2025
+ "kind": "anomaly", "text": a["title"],
1822
2026
  "detail": a["detail"], "drilldown": a.get("drilldown"),
1823
2027
  "basis": "estimated"})
1824
2028
  for wf in wst["findings"][:2]:
1825
2029
  actions.append({"priority": 2, "kind": "waste",
1826
2030
  "text": wf["title"],
1827
- "detail": f"{wf['detail']} ~${wf['est_excess_usd']:,.2f} estimated "
1828
- f"excess across ${wf['est_cost_usd']:,.2f} of exposed spend. "
1829
- f"{wf['recommended_action']}",
2031
+ "detail": (f"{wf['detail']} ~${wf['est_excess_usd']:,.2f} estimated "
2032
+ f"excess across ${wf['est_cost_usd']:,.2f} of exposed spend. "
2033
+ if wf["est_excess_usd"] > 0 else
2034
+ f"{wf['detail']} No excess is claimed; "
2035
+ f"${wf['est_cost_usd']:,.2f} of spend sits in these items. ")
2036
+ + wf["recommended_action"],
1830
2037
  "basis": "estimated"})
1831
2038
  for r in recs["recommendations"][:2]:
1832
2039
  if r["type"] == "cache_working":
@@ -1870,14 +2077,14 @@ class Analytics:
1870
2077
  WHERE {w} GROUP BY 1 ORDER BY 2 DESC""", p)
1871
2078
  files = self.q(f"""SELECT ft.path, COUNT(*) touches, COUNT(DISTINCT ft.session_id) sessions,
1872
2079
  GROUP_CONCAT(DISTINCT ft.op) ops
1873
- FROM files_touched ft WHERE ft.session_id IN
1874
- (SELECT DISTINCT r.session_id FROM requests r WHERE {w})
2080
+ FROM files_touched ft WHERE ft.prompt_id IN
2081
+ (SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
1875
2082
  GROUP BY 1 ORDER BY 2 DESC LIMIT 40""", p)
1876
2083
  branches = self.q(f"""SELECT COALESCE(s.git_branch,'(none)') branch,
1877
2084
  COUNT(DISTINCT s.id) sessions, SUM(r.est_cost_usd) cost,
1878
2085
  SUM(r.billable_tokens) tokens
1879
2086
  FROM requests r JOIN sessions s ON s.id=r.session_id
1880
- WHERE {w} GROUP BY 1 ORDER BY cost DESC LIMIT 25""", p)
2087
+ WHERE {w} GROUP BY 1 ORDER BY cost DESC""", p)
1881
2088
  repos = [r for r in self.projects(f) if not r["is_sandbox"]]
1882
2089
  return {
1883
2090
  "tools": tools, "files": files, "branches": branches, "repositories": repos,
@@ -1897,32 +2104,41 @@ class Analytics:
1897
2104
 
1898
2105
  # ---------------- global search ----------------
1899
2106
  def search(self, term, limit=40):
1900
- like = f"%{term}%"
2107
+ # One more row than shown, so the page can say "40+" instead of a capped count.
2108
+ pat, limit = like(term), limit + 1
2109
+ out = self._search(term, pat, limit)
2110
+ for k, v in list(out.items()):
2111
+ if isinstance(v, list):
2112
+ out[k + "_more"] = len(v) >= limit
2113
+ out[k] = v[:limit - 1]
2114
+ return out
2115
+
2116
+ def _search(self, term, like, limit):
1901
2117
  return {
1902
2118
  "term": term,
1903
2119
  "prompts": self.q(
1904
2120
  "SELECT id prompt_id, ts, category, session_id, substr(text,1,240) preview,"
1905
- " est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ?"
2121
+ " est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ? ESCAPE '\\'"
1906
2122
  " ORDER BY est_cost_usd DESC LIMIT ?", (like, limit)),
1907
2123
  "sessions": self.q(
1908
2124
  "SELECT s.id session_id, s.title, s.git_branch, pr.name project, s.started_at,"
1909
2125
  " s.est_cost_usd, s.billable_tokens FROM sessions s"
1910
2126
  " JOIN projects pr ON pr.id=s.project_id"
1911
- " WHERE s.id LIKE ? OR s.title LIKE ? OR s.git_branch LIKE ?"
2127
+ " WHERE s.id LIKE ? ESCAPE '\\' OR s.title LIKE ? ESCAPE '\\' OR s.git_branch LIKE ? ESCAPE '\\'"
1912
2128
  " ORDER BY s.est_cost_usd DESC LIMIT ?", (like, like, like, limit)),
1913
2129
  "projects": self.q(
1914
2130
  "SELECT id project_id, name, path, slug FROM projects"
1915
- " WHERE name LIKE ? OR path LIKE ? LIMIT ?", (like, like, limit)),
2131
+ " WHERE name LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\' LIMIT ?", (like, like, limit)),
1916
2132
  "models": self.q(
1917
2133
  "SELECT model, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
1918
- " WHERE model LIKE ? GROUP BY 1", (like,)),
2134
+ " WHERE model LIKE ? ESCAPE '\\' GROUP BY 1", (like,)),
1919
2135
  "tools": self.q(
1920
2136
  "SELECT name, target, COUNT(*) n FROM tool_calls"
1921
- " WHERE name LIKE ? OR target LIKE ? GROUP BY name, target"
2137
+ " WHERE name LIKE ? ESCAPE '\\' OR target LIKE ? ESCAPE '\\' GROUP BY name, target"
1922
2138
  " ORDER BY n DESC LIMIT ?", (like, like, limit)),
1923
2139
  "days": self.q(
1924
2140
  "SELECT day, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
1925
- " WHERE day LIKE ? GROUP BY 1 ORDER BY 1", (like,)),
2141
+ " WHERE day LIKE ? ESCAPE '\\' GROUP BY 1 ORDER BY 1", (like,)),
1926
2142
  }
1927
2143
 
1928
2144
  # ---------------- filter option lists ----------------
@@ -1971,11 +2187,16 @@ class Analytics:
1971
2187
  " FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
1972
2188
  " GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
1973
2189
  "categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
1974
- "date_range": {"first": self.first_day, "last": self.last_day},
2190
+ "date_range": {"first": self.first_day, "last": self.last_day,
2191
+ "today": self.today().isoformat(), "synced_through": self.data_end().isoformat()},
1975
2192
  "billing_period": self.billing_period(),
1976
2193
  "meta": self.meta,
1977
2194
  "pricing": {"updated": self.pricing.updated, "source": self.pricing.source,
1978
- "models": self.pricing.models},
2195
+ "models": self.pricing.models,
2196
+ # price-table keys your data actually uses (ids normalised the same
2197
+ # way costs are), so the page can lead with those
2198
+ "used": sorted({self.pricing.normalize(r["model"]) for r in self.q(
2199
+ "SELECT DISTINCT model FROM requests")} & set(self.pricing.models))},
1979
2200
  "settings": self.settings,
1980
2201
  "version": _version(),
1981
2202
  "unavailable_label": UNAVAILABLE,