claude-finops 0.7.2 → 0.9.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.
package/finops/api.py CHANGED
@@ -4,10 +4,11 @@ the warehouse contains your full prompt text.
4
4
  import json
5
5
  import io
6
6
  import csv
7
+ import logging
7
8
  import os
8
- import sqlite3
9
+ import sys
9
10
  import threading
10
- import traceback
11
+ from datetime import datetime
11
12
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
13
  from urllib.parse import urlparse, parse_qs
13
14
 
@@ -17,6 +18,49 @@ from .paths import DB_PATH, LOCAL_SETTINGS_PATH, LOGFILE, PIDFILE, WEB_DIR, ensu
17
18
  WEB = WEB_DIR
18
19
  _lock = threading.Lock()
19
20
  A = None
21
+ log = logging.getLogger("finops")
22
+
23
+
24
+ class BadRequest(Exception):
25
+ pass
26
+
27
+
28
+ def _int(qs, key, default, lo=0, hi=1_000_000):
29
+ raw = qs.get(key, [None])[0]
30
+ if raw in (None, ""):
31
+ return default
32
+ try:
33
+ v = int(raw)
34
+ except ValueError:
35
+ raise BadRequest(f"{key} must be an integer")
36
+ return max(lo, min(hi, v))
37
+
38
+
39
+ SETTINGS_SHAPE = {
40
+ "budgets": dict, "limits": dict, "alert_thresholds_pct": list, "waste_rules": dict,
41
+ "anomaly": dict, "scorecard": dict, "account": dict, "billing_period": dict,
42
+ }
43
+
44
+
45
+ def _is_num(v):
46
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
47
+
48
+
49
+ def _validate_numeric_section(name, v):
50
+ """budgets/limits: every leaf a number or null, per_*_usd a dict of numbers."""
51
+ for k, leaf in v.items():
52
+ if k.startswith("_"):
53
+ continue
54
+ if k in ("per_project_usd", "per_model_usd"):
55
+ if not isinstance(leaf, dict) or not all(_is_num(x) for x in leaf.values()):
56
+ raise BadRequest(f"{name}.{k} must be a dict of numbers")
57
+ elif not (leaf is None or _is_num(leaf)):
58
+ raise BadRequest(f"{name}.{k} must be a number or null")
59
+
60
+
61
+ def _validate_thresholds(v):
62
+ if not all(_is_num(x) and 0 <= x <= 1000 for x in v):
63
+ raise BadRequest("alert_thresholds_pct must be a list of numbers 0..1000")
20
64
 
21
65
  MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
22
66
  ".css": "text/css; charset=utf-8", ".json": "application/json",
@@ -30,15 +74,39 @@ def filters_from(qs):
30
74
  for item in v:
31
75
  out += [x for x in item.split(",") if x]
32
76
  return out
77
+ try:
78
+ projects = [int(x) for x in lst("projects")]
79
+ except ValueError:
80
+ raise BadRequest("projects must be integers")
81
+
82
+ def _date(k):
83
+ v = qs.get(k, [None])[0] or None
84
+ if v is None:
85
+ return None
86
+ try:
87
+ datetime.strptime(v, "%Y-%m-%d")
88
+ except ValueError:
89
+ raise BadRequest(f"{k} must be YYYY-MM-DD")
90
+ return v
91
+
92
+ def _num(k, cast):
93
+ v = qs.get(k, [None])[0] or None
94
+ if v is None:
95
+ return None
96
+ try:
97
+ return cast(v)
98
+ except ValueError:
99
+ raise BadRequest(f"{k} must be a number")
100
+
33
101
  f = {
34
- "start": qs.get("start", [None])[0] or None,
35
- "end": qs.get("end", [None])[0] or None,
36
- "agents": lst("agents"), "models": lst("models"), "projects": lst("projects"),
102
+ "start": _date("start"),
103
+ "end": _date("end"),
104
+ "agents": lst("agents"), "models": lst("models"), "projects": projects,
37
105
  "sessions": lst("sessions"), "categories": lst("categories"),
38
106
  "include_sandbox": qs.get("include_sandbox", ["1"])[0] != "0",
39
- "min_cost": qs.get("min_cost", [None])[0] or None,
40
- "max_cost": qs.get("max_cost", [None])[0] or None,
41
- "min_tokens": qs.get("min_tokens", [None])[0] or None,
107
+ "min_cost": _num("min_cost", float),
108
+ "max_cost": _num("max_cost", float),
109
+ "min_tokens": _num("min_tokens", int),
42
110
  }
43
111
  return f
44
112
 
@@ -91,42 +159,65 @@ class Handler(BaseHTTPRequestHandler):
91
159
  self.send_response(code)
92
160
  self.send_header("Content-Type", "application/json")
93
161
  self.send_header("Content-Length", str(len(body)))
162
+ self.send_header("X-Content-Type-Options", "nosniff")
163
+ self.send_header("Referrer-Policy", "no-referrer")
94
164
  self.end_headers()
95
165
  self.wfile.write(body)
96
166
 
97
- def send_text(self, body, ctype, filename=None, code=200):
167
+ def send_text(self, body, ctype, filename=None, code=200, no_store=False):
98
168
  if isinstance(body, str):
99
169
  body = body.encode()
100
170
  self.send_response(code)
101
171
  self.send_header("Content-Type", ctype)
172
+ if no_store:
173
+ self.send_header("Cache-Control", "no-store, must-revalidate")
102
174
  if filename:
103
175
  self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
104
176
  self.send_header("Content-Length", str(len(body)))
177
+ self.send_header("X-Content-Type-Options", "nosniff")
178
+ self.send_header("Referrer-Policy", "no-referrer")
105
179
  self.end_headers()
106
180
  self.wfile.write(body)
107
181
 
182
+ def _host_ok(self):
183
+ host = (self.headers.get("Host") or "").split(":")[0]
184
+ return host in ("127.0.0.1", "localhost", "[::1]", "::1")
185
+
108
186
  def do_POST(self):
109
- n = int(self.headers.get("Content-Length") or 0)
110
- payload = json.loads(self.rfile.read(n) or b"{}")
187
+ if not self._host_ok():
188
+ return self.send_json({"error": "forbidden host"}, 403)
111
189
  path = urlparse(self.path).path
112
190
  try:
191
+ try:
192
+ n = max(0, min(int(self.headers.get("Content-Length") or 0), 5_000_000))
193
+ payload = json.loads(self.rfile.read(n) or b"{}")
194
+ if not isinstance(payload, dict):
195
+ raise ValueError
196
+ except ValueError:
197
+ return self.send_json({"error": "body must be a JSON object"}, 400)
113
198
  if path.startswith("/api/live/"):
114
199
  self._payload = payload
115
200
  return self.session_action(path)
116
- if path == "/api/trial/run":
117
- # Spends real money and drives a real agent, so it is guarded like
118
- # the other side-effecting routes and only ever reached by a click.
119
- if not self._same_origin():
120
- return self.send_json({"ok": False, "error": "forbidden"}, 403)
121
- from .trial import run
122
- return self.send_json(run(payload.get("category"), payload.get("model"),
123
- payload.get("prompts") or [],
124
- cwd=payload.get("cwd") or None))
125
201
  if path.startswith("/api/do/"):
126
202
  if not self._same_origin():
127
203
  return self.send_json({"ok": False, "error": "forbidden"}, 403)
128
204
  return self.do_action(path[len("/api/do/"):].strip("/").split("/"), payload)
129
205
  if path == "/api/settings":
206
+ if not self._same_origin():
207
+ return self.send_json({"ok": False, "error": "forbidden"}, 403)
208
+ for k, v in payload.items():
209
+ if k not in SETTINGS_SHAPE:
210
+ return self.send_json({"error": f"unknown key {k}"}, 400)
211
+ if not isinstance(v, SETTINGS_SHAPE[k]):
212
+ return self.send_json({"error": f"{k} must be a {SETTINGS_SHAPE[k].__name__}"}, 400)
213
+ bp = payload.get("billing_period") or {}
214
+ if "anchor_day" in bp and not (isinstance(bp["anchor_day"], int) and 1 <= bp["anchor_day"] <= 28):
215
+ return self.send_json({"error": "anchor_day must be 1..28"}, 400)
216
+ for section in ("budgets", "limits"):
217
+ if section in payload:
218
+ _validate_numeric_section(section, payload[section])
219
+ if "alert_thresholds_pct" in payload:
220
+ _validate_thresholds(payload["alert_thresholds_pct"])
130
221
  # UI edits go to the gitignored per-machine file, never the shared defaults
131
222
  local = {}
132
223
  if os.path.exists(LOCAL_SETTINGS_PATH):
@@ -146,8 +237,11 @@ class Handler(BaseHTTPRequestHandler):
146
237
  A.settings = cur
147
238
  return self.send_json({"ok": True, "settings": cur})
148
239
  self.send_json({"error": "unknown endpoint"}, 404)
240
+ except BadRequest as e:
241
+ self.send_json({"error": str(e)}, 400)
149
242
  except Exception:
150
- self.send_json({"error": traceback.format_exc()}, 500)
243
+ log.exception("POST %s failed", path)
244
+ self.send_json({"error": "internal error; see data/server.log"}, 500)
151
245
 
152
246
  def _same_origin(self):
153
247
  origin = self.headers.get("Origin")
@@ -239,6 +333,8 @@ class Handler(BaseHTTPRequestHandler):
239
333
  return self.send_json(act(int(parts[2]), parts[3]))
240
334
 
241
335
  def do_GET(self):
336
+ if not self._host_ok():
337
+ return self.send_json({"error": "forbidden host"}, 403)
242
338
  u = urlparse(self.path)
243
339
  path, qs = u.path, parse_qs(u.query)
244
340
  try:
@@ -246,15 +342,22 @@ class Handler(BaseHTTPRequestHandler):
246
342
  return self.api(path[5:], qs)
247
343
  rel = "index.html" if path in ("/", "") else path.lstrip("/")
248
344
  fp = os.path.normpath(os.path.join(WEB, rel))
249
- if not fp.startswith(WEB) or not os.path.isfile(fp):
345
+ if os.path.commonpath([fp, WEB]) != WEB or not os.path.isfile(fp):
250
346
  return self.send_text("not found", "text/plain", code=404)
251
347
  ext = os.path.splitext(fp)[1]
252
348
  with open(fp, "rb") as fh:
253
- self.send_text(fh.read(), MIME.get(ext, "application/octet-stream"))
349
+ # Served from disk on every request and revalidated every time: this is
350
+ # localhost, so the fetch is free, and a stale cached app.js after an
351
+ # upgrade looks exactly like "the new feature is missing".
352
+ self.send_text(fh.read(), MIME.get(ext, "application/octet-stream"),
353
+ no_store=True)
254
354
  except BrokenPipeError:
255
355
  pass
356
+ except BadRequest as e:
357
+ self.send_json({"error": str(e) or "bad request"}, 400)
256
358
  except Exception:
257
- self.send_json({"error": traceback.format_exc()}, 500)
359
+ log.exception("GET %s failed", path)
360
+ self.send_json({"error": "internal error; see data/server.log"}, 500)
258
361
 
259
362
  def api(self, route, qs):
260
363
  if route == "update":
@@ -263,6 +366,9 @@ class Handler(BaseHTTPRequestHandler):
263
366
  if route == "usage":
264
367
  from .limits import usage
265
368
  return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
369
+ if route == "plan_history":
370
+ from .plan_history import history
371
+ return self.send_json(history())
266
372
  if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
267
373
  return self.actions_get(route)
268
374
  f = filters_from(qs)
@@ -282,34 +388,41 @@ class Handler(BaseHTTPRequestHandler):
282
388
  return self.send_json(a.burn(f))
283
389
  if route == "timeline":
284
390
  return self.send_json(a.timeline(f, g("grain", "day")))
391
+ if route == "heatmap":
392
+ return self.send_json(a.heatmap(f))
285
393
  if route == "models":
286
394
  return self.send_json(a.models(f))
287
395
  if route == "projects":
288
396
  return self.send_json(a.projects(f))
289
397
  if route == "sessions":
290
- return self.send_json(a.sessions(f, int(g("limit", 200)), g("order", "cost")))
398
+ limit = _int(qs, "limit", 200, 1, 5000)
399
+ offset = _int(qs, "offset", 0, 0)
400
+ return self.send_json({"rows": a.sessions(f, limit, g("order", "cost"), offset),
401
+ "total": a.sessions_total(f)})
291
402
  if route == "prompts":
292
- return self.send_json(a.prompts(f, int(g("limit", 200)), int(g("offset", 0)),
293
- g("order", "cost"), g("q")))
403
+ limit = _int(qs, "limit", 200, 1, 5000)
404
+ offset = _int(qs, "offset", 0, 0)
405
+ search = g("q")
406
+ return self.send_json({"rows": a.prompts(f, limit, offset, g("order", "cost"), search),
407
+ "total": a.prompts_total(f, search)})
294
408
  if route == "leaderboards":
295
- return self.send_json(a.leaderboards(f, int(g("n", 20))))
409
+ return self.send_json(a.leaderboards(f, _int(qs, "n", 20)))
296
410
  if route == "categories":
297
411
  return self.send_json(a.categories(f))
298
412
  if route == "efficiency":
299
413
  return self.send_json(a.efficiency(f))
414
+ if route == "long_context_pricing":
415
+ return self.send_json(a.long_context_pricing(f))
416
+ if route == "ttl_replay":
417
+ return self.send_json(a.ttl_replay(f))
418
+ if route == "hygiene":
419
+ return self.send_json(a.hygiene(f, top=_int(qs, "top", 12)))
300
420
  if route == "context":
301
421
  return self.send_json(a.context_analysis(f))
302
422
  if route == "waste":
303
423
  return self.send_json(a.waste(f))
304
- if route == "trial":
305
- from .trial import samples, available
306
- return self.send_json({"available": available(),
307
- "samples": samples(qs.get("category", [""])[0],
308
- int(qs.get("limit", ["3"])[0]))})
309
- if route == "model_evidence":
310
- return self.send_json(a.model_evidence(f))
311
- if route == "model_switch":
312
- return self.send_json(a.model_switch(f))
424
+ if route == "context_window_fit":
425
+ return self.send_json(a.context_window_fit(f))
313
426
  if route == "recommendations":
314
427
  return self.send_json(a.recommendations(f))
315
428
  if route == "forecast":
@@ -329,7 +442,7 @@ class Handler(BaseHTTPRequestHandler):
329
442
  from .diagnose import Diagnoser
330
443
  return self.send_json(Diagnoser(a).breakdown(f))
331
444
  if route == "live":
332
- from .procs import list_sessions, list_agent_sessions
445
+ from .procs import list_sessions, list_agent_sessions, ACTIONS
333
446
  want = [x for x in (g("agents", "") or "").split(",") if x]
334
447
  claude = [dict(s, agent="claude", signalable=True,
335
448
  resume=f"claude --resume {s.get('session_id')}")
@@ -337,27 +450,20 @@ class Handler(BaseHTTPRequestHandler):
337
450
  others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
338
451
  if not want or any(x != "claude" for x in want) else []
339
452
  rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
340
- # Live model advice, while the session can still act on it. One cached
341
- # evidence read for the whole list, and never fatal to the view.
342
- try:
343
- from .advisor import advise, evidence
344
- ev = evidence()
345
- for r in rows:
346
- r["advice"] = advise(model=r.get("model"), transcript=r.get("transcript"),
347
- ev=ev) if ev else None
348
- except Exception:
349
- for r in rows:
350
- r.setdefault("advice", None)
351
- return self.send_json({"sessions": rows})
453
+ return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
352
454
  if route == "cloud":
353
455
  from .cloud import report
354
- return self.send_json(report(a, int(g("days", 30))))
456
+ return self.send_json(report(a, _int(qs, "days", 30)))
355
457
  if route == "developer":
356
458
  return self.send_json(a.developer(f))
357
459
  if route == "search":
358
- return self.send_json(a.search(g("q", ""), int(g("limit", 40))))
460
+ return self.send_json(a.search(g("q", ""), _int(qs, "limit", 40)))
359
461
  if route.startswith("prompt/"):
360
- return self.send_json(a.prompt_detail(int(route.split("/")[1])))
462
+ try:
463
+ pid = int(route.split("/")[1])
464
+ except ValueError:
465
+ raise BadRequest("id must be an integer")
466
+ return self.send_json(a.prompt_detail(pid))
361
467
  if route.startswith("session/"):
362
468
  return self.send_json(a.session_detail(route.split("/", 1)[1]))
363
469
  if route == "bundle":
@@ -449,7 +555,7 @@ def _sync_job(log):
449
555
  res = X.sync(log)
450
556
  with _lock:
451
557
  try:
452
- A.db.close() # Windows can't replace a file that's still open
558
+ A.close() # Windows can't replace a file that's still open
453
559
  except Exception:
454
560
  pass
455
561
  X.finish_sync(res["tmp"])
@@ -465,12 +571,21 @@ def _notify_update():
465
571
 
466
572
  def serve(port=8787, db=DB_PATH, background=None):
467
573
  global A
574
+ ensure_dirs()
575
+ if not log.handlers:
576
+ fh = logging.FileHandler(LOGFILE)
577
+ fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
578
+ log.addHandler(fh)
579
+ log.setLevel(logging.ERROR)
468
580
  if not os.path.exists(db):
469
581
  raise SystemExit(f"No warehouse at {db}. Run: python3 -m finops.etl")
470
582
  if background is None:
471
583
  background = os.environ.get("FINOPS_DETACH") == "1" or _under_claude()
472
584
  if background and hasattr(os, "fork"):
473
585
  detach(port)
586
+ from .etl import needs_rebuild
587
+ if needs_rebuild(db):
588
+ print("Warehouse schema is out of date. Run: claude-finops --rebuild", file=sys.stderr)
474
589
  A = Analytics(db)
475
590
  srv = Server(("127.0.0.1", port), Handler)
476
591
  print(f"Claude FinOps Command Center -> http://127.0.0.1:{port}")
@@ -478,11 +593,20 @@ def serve(port=8787, db=DB_PATH, background=None):
478
593
  print(f" data : {A.first_day} .. {A.last_day}")
479
594
  print(" Ctrl-C to stop.")
480
595
  # Off the main thread: a slow registry must never delay the dashboard.
481
- threading.Thread(target=_notify_update, daemon=True).start()
596
+ from .update import disabled
597
+ if not disabled():
598
+ threading.Thread(target=_notify_update, daemon=True).start()
599
+ with open(PIDFILE, "w") as fh:
600
+ fh.write(str(os.getpid()))
482
601
  try:
483
602
  srv.serve_forever()
484
603
  except KeyboardInterrupt:
485
604
  print("\nbye")
605
+ finally:
606
+ try:
607
+ os.remove(PIDFILE)
608
+ except OSError:
609
+ pass
486
610
 
487
611
 
488
612
  if __name__ == "__main__":
@@ -154,7 +154,7 @@ class Diagnoser:
154
154
  a = self.a
155
155
  if not a.last_day:
156
156
  return None
157
- end = _d(f.get("end") or a.last_day)
157
+ end = _d(f.get("end")) if f.get("end") else (a.today() - timedelta(days=1))
158
158
  ranges = {"current": (end - timedelta(days=6), end),
159
159
  "previous": (end - timedelta(days=13), end - timedelta(days=7))}
160
160
  res = {}
@@ -189,7 +189,6 @@ class Diagnoser:
189
189
  t = drv["total"]
190
190
  if not t.get("n"):
191
191
  return recs
192
- cr_rate_cost = a.pricing.estimate(self._main_model(f), cache_read=t["cr"] or 0)
193
192
 
194
193
  if d.get("context_reread", {}).get("share_pct", 0) > 60:
195
194
  recs.append({
@@ -198,27 +197,27 @@ class Diagnoser:
198
197
  "how": f"Run {v['clear']} when you switch to an unrelated task, and {v['compact']} once a "
199
198
  "session passes ~100K context. Start one session per ticket rather than "
200
199
  "one per day.",
201
- "est_savings_usd": round(cr_rate_cost * 0.3, 2),
202
- "savings_basis": "30% fewer cache-read tokens, priced at your main model's rate"})
200
+ # No dollar figure: the old one assumed 30% fewer cache-read tokens, a
201
+ # number that came from nowhere in the data.
202
+ })
203
203
  if d.get("long_sessions", {}).get("share_pct", 0) > 30:
204
204
  recs.append({
205
205
  "priority": 1, "title": "Break up marathon sessions",
206
206
  "why": d["long_sessions"]["detail"],
207
207
  "how": f"Finish a unit of work, write the state to a file or memory, then {v['clear']}. "
208
208
  "Use the Sessions view sorted by cost to find the worst ones.",
209
- "est_savings_usd": None, "savings_basis": None})
209
+ })
210
210
  fm = d.get("frontier_model")
211
211
  if fm and fm["share_pct"] > 70:
212
- mr = a.recommendations(f)
213
- save = sum(r["estimated_savings_usd"] for r in mr["recommendations"]
214
- if r["type"] == "model_downgrade")
212
+ # No dollar figure: the repriced model-switch estimate was removed because it
213
+ # held turn counts fixed and ignored the per-model cache. The observation
214
+ # (most spend is on the frontier model) stands; the saving is not knowable
215
+ # without running the work on both models.
215
216
  recs.append({
216
217
  "priority": 2, "title": v["cheaper"],
217
218
  "why": fm["detail"],
218
219
  "how": v["model_how"],
219
- "est_savings_usd": round(save, 2) if save else None,
220
- "savings_basis": "Model-downgrade estimate from the Recommendations view; "
221
- "quality not modelled"})
220
+ })
222
221
  th = d.get("tool_heavy")
223
222
  if th and th.get("evidence"):
224
223
  bash = next((x for x in th["evidence"] if x["name"] in v["shell"]), None)
@@ -230,7 +229,7 @@ class Diagnoser:
230
229
  "how": "Ask for `| head`/`| tail`, quiet flags (`-q`, `--silent`), and run "
231
230
  "test suites with a reporter that prints failures only. Add these "
232
231
  "habits to " + v["md"] + " so the agent does it unprompted.",
233
- "est_savings_usd": None, "savings_basis": None})
232
+ })
234
233
  mcp = [x for x in th["evidence"] if x["name"].startswith("mcp__")]
235
234
  if mcp:
236
235
  recs.append({
@@ -240,7 +239,7 @@ class Diagnoser:
240
239
  "how": "Prefer targeted reads (find / get_page_text) over screenshots, and "
241
240
  "do browser checks in a subagent so the payload doesn't stay in the "
242
241
  "main session.",
243
- "est_savings_usd": None, "savings_basis": None})
242
+ })
244
243
  sub = d.get("subagents")
245
244
  if sub and sub["share_pct"] > 25:
246
245
  recs.append({
@@ -248,7 +247,7 @@ class Diagnoser:
248
247
  "why": sub["detail"],
249
248
  "how": "Use subagents for wide searches only; for known files, read them "
250
249
  "directly. Set `model: sonnet` or `haiku` in custom agent frontmatter.",
251
- "est_savings_usd": None, "savings_basis": None})
250
+ })
252
251
  n_fix = sum(1 for pj in projects if pj["issues"])
253
252
  if n_fix:
254
253
  recs.append({
@@ -256,8 +255,8 @@ class Diagnoser:
256
255
  "why": "Missing or oversized CLAUDE.md, oversized memory, repeated file "
257
256
  "re-reads or reads of generated files — see the project table below.",
258
257
  "how": "Apply the per-project fixes listed below.",
259
- "est_savings_usd": None, "savings_basis": None})
260
- recs.sort(key=lambda r: (r["priority"], -(r["est_savings_usd"] or 0)))
258
+ })
259
+ recs.sort(key=lambda r: r["priority"])
261
260
  return recs
262
261
 
263
262
  def _main_model(self, f):
@@ -538,7 +537,7 @@ class Diagnoser:
538
537
  a = self.a
539
538
  f = f or {}
540
539
  w, p = a.where(f)
541
- rate = a.pricing.rates(self._main_model(f)).get("cache_read", 0.3) / 1e6
540
+ rate = (a.pricing.rates(self._main_model(f)).get("cache_read") or 0) / 1e6
542
541
  tcw = f"tc.request_pk IN (SELECT r.id FROM requests r WHERE {w})"
543
542
 
544
543
  sessions = a.q(f"""SELECT r.session_id, s.title, pj.name project,
@@ -692,7 +691,6 @@ class Diagnoser:
692
691
  FROM requests r JOIN sessions s ON s.id=r.session_id JOIN projects pj ON pj.id=r.project_id
693
692
  WHERE {w} GROUP BY r.session_id HAVING peak >= ? ORDER BY over_base DESC LIMIT ?""",
694
693
  p + [self.CTX_WARN, limit])
695
- rate = a.pricing.rates(self._main_model(f)).get("cache_read", 0.3) / 1e6
696
694
  compacts = {r["session_id"]: r["n"] for r in a.q(
697
695
  "SELECT session_id, COUNT(*) n FROM prompts WHERE source='slash:/compact' GROUP BY 1")}
698
696
  for r in rows:
@@ -702,8 +700,7 @@ class Diagnoser:
702
700
  # NULL used to take the whole diagnose page down with a 500.
703
701
  over = r["over_base"] or 0
704
702
  r["over_base"] = over
705
- r["avoidable_tokens"] = over
706
- r["avoidable_cost"] = over * rate
703
+ r["tokens_above_100k"] = over # re-read above a 100K baseline; not a saving
707
704
  if not compacts.get(sid):
708
705
  fixes.append(f"Never compacted. {r['heavy_steps']:,} steps ran above "
709
706
  f"{self.CTX_WARN // 1000}K context; /compact (or /clear between the "