claude-finops 0.13.0 → 0.15.0

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
@@ -124,6 +125,9 @@ def _validate_settings(cur, defaults):
124
125
  if pct is not None:
125
126
  bad.append("alert_thresholds_pct")
126
127
  cur["alert_thresholds_pct"] = defaults.get("alert_thresholds_pct", [])
128
+ from .guard import validate_guard
129
+ cur["guard"], guard_bad = validate_guard(cur.get("guard"), defaults.get("guard"))
130
+ bad += guard_bad
127
131
  for path in bad:
128
132
  print(f"finops: settings.local.json has an invalid '{path}'; using the shipped default",
129
133
  file=sys.stderr)
@@ -161,6 +165,13 @@ def _d(s):
161
165
  return datetime.strptime(s, "%Y-%m-%d").date()
162
166
 
163
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
+
164
175
  def _cumsum(values):
165
176
  total = 0.0
166
177
  for v in values:
@@ -198,7 +209,15 @@ class Analytics:
198
209
  _today = None # tests set this; production uses the clock
199
210
 
200
211
  def today(self):
201
- 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
202
221
 
203
222
  @property
204
223
  def db(self):
@@ -334,6 +353,11 @@ class Analytics:
334
353
  p).get("n", 0)
335
354
  tot["tool_calls"] = self.one(
336
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)
337
361
 
338
362
  bp = self.billing_period()
339
363
  today = bp["today"]
@@ -359,8 +383,12 @@ class Analytics:
359
383
 
360
384
  # ---------------- burn rate & limits ----------------
361
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)
362
389
  w, p = self.where(f)
363
390
  bp = self.billing_period()
391
+ today, synced = self.today(), self.data_end()
364
392
  rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens,
365
393
  COUNT(*) requests FROM requests r
366
394
  WHERE {w} AND r.day >= ? AND r.day <= ? GROUP BY 1 ORDER BY 1""",
@@ -368,23 +396,37 @@ class Analytics:
368
396
  used_cost = sum(r["cost"] for r in rows)
369
397
  used_tokens = sum(r["tokens"] for r in rows)
370
398
  used_req = sum(r["requests"] for r in rows)
371
- 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)
372
403
  daily_avg = used_cost / elapsed
373
404
 
374
405
  # Trailing rates are measured over the last N CALENDAR days, not only the
375
406
  # slice inside the billing period — early in a period that slice is too short
376
407
  # to be a rate. Idle days count as zero, because these rates get multiplied by
377
408
  # calendar days remaining. This keeps burn and forecast on one methodology.
378
- yesterday = (self.today() - timedelta(days=1)).isoformat()
379
- last7 = self.daily_series(f, days=7, end=yesterday)
380
- 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)
381
413
  avg7 = (sum(r["cost"] for r in last7) / len(last7)) if last7 else 0.0
382
414
  tok_avg7 = (sum(r["tokens"] for r in last7) / len(last7)) if last7 else 0.0
383
415
  # the projection rate matches Analytics.forecast()'s "expected" scenario
384
416
  burn = (sum(r["cost"] for r in last14) / len(last14)) if last14 else daily_avg
385
417
  tok_burn = (sum(r["tokens"] for r in last14) / len(last14)) if last14 else 0.0
386
- 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
387
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
388
430
 
389
431
  lim = self.settings["limits"]
390
432
  out = {
@@ -396,7 +438,8 @@ class Analytics:
396
438
  "burn_rate_cost_per_day": burn,
397
439
  "burn_rate_window_days": len(last14),
398
440
  "projected_period_cost": projected,
399
- "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,
400
443
  "forecast_basis": "forecast",
401
444
  "forecast_note": ("Projection uses the %d-calendar-day mean daily spend, idle days "
402
445
  "included as zero — the same rate as the "
@@ -407,7 +450,7 @@ class Analytics:
407
450
  for key, used_val, rate, label in (
408
451
  ("monthly_cost_allowance_usd", used_cost, burn, "cost"),
409
452
  ("monthly_token_allowance", used_tokens, (tok_burn or tok_daily), "tokens"),
410
- ("monthly_request_allowance", used_req, used_req / elapsed, "requests"),
453
+ ("monthly_request_allowance", used_req, req_burn, "requests"),
411
454
  ):
412
455
  allowance = lim.get(key)
413
456
  if not allowance:
@@ -416,7 +459,7 @@ class Analytics:
416
459
  remaining = allowance - used_val
417
460
  pct = 100.0 * used_val / allowance
418
461
  days_left = (remaining / rate) if rate > 0 and remaining > 0 else None
419
- proj = used_val + rate * bp["remaining_days"]
462
+ proj = used_val + rate * ahead
420
463
  out["allowances"][label] = {
421
464
  "configured": True, "allowance": allowance, "used": used_val,
422
465
  "remaining": remaining, "used_pct": round(pct, 1),
@@ -443,29 +486,64 @@ class Analytics:
443
486
  # ---------------- timeline ----------------
444
487
  def timeline(self, f=None, grain="day"):
445
488
  w, p = self.where(f)
446
- col = "r.day" if grain == "day" else "substr(r.ts,1,13)"
447
- 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"""
448
491
  SELECT {col} bucket, COUNT(*) requests,
449
492
  COUNT(DISTINCT r.session_id) sessions,
450
- COUNT(DISTINCT r.prompt_id) prompts,
451
493
  SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
452
494
  SUM(r.cache_read_tokens) cache_read_tokens,
453
495
  SUM(r.cache_write_tokens) cache_write_tokens,
454
496
  SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
455
497
  AVG(r.context_tokens) avg_context
456
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
457
532
 
458
533
  def heatmap(self, f=None):
459
- """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).
460
535
 
461
536
  Transcripts stamp UTC; bucketing on that would put a 10am IST session at
462
- 4am. SQLite's 'localtime' modifier applies the local offset, half-hour
463
- 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.
464
540
  """
465
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')"
466
544
  rows = self.q(f"""
467
- SELECT CAST(strftime('%w', r.ts, 'localtime') AS INTEGER) wd,
468
- CAST(strftime('%H', r.ts, 'localtime') AS INTEGER) hr,
545
+ SELECT CAST({wd} AS INTEGER) wd,
546
+ CAST({hr} AS INTEGER) hr,
469
547
  COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
470
548
  COUNT(*) requests
471
549
  FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
@@ -477,7 +555,7 @@ class Analytics:
477
555
  c = grid[((r["wd"] + 6) % 7, r["hr"])]
478
556
  c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
479
557
  return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
480
- "tz": time.strftime("%Z"), "cost_basis": "estimated"}
558
+ "tz": os.environ.get("CLAUDE_FINOPS_TZ") or time.strftime("%Z"), "cost_basis": "estimated"}
481
559
 
482
560
  # ---------------- models ----------------
483
561
  def models(self, f=None):
@@ -504,9 +582,12 @@ class Analytics:
504
582
  COUNT(*) n, AVG(r.context_tokens) avg_ctx
505
583
  FROM requests r WHERE {w}
506
584
  GROUP BY r.model, r.priced_as, r.unpriced_long_context""", p):
507
- 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})
508
586
  win = self.pricing.context_window(v["priced_as"] or v["model"])
509
- 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"]:
510
591
  u["over"] += v["n"]
511
592
  continue
512
593
  u["num"] += v["n"] * 100.0 * (v["avg_ctx"] or 0) / win
@@ -518,6 +599,9 @@ class Analytics:
518
599
  r["tier"] = self.pricing.tier(r["model"])
519
600
  r["context_window"] = self.pricing.context_window(r["model"])
520
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"))
521
605
  r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
522
606
  r["token_pct"] = round(100.0 * r["tokens"] / tt, 1)
523
607
  r["cost_per_1k_output"] = (1000.0 * r["cost"] / r["output_tokens"]) if r["output_tokens"] else None
@@ -527,16 +611,21 @@ class Analytics:
527
611
  u = util.get(r["model"], {})
528
612
  r["utilization_pct"] = round(u["num"] / u["den"], 1) if u.get("den") else None
529
613
  r["over_window_requests"] = u.get("over", 0)
614
+ r["unknown_window_requests"] = u.get("nowin", 0)
530
615
  r["long_context_requests"] = u.get("long", 0)
531
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)]
532
621
  superlatives = {}
533
622
  if priced:
534
623
  superlatives = {
535
624
  "most_expensive": max(priced, key=lambda r: r["cost"])["model"],
536
625
  "most_used": max(priced, key=lambda r: r["requests"])["model"],
537
- "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"],
538
627
  "best_cost_per_output": min(
539
- [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"]],
540
629
  key=lambda r: r["cost_per_1k_output"], default={}).get("model"),
541
630
  }
542
631
  return {"rows": rows, "superlatives": superlatives, "basis": "estimated"}
@@ -553,12 +642,15 @@ class Analytics:
553
642
  SUM(r.est_cost_usd) cost, GROUP_CONCAT(DISTINCT r.model) models
554
643
  FROM requests r JOIN projects pr ON pr.id = r.project_id
555
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)}
556
650
  for r in rows:
557
651
  r["avg_cost_per_session"] = r["cost"] / r["sessions"] if r["sessions"] else 0
558
652
  r["avg_cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else None
559
- r["files_touched"] = self.one(
560
- "SELECT COUNT(DISTINCT path) n FROM files_touched WHERE project_id=?",
561
- (r["project_id"],))["n"]
653
+ r["files_touched"] = files.get(r["project_id"], 0)
562
654
  b = self.settings["budgets"].get("per_project_usd", {}).get(r["name"])
563
655
  r["budget_usd"] = b
564
656
  r["budget_used_pct"] = round(100.0 * r["cost"] / b, 1) if b else None
@@ -571,7 +663,8 @@ class Analytics:
571
663
  rows = self.q(f"""
572
664
  SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
573
665
  s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
574
- s.agent, s.source_file,
666
+ s.agent, s.source_file, pr.path project_path,
667
+ s.billable_tokens session_tokens,
575
668
  COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
576
669
  SUM(r.tool_call_count) tool_calls,
577
670
  SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
@@ -590,22 +683,38 @@ class Analytics:
590
683
  r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
591
684
  cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
592
685
  r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
686
+ r["budget_tokens"] = self.session_budget(r["agent"], r.pop("project_path"), r["session_id"])
593
687
  r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
594
688
  return rows
595
689
 
690
+ def session_budget(self, agent, project_path, session_id=None):
691
+ """The per-session token budget that applies to a session, or None.
692
+
693
+ Claude Code sessions only: the guard hook runs there, and other agents'
694
+ sessions are not what the budget was set for.
695
+ """
696
+ if agent != "claude":
697
+ return None
698
+ from .guard import budget_for
699
+ return budget_for(self.settings.get("guard") or {}, project_path, session_id)
700
+
596
701
  def sessions_total(self, f=None):
597
702
  w, p = self.where(f)
598
703
  return self.one(f"SELECT COUNT(DISTINCT s.id) n FROM requests r "
599
704
  f"JOIN sessions s ON s.id = r.session_id "
600
705
  f"JOIN projects pr ON pr.id = s.project_id WHERE {w}", p)["n"]
601
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
+
602
715
  def prompts_total(self, f=None, search=None):
603
716
  w, p = self.where(f)
604
- extra, ep = "", []
605
- if search:
606
- extra = (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? "
607
- "OR pr.session_id LIKE ? OR pr.category LIKE ?)")
608
- ep = [f"%{search}%"] * 3
717
+ extra, ep = self._prompt_search(search)
609
718
  return self.one(f"SELECT COUNT(DISTINCT r.prompt_id) n FROM requests r "
610
719
  f"WHERE {w}{extra} AND r.prompt_id IS NOT NULL", p + ep)["n"]
611
720
 
@@ -614,11 +723,9 @@ class Analytics:
614
723
  ob = {"cost": "pcost DESC", "tokens": "ptokens DESC", "recent": "pr.ts DESC",
615
724
  "cheapest": "pcost ASC", "efficiency": "efficiency DESC",
616
725
  "length": "pr.char_len DESC"}.get(order, "pcost DESC")
617
- extra, ep = "", []
618
- if search:
619
- extra = (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? "
620
- "OR pr.session_id LIKE ? OR pr.category LIKE ?)")
621
- 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 ""
622
729
  # Aggregate requests per prompt first, rank, and only then join the (large) prompt
623
730
  # text for the page being returned. Grouping with the text attached was ~2s a call.
624
731
  if order in ("recent", "length"):
@@ -637,7 +744,7 @@ class Analytics:
637
744
  GROUP_CONCAT(DISTINCT r.model) models,
638
745
  (CAST(SUM(r.output_tokens) AS REAL) / MAX(SUM(r.billable_tokens),1)) efficiency
639
746
  FROM requests r WHERE {w}{extra} AND r.prompt_id IS NOT NULL
640
- GROUP BY r.prompt_id {inner_ob} {inner_lim})
747
+ GROUP BY r.prompt_id {having} {inner_ob} {inner_lim})
641
748
  SELECT pr.id prompt_id, pr.uuid, pr.ts, pr.day, pr.text, pr.char_len, pr.word_len,
642
749
  pr.category, pr.category_confidence, pr.category_evidence, pr.source,
643
750
  pr.session_id, proj.name project, proj.id project_id, s.title session_title,
@@ -666,12 +773,23 @@ class Analytics:
666
773
  r["category_evidence"] = []
667
774
  return rows
668
775
 
669
- 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):
670
787
  p = self.one("""SELECT pr.*, proj.name project, s.title session_title, s.git_branch
671
788
  FROM prompts pr JOIN projects proj ON proj.id=pr.project_id
672
789
  LEFT JOIN sessions s ON s.id=pr.session_id WHERE pr.id=?""", (pid,))
673
790
  if not p:
674
791
  return {"error": "not found"}
792
+ p["in_range"] = self._in_range(f, "prompt_id", pid)
675
793
  p["requests"] = self.q(
676
794
  "SELECT ts, model, effort, input_tokens, output_tokens, thinking_tokens,"
677
795
  " cache_read_tokens, cache_write_tokens, billable_tokens, context_tokens,"
@@ -692,11 +810,12 @@ class Analytics:
692
810
  p["category_evidence"] = []
693
811
  return p
694
812
 
695
- def session_detail(self, sid):
813
+ def session_detail(self, sid, f=None):
696
814
  s = self.one("""SELECT s.*, pr.name project FROM sessions s
697
815
  JOIN projects pr ON pr.id=s.project_id WHERE s.id=?""", (sid,))
698
816
  if not s:
699
817
  return {"error": "not found"}
818
+ s["in_range"] = self._in_range(f, "session_id", sid)
700
819
  s["prompts"] = self.q("""
701
820
  SELECT pr.id prompt_id, pr.ts, pr.category, substr(pr.text,1,220) preview,
702
821
  pr.char_len, pr.billable_tokens ptokens, pr.est_cost_usd pcost,
@@ -724,8 +843,16 @@ class Analytics:
724
843
  AVG(pr.char_len) avg_prompt_chars
725
844
  FROM prompts pr JOIN requests r ON r.prompt_id = pr.id
726
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)}
727
852
  tc = sum(r["cost"] for r in rows) or 1
728
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")
729
856
  r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
730
857
  r["cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else 0
731
858
  return {"rows": rows, "basis": "estimated",
@@ -833,16 +960,17 @@ class Analytics:
833
960
  WHERE s.id IN ({ph})""", ids)}
834
961
 
835
962
  def downsample(traj):
963
+ """Evenly spaced points, each with its real 1-based request number."""
836
964
  n = len(traj)
837
- if n <= trajectory_points:
838
- return traj
839
- step = n / trajectory_points
840
- 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]
841
968
 
842
969
  out_sessions = []
843
970
  for s in ranked:
844
971
  m = meta.get(s["session_id"], {})
845
- traj = downsample(s["traj"])
972
+ traj, traj_idx = downsample(s["traj"])
973
+ cum = list(_cumsum(cost for _, cost in s["traj"]))
846
974
  out_sessions.append({
847
975
  "session_id": s["session_id"], "title": m.get("title"), "project": m.get("project"),
848
976
  "requests": s["requests"], "cost_usd": s["cost_usd"], "max_context": s["max_context"],
@@ -854,7 +982,8 @@ class Analytics:
854
982
  "cost_after_pct": {str(t): (round(100.0 * s["cost_after"][t] / s["cost_usd"], 1)
855
983
  if s["cost_usd"] else 0.0) for t in thresholds},
856
984
  "context_trajectory": [c for c, _ in traj],
857
- "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],
858
987
  "basis": "actual",
859
988
  })
860
989
  return {
@@ -945,8 +1074,9 @@ class Analytics:
945
1074
  % (sum(r["n"] for r in unpriced), ", ".join(sorted({r["model"] for r in unpriced})))
946
1075
  if unpriced else ""),
947
1076
  "unknown_message": (
948
- "%d requests used a claude-* model with no entry in pricing.json at "
949
- "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."
950
1080
  % (unknown_requests, ", ".join(unknown_models))
951
1081
  if unknown_rows else ""),
952
1082
  "basis": "estimated",
@@ -1054,6 +1184,8 @@ class Analytics:
1054
1184
  "reads": t["cr"], "writes": t["cw"],
1055
1185
  "cost_split": cache_cost,
1056
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,
1057
1189
  "cost_with_cache": t["cost"], "cost_without_cache": t["cost_nc"],
1058
1190
  # Named for what it is. This used to be "estimated_savings_usd", and the
1059
1191
  # UI called it a saving; it is the gap to a run that never happened.
@@ -1095,14 +1227,28 @@ class Analytics:
1095
1227
  WHERE {w} GROUP BY s.id HAVING MAX(r.context_tokens) >= ?
1096
1228
  ORDER BY cost DESC LIMIT 20""", p + [thr])
1097
1229
  agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
1098
- 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
1099
1244
  return {
1100
1245
  "buckets": buckets, "avg_context": agg["a"], "max_context": agg["m"],
1101
1246
  "threshold": thr,
1102
1247
  "large_context_requests": big["n"], "large_context_cost": big["c"],
1103
1248
  "large_context_cost_pct": round(100.0 * big["c"] / total_cost, 1),
1104
1249
  "heavy_sessions": heavy_sessions,
1105
- "typical_context_window": max(windows) if windows else None,
1250
+ "typical_context_window": typical_window,
1251
+ "context_utilization_pct": util_pct,
1106
1252
  "basis": "actual token counts, estimated cost",
1107
1253
  }
1108
1254
 
@@ -1138,41 +1284,74 @@ class Analytics:
1138
1284
  "affected": {key: [e[key] for e in evidence if e.get(key)]},
1139
1285
  "recommended_action": action, "basis": "estimated"})
1140
1286
 
1141
- # 1. very long prompts — excess is the share of spend attributable to
1142
- # re-sending the oversized prompt text on every turn of the same request.
1143
- rows = self.q(f"""SELECT pr.id prompt_id, pr.char_len, substr(pr.text,1,160) preview,
1144
- pr.session_id, pr.est_cost_usd cost, pr.billable_tokens tokens,
1145
- pr.request_count requests
1146
- FROM prompts pr WHERE {pfilter} AND pr.char_len >= ?
1147
- 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.
1148
1295
  budget_chars = rules["long_prompt_chars"]
1149
- for r in rows:
1150
- r["excess"] = 0.0
1151
- if rows:
1152
- add("high", "long_prompts",
1153
- f"{len(rows)} very long prompts (>{budget_chars:,} chars)",
1154
- "Long pasted prompts inflate the cached prefix re-sent on every following turn.",
1155
- rows, "Move large pasted context into a file and reference it, or summarize first.",
1156
- "prompt_id",
1157
- "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")
1158
1316
 
1159
1317
  # 2. duplicate prompts — excess is the cost of the repeats, not the first ask,
1160
1318
  # counted only within a single session so cross-session coincidences don't count.
1161
- dups = self.q(f"""SELECT pr.norm_hash, pr.session_id, COUNT(*) n, substr(MIN(pr.text),1,160) preview,
1162
- SUM(pr.est_cost_usd) cost, SUM(pr.billable_tokens) tokens,
1163
- MIN(pr.est_cost_usd) first_cost, GROUP_CONCAT(pr.id) prompt_ids
1164
- FROM prompts pr WHERE {pfilter} AND pr.char_len > 25
1165
- GROUP BY pr.norm_hash, pr.session_id HAVING n > 1
1166
- ORDER BY cost DESC LIMIT 15""", p)
1167
- for d in dups:
1168
- d["prompt_id"] = int(d["prompt_ids"].split(",")[0])
1169
- d["affected_ids"] = [int(x) for x in d["prompt_ids"].split(",")]
1170
- 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"])
1171
1349
  if dups:
1172
- 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",
1173
1352
  "The same request was sent again, re-paying for context each time.",
1174
1353
  dups, "Reuse the earlier answer, or capture the recurring request as a slash command.",
1175
- "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")
1176
1355
 
1177
1356
  # 3. low-yield sessions — excess is what the session cost ABOVE what the same
1178
1357
  # output would have cost at your own median session efficiency.
@@ -1194,7 +1373,7 @@ class Analytics:
1194
1373
  proj.name project FROM sessions s JOIN projects proj ON proj.id=s.project_id
1195
1374
  WHERE {sfilter} AND s.billable_tokens > ?
1196
1375
  AND (CAST(s.output_tokens AS REAL)/MAX(s.billable_tokens,1)) < ?
1197
- ORDER BY cost DESC LIMIT 15""",
1376
+ ORDER BY cost DESC""",
1198
1377
  p + [rules["huge_session_tokens"], cutoff])
1199
1378
  for r in low:
1200
1379
  r["excess"] = 0.0
@@ -1232,7 +1411,7 @@ class Analytics:
1232
1411
  AND pr.output_tokens < ? AND pr.tool_calls = 0 AND pr.est_cost_usd > 0
1233
1412
  AND EXISTS (SELECT 1 FROM requests r2 WHERE r2.prompt_id=pr.id
1234
1413
  AND r2.model IN ({ph}))
1235
- ORDER BY cost DESC LIMIT 15""",
1414
+ ORDER BY cost DESC""",
1236
1415
  p + [rules["simple_task_output_tokens"]] + frontier)
1237
1416
  for r in small:
1238
1417
  r["excess"] = 0.0
@@ -1241,7 +1420,9 @@ class Analytics:
1241
1420
  f"{len(small)} frontier-model prompts produced under "
1242
1421
  f"{rules['simple_task_output_tokens']} output tokens",
1243
1422
  "Short, simple turns running on the most expensive model tier.",
1244
- 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.",
1245
1426
  "prompt_id", "none claimed")
1246
1427
 
1247
1428
  # 5. tool loops — excess is the share of the loop beyond the threshold.
@@ -1249,11 +1430,11 @@ class Analytics:
1249
1430
  loops = self.q(f"""SELECT pr.id prompt_id, substr(pr.text,1,160) preview, pr.session_id,
1250
1431
  pr.tool_calls tools, pr.est_cost_usd cost, pr.billable_tokens tokens
1251
1432
  FROM prompts pr WHERE {pfilter} AND pr.tool_calls > ?
1252
- ORDER BY cost DESC LIMIT 15""", p + [loop_calls])
1433
+ ORDER BY cost DESC""", p + [loop_calls])
1253
1434
  for r in loops:
1254
1435
  r["excess"] = 0.0
1255
1436
  if loops:
1256
- 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",
1257
1438
  "Long agentic loops re-send the whole conversation each step, so cost grows super-linearly.",
1258
1439
  loops, "Split the task, or give more precise instructions up front.", "prompt_id",
1259
1440
  "none claimed")
@@ -1280,7 +1461,7 @@ class Analytics:
1280
1461
  if net > 0:
1281
1462
  r["excess"] = net
1282
1463
  flagged.append(r)
1283
- poor = flagged[:15]
1464
+ poor = flagged
1284
1465
  if poor:
1285
1466
  add("medium", "poor_cache_reuse", f"{len(poor)} sessions wrote cache they barely reused",
1286
1467
  "Cache writes cost more than plain input; they only pay off when read back repeatedly.",
@@ -1293,7 +1474,7 @@ class Analytics:
1293
1474
  s.request_count requests, s.est_cost_usd cost
1294
1475
  FROM sessions s JOIN projects proj ON proj.id=s.project_id
1295
1476
  WHERE {sfilter} AND s.duration_s > ? AND s.request_count < 30
1296
- ORDER BY s.duration_s DESC LIMIT 10""",
1477
+ ORDER BY s.duration_s DESC""",
1297
1478
  p + [rules["idle_gap_minutes"] * 60 * 4])
1298
1479
  for r in idle:
1299
1480
  r["excess"] = 0.0
@@ -1341,7 +1522,7 @@ class Analytics:
1341
1522
  len(e.get("affected_ids") or [1]), 1))
1342
1523
  elif e.get("session_id"):
1343
1524
  s_excess[e["session_id"]] = max(s_excess.get(e["session_id"], 0.0), ex)
1344
- counted_sessions = set(s_excess)
1525
+ counted_sessions = {k for k, v in s_excess.items() if v > 0}
1345
1526
  outside = 0.0
1346
1527
  if p_excess:
1347
1528
  owner = {r["id"]: r["session_id"] for r in self.q(
@@ -1350,7 +1531,13 @@ class Analytics:
1350
1531
  outside = sum(v for pid_, v in p_excess.items()
1351
1532
  if owner.get(pid_) not in counted_sessions)
1352
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()}
1353
1539
  return {"findings": findings,
1540
+ "high_severity_pct": round(min(100.0 * high / total, 100.0), 1) if total else 0,
1354
1541
  "estimated_excess_usd": excess,
1355
1542
  "excess_pct": round(100.0 * excess / total, 1) if total else 0,
1356
1543
  "exposed_cost_usd": exposed,
@@ -1456,42 +1643,58 @@ class Analytics:
1456
1643
  "estimated_savings_usd": None, "estimated_savings_pct": None,
1457
1644
  "caveat": "No saving is claimed: what an uncached run would have cost is a "
1458
1645
  "counterfactual, not money you avoided.",
1459
- "basis": "actual",
1646
+ "basis": "estimated",
1460
1647
  })
1461
1648
 
1462
- ctx = self.context_analysis(f)
1463
- 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:
1464
1661
  recs.append({
1465
1662
  "type": "context_reduction", "confidence": "observed",
1466
- "title": f"{ctx['large_context_cost_pct']}% of spend comes from >"
1467
- f"{ctx['threshold']//1000}K-context requests",
1468
- "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 "
1469
1666
  f"on every turn. /compact or a fresh session resets it; how much "
1470
1667
  f"that would have saved depends on what the work needed, and is "
1471
1668
  f"not estimated here."),
1472
- "scope": f"{ctx['large_context_requests']:,} requests",
1473
- "actual_cost_usd": ctx["large_context_cost"],
1669
+ "scope": f"{big['requests']:,} requests",
1670
+ "actual_cost_usd": big["cost_usd"],
1474
1671
  "estimated_alternative_cost_usd": None,
1475
1672
  "estimated_savings_usd": None, "estimated_savings_pct": None,
1476
1673
  "caveat": "Observed share of spend. No reduction is assumed.",
1477
- "basis": "actual",
1674
+ "basis": "estimated",
1478
1675
  })
1479
1676
  recs.sort(key=lambda r: -(r.get("actual_cost_usd") or 0))
1480
1677
  return {"recommendations": recs, "basis": "actual"}
1481
1678
 
1482
1679
  # ---------------- forecast ----------------
1483
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)
1484
1684
  w, p = self.where(f)
1485
1685
  bp = self.billing_period()
1486
1686
  rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens
1487
1687
  FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
1488
1688
  if not rows:
1489
1689
  return {"available": False, "message": "No usage in the selected range."}
1490
- # calendar days, idle days as zero, excluding today (still partial) — the rate
1491
- # below is multiplied by calendar days remaining, so a per-active-day mean
1492
- # would overstate every scenario and a partial today would understate it
1493
- yesterday = (self.today() - timedelta(days=1)).isoformat()
1494
- 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
1495
1698
  priced = [r["cost"] for r in recent]
1496
1699
  sample_days = sum(1 for c in priced if c > 0)
1497
1700
  mean = statistics.fmean(priced) if priced else 0.0
@@ -1499,14 +1702,15 @@ class Analytics:
1499
1702
  in_period = [r for r in rows if bp["start"] <= r["day"] <= bp["end"]]
1500
1703
  used = sum(r["cost"] for r in in_period)
1501
1704
  used_tok = sum(r["tokens"] for r in in_period)
1502
- left = bp["remaining_days"]
1705
+ left = bp["remaining_days"] + unread
1503
1706
  insufficient = sample_days < 7
1504
1707
 
1505
1708
  def band(rate, spread=0.0):
1506
1709
  # spend on different days is treated as independent, so the spread of a
1507
1710
  # sum over `left` days grows with sqrt(left), not left
1508
- return {"daily_rate": rate,
1509
- "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}
1510
1714
 
1511
1715
  scenarios = {"expected": band(mean)}
1512
1716
  if not insufficient:
@@ -1521,14 +1725,15 @@ class Analytics:
1521
1725
  "available": True,
1522
1726
  "method": ("mean of the last 14 complete calendar days (idle days as zero); "
1523
1727
  "bands are ±1 sd × sqrt(days remaining)"),
1524
- "sample_days": sample_days,
1728
+ "sample_days": sample_days, "window_days": len(recent),
1729
+ "synced_through": synced.isoformat(), "unread_days": unread,
1525
1730
  "insufficient_history": insufficient,
1526
1731
  "daily_mean": mean, "daily_stdev": sd,
1527
1732
  "period_used": used, "period_used_tokens": used_tok,
1528
1733
  "remaining_days": left,
1529
1734
  "scenarios": scenarios,
1530
1735
  "end_of_period_tokens": used_tok + tok_mean * left,
1531
- "estimated_monthly_cost": used + mean * left,
1736
+ "estimated_monthly_cost": mean * 30,
1532
1737
  "basis": "forecast",
1533
1738
  }
1534
1739
  allowance = self.settings["limits"].get("monthly_cost_allowance_usd")
@@ -1575,31 +1780,101 @@ class Analytics:
1575
1780
  "thresholds_breached": breached, "thresholds_forecast_breach": fbreach,
1576
1781
  "unit": unit}
1577
1782
 
1783
+ out["suggest"] = self.budget_suggestions(f)
1578
1784
  fc_cost = fc.get("scenarios", {}).get("expected", {}).get("end_of_period_cost")
1579
1785
  out["lines"].append(line("Monthly spend", b.get("monthly_usd"), period["c"], fc_cost))
1580
1786
  out["lines"].append(line("Daily spend", b.get("daily_usd"), today["c"], today["c"]))
1581
1787
  out["lines"].append(line("Monthly tokens", b.get("monthly_tokens"), period["t"],
1582
1788
  fc.get("end_of_period_tokens"), unit="tokens"))
1789
+ g = self.settings.get("guard") or {}
1790
+ from .guard import has_any_budget
1791
+ if has_any_budget(g):
1792
+ out["lines"].append(self._session_budget_line(f, bp, line))
1793
+ else:
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 []})
1583
1797
  for proj, bud in (b.get("per_project_usd") or {}).items():
1584
- 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
1585
1799
  JOIN projects pr ON pr.id=r.project_id
1586
- WHERE pr.name=? AND r.day>=? AND r.day<=?""",
1587
- (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"]
1588
1802
  out["lines"].append(line(f"Project: {proj}", bud, act, None))
1589
1803
  for model, bud in (b.get("per_model_usd") or {}).items():
1590
- act = self.one("""SELECT COALESCE(SUM(est_cost_usd),0) c FROM requests
1591
- WHERE model=? AND day>=? AND day<=?""",
1592
- (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"]
1593
1807
  out["lines"].append(line(f"Model: {self.pricing.display_name(model)}", bud, act, None))
1594
1808
  return out
1595
1809
 
1810
+ def budget_suggestions(self, f=None):
1811
+ """Figures the Budgets form offers as suggestions: recent spend and session sizes.
1812
+
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.
1818
+ """
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()
1822
+ days = [r["c"] for r in self.q(
1823
+ f"SELECT r.day, SUM(r.est_cost_usd) c FROM requests r WHERE {w} AND r.day >= ? "
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
1826
+ tot = self.one(f"SELECT COALESCE(SUM(r.est_cost_usd),0) c, COALESCE(SUM(r.billable_tokens),0) t "
1827
+ f"FROM requests r WHERE {w} AND r.day >= ? AND r.day <= ?", p + [start, end])
1828
+ sizes = [r["t"] for r in self.q(
1829
+ "SELECT billable_tokens t FROM sessions WHERE agent='claude' AND billable_tokens > 0 "
1830
+ "AND id IN (SELECT DISTINCT session_id FROM requests WHERE day >= ? AND day <= ?) "
1831
+ "ORDER BY t", (start, end))]
1832
+ if len(sizes) > 2000:
1833
+ step = len(sizes) / 2000.0
1834
+ sizes = [sizes[int(i * step)] for i in range(2000)]
1835
+ days.sort()
1836
+ return {"spend_30d": round(tot["c"], 2), "tokens_30d": tot["t"],
1837
+ "daily_avg": round(tot["c"] / 30.0, 2),
1838
+ "daily_p90": round(days[min(len(days) - 1, int(len(days) * 0.9))], 2) if days else 0,
1839
+ "session_tokens": sizes}
1840
+
1841
+ def _session_budget_line(self, f, bp, line):
1842
+ """Largest Claude Code session this period against its own budget, and who went over."""
1843
+ w, p = self.where(f)
1844
+ rows = self.q(f"""SELECT s.id session_id, s.title, pr.name project, pr.path path,
1845
+ s.billable_tokens tokens
1846
+ FROM sessions s JOIN projects pr ON pr.id = s.project_id
1847
+ WHERE s.agent = 'claude' AND s.id IN (
1848
+ SELECT r.session_id FROM requests r
1849
+ WHERE {w} AND r.day >= ? AND r.day <= ?)""",
1850
+ p + [bp["start"], bp["end"]])
1851
+ scored = []
1852
+ for r in rows:
1853
+ bud = self.session_budget("claude", r.pop("path"), r["session_id"])
1854
+ if bud:
1855
+ r["budget"], r["pct"] = bud, round(100.0 * (r["tokens"] or 0) / bud, 1)
1856
+ scored.append(r)
1857
+ scored.sort(key=lambda r: r["pct"], reverse=True)
1858
+ top = scored[0] if scored else None
1859
+ g = self.settings.get("guard") or {}
1860
+ ln = line("Per-session tokens", top["budget"] if top else g.get("session_tokens"),
1861
+ top["tokens"] if top else 0, None, unit="tokens")
1862
+ over = [r for r in scored if r["pct"] >= 100]
1863
+ ln["sessions_over"] = len(over)
1864
+ ln["top_over"] = over[:5]
1865
+ ln["basis"] = "largest session this period vs its budget"
1866
+ return ln
1867
+
1596
1868
  # ---------------- anomalies ----------------
1597
1869
  def anomalies(self, f=None):
1598
1870
  w, p = self.where(f)
1599
1871
  cfg = self.settings["anomaly"]
1600
1872
  found = []
1601
- yesterday = (self.today() - timedelta(days=1)).isoformat()
1602
- 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())]
1603
1878
  priced = [d for d in series if d["cost"] > 0]
1604
1879
  if len(priced) >= 14:
1605
1880
  # Median/MAD, not mean/stdev: unpriced $0 days from agents without pricing
@@ -1617,7 +1892,7 @@ class Analytics:
1617
1892
  if score >= cfg.get("daily_robust_z", 3.5) and ratio >= cfg["daily_ratio"]:
1618
1893
  found.append({
1619
1894
  "severity": "high", "type": "daily_spike", "date": d["day"],
1620
- "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",
1621
1896
  "detail": f"${d['cost']:,.2f} vs a ${med:,.2f} median priced day (robust z={score:.1f}).",
1622
1897
  "metric_value": d["cost"], "baseline": med, "ratio": round(ratio, 2),
1623
1898
  "drilldown": {"filter": {"start": d["day"], "end": d["day"]}},
@@ -1655,7 +1930,7 @@ class Analytics:
1655
1930
  })
1656
1931
  # week-over-week model shift
1657
1932
  if self.last_day:
1658
- 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
1659
1934
  cur_s = (end - timedelta(days=6)).isoformat()
1660
1935
  prev_s, prev_e = (end - timedelta(days=13)).isoformat(), (end - timedelta(days=7)).isoformat()
1661
1936
  for m in self.q(f"SELECT DISTINCT r.model FROM requests r WHERE {w}", p):
@@ -1675,7 +1950,8 @@ class Analytics:
1675
1950
  "drilldown": {"filter": {"models": [model], "start": cur_s}},
1676
1951
  "basis": "estimated",
1677
1952
  })
1678
- 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)))
1679
1955
  return {"anomalies": found[:25], "basis": "estimated"}
1680
1956
 
1681
1957
  # ---------------- scorecard ----------------
@@ -1699,9 +1975,12 @@ class Analytics:
1699
1975
  if margin is None:
1700
1976
  dim("Cache break-even", 50, "No cache activity in range", 1.0)
1701
1977
  else:
1978
+ ratio = eff["cache"].get("breakeven_ratio") or margin
1702
1979
  dim("Cache break-even", 50 + margin * 50,
1703
- f"Caching returned {margin*100:.0f}% of its cost as read discount net of "
1704
- "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)
1705
1984
 
1706
1985
  ml = next((l for l in bud["lines"] if l["name"] == "Monthly spend"), None)
1707
1986
  if ml and ml.get("configured"):
@@ -1720,7 +1999,7 @@ class Analytics:
1720
1999
  return {
1721
2000
  "score": total,
1722
2001
  "dimensions": dims,
1723
- "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],
1724
2003
  "needs_attention": [f"{d['name']}: {d['detail']}" for d in weak if d["score"] < 70],
1725
2004
  "biggest_opportunity": (
1726
2005
  {"title": top["title"], "detail": top["detail"],
@@ -1742,15 +2021,19 @@ class Analytics:
1742
2021
  ov = self.overview(f)
1743
2022
 
1744
2023
  for a in anos["anomalies"][:2]:
1745
- 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"],
1746
2026
  "detail": a["detail"], "drilldown": a.get("drilldown"),
1747
2027
  "basis": "estimated"})
1748
2028
  for wf in wst["findings"][:2]:
1749
2029
  actions.append({"priority": 2, "kind": "waste",
1750
2030
  "text": wf["title"],
1751
- "detail": f"{wf['detail']} ~${wf['est_excess_usd']:,.2f} estimated "
1752
- f"excess across ${wf['est_cost_usd']:,.2f} of exposed spend. "
1753
- 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"],
1754
2037
  "basis": "estimated"})
1755
2038
  for r in recs["recommendations"][:2]:
1756
2039
  if r["type"] == "cache_working":
@@ -1794,14 +2077,14 @@ class Analytics:
1794
2077
  WHERE {w} GROUP BY 1 ORDER BY 2 DESC""", p)
1795
2078
  files = self.q(f"""SELECT ft.path, COUNT(*) touches, COUNT(DISTINCT ft.session_id) sessions,
1796
2079
  GROUP_CONCAT(DISTINCT ft.op) ops
1797
- FROM files_touched ft WHERE ft.session_id IN
1798
- (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})
1799
2082
  GROUP BY 1 ORDER BY 2 DESC LIMIT 40""", p)
1800
2083
  branches = self.q(f"""SELECT COALESCE(s.git_branch,'(none)') branch,
1801
2084
  COUNT(DISTINCT s.id) sessions, SUM(r.est_cost_usd) cost,
1802
2085
  SUM(r.billable_tokens) tokens
1803
2086
  FROM requests r JOIN sessions s ON s.id=r.session_id
1804
- WHERE {w} GROUP BY 1 ORDER BY cost DESC LIMIT 25""", p)
2087
+ WHERE {w} GROUP BY 1 ORDER BY cost DESC""", p)
1805
2088
  repos = [r for r in self.projects(f) if not r["is_sandbox"]]
1806
2089
  return {
1807
2090
  "tools": tools, "files": files, "branches": branches, "repositories": repos,
@@ -1821,32 +2104,41 @@ class Analytics:
1821
2104
 
1822
2105
  # ---------------- global search ----------------
1823
2106
  def search(self, term, limit=40):
1824
- 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):
1825
2117
  return {
1826
2118
  "term": term,
1827
2119
  "prompts": self.q(
1828
2120
  "SELECT id prompt_id, ts, category, session_id, substr(text,1,240) preview,"
1829
- " est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ?"
2121
+ " est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ? ESCAPE '\\'"
1830
2122
  " ORDER BY est_cost_usd DESC LIMIT ?", (like, limit)),
1831
2123
  "sessions": self.q(
1832
2124
  "SELECT s.id session_id, s.title, s.git_branch, pr.name project, s.started_at,"
1833
2125
  " s.est_cost_usd, s.billable_tokens FROM sessions s"
1834
2126
  " JOIN projects pr ON pr.id=s.project_id"
1835
- " 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 '\\'"
1836
2128
  " ORDER BY s.est_cost_usd DESC LIMIT ?", (like, like, like, limit)),
1837
2129
  "projects": self.q(
1838
2130
  "SELECT id project_id, name, path, slug FROM projects"
1839
- " WHERE name LIKE ? OR path LIKE ? LIMIT ?", (like, like, limit)),
2131
+ " WHERE name LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\' LIMIT ?", (like, like, limit)),
1840
2132
  "models": self.q(
1841
2133
  "SELECT model, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
1842
- " WHERE model LIKE ? GROUP BY 1", (like,)),
2134
+ " WHERE model LIKE ? ESCAPE '\\' GROUP BY 1", (like,)),
1843
2135
  "tools": self.q(
1844
2136
  "SELECT name, target, COUNT(*) n FROM tool_calls"
1845
- " WHERE name LIKE ? OR target LIKE ? GROUP BY name, target"
2137
+ " WHERE name LIKE ? ESCAPE '\\' OR target LIKE ? ESCAPE '\\' GROUP BY name, target"
1846
2138
  " ORDER BY n DESC LIMIT ?", (like, like, limit)),
1847
2139
  "days": self.q(
1848
2140
  "SELECT day, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
1849
- " WHERE day LIKE ? GROUP BY 1 ORDER BY 1", (like,)),
2141
+ " WHERE day LIKE ? ESCAPE '\\' GROUP BY 1 ORDER BY 1", (like,)),
1850
2142
  }
1851
2143
 
1852
2144
  # ---------------- filter option lists ----------------
@@ -1891,15 +2183,20 @@ class Analytics:
1891
2183
  return {
1892
2184
  "agents": self.agents(),
1893
2185
  "models": self.q("SELECT model, agent, COUNT(*) n FROM requests GROUP BY 1 ORDER BY 3 DESC"),
1894
- "projects": self.q("SELECT pr.id project_id, pr.name, pr.is_sandbox, pr.agent, COUNT(r.id) n"
2186
+ "projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, pr.agent, COUNT(r.id) n"
1895
2187
  " FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
1896
2188
  " GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
1897
2189
  "categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
1898
- "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()},
1899
2192
  "billing_period": self.billing_period(),
1900
2193
  "meta": self.meta,
1901
2194
  "pricing": {"updated": self.pricing.updated, "source": self.pricing.source,
1902
- "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))},
1903
2200
  "settings": self.settings,
1904
2201
  "version": _version(),
1905
2202
  "unavailable_label": UNAVAILABLE,