claude-finops 0.7.2 → 0.8.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":
@@ -287,29 +390,34 @@ class Handler(BaseHTTPRequestHandler):
287
390
  if route == "projects":
288
391
  return self.send_json(a.projects(f))
289
392
  if route == "sessions":
290
- return self.send_json(a.sessions(f, int(g("limit", 200)), g("order", "cost")))
393
+ limit = _int(qs, "limit", 200, 1, 5000)
394
+ offset = _int(qs, "offset", 0, 0)
395
+ return self.send_json({"rows": a.sessions(f, limit, g("order", "cost"), offset),
396
+ "total": a.sessions_total(f)})
291
397
  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")))
398
+ limit = _int(qs, "limit", 200, 1, 5000)
399
+ offset = _int(qs, "offset", 0, 0)
400
+ search = g("q")
401
+ return self.send_json({"rows": a.prompts(f, limit, offset, g("order", "cost"), search),
402
+ "total": a.prompts_total(f, search)})
294
403
  if route == "leaderboards":
295
- return self.send_json(a.leaderboards(f, int(g("n", 20))))
404
+ return self.send_json(a.leaderboards(f, _int(qs, "n", 20)))
296
405
  if route == "categories":
297
406
  return self.send_json(a.categories(f))
298
407
  if route == "efficiency":
299
408
  return self.send_json(a.efficiency(f))
409
+ if route == "long_context_pricing":
410
+ return self.send_json(a.long_context_pricing(f))
411
+ if route == "ttl_replay":
412
+ return self.send_json(a.ttl_replay(f))
413
+ if route == "hygiene":
414
+ return self.send_json(a.hygiene(f, top=_int(qs, "top", 12)))
300
415
  if route == "context":
301
416
  return self.send_json(a.context_analysis(f))
302
417
  if route == "waste":
303
418
  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))
419
+ if route == "context_window_fit":
420
+ return self.send_json(a.context_window_fit(f))
313
421
  if route == "recommendations":
314
422
  return self.send_json(a.recommendations(f))
315
423
  if route == "forecast":
@@ -329,7 +437,7 @@ class Handler(BaseHTTPRequestHandler):
329
437
  from .diagnose import Diagnoser
330
438
  return self.send_json(Diagnoser(a).breakdown(f))
331
439
  if route == "live":
332
- from .procs import list_sessions, list_agent_sessions
440
+ from .procs import list_sessions, list_agent_sessions, ACTIONS
333
441
  want = [x for x in (g("agents", "") or "").split(",") if x]
334
442
  claude = [dict(s, agent="claude", signalable=True,
335
443
  resume=f"claude --resume {s.get('session_id')}")
@@ -337,27 +445,20 @@ class Handler(BaseHTTPRequestHandler):
337
445
  others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
338
446
  if not want or any(x != "claude" for x in want) else []
339
447
  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})
448
+ return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
352
449
  if route == "cloud":
353
450
  from .cloud import report
354
- return self.send_json(report(a, int(g("days", 30))))
451
+ return self.send_json(report(a, _int(qs, "days", 30)))
355
452
  if route == "developer":
356
453
  return self.send_json(a.developer(f))
357
454
  if route == "search":
358
- return self.send_json(a.search(g("q", ""), int(g("limit", 40))))
455
+ return self.send_json(a.search(g("q", ""), _int(qs, "limit", 40)))
359
456
  if route.startswith("prompt/"):
360
- return self.send_json(a.prompt_detail(int(route.split("/")[1])))
457
+ try:
458
+ pid = int(route.split("/")[1])
459
+ except ValueError:
460
+ raise BadRequest("id must be an integer")
461
+ return self.send_json(a.prompt_detail(pid))
361
462
  if route.startswith("session/"):
362
463
  return self.send_json(a.session_detail(route.split("/", 1)[1]))
363
464
  if route == "bundle":
@@ -449,7 +550,7 @@ def _sync_job(log):
449
550
  res = X.sync(log)
450
551
  with _lock:
451
552
  try:
452
- A.db.close() # Windows can't replace a file that's still open
553
+ A.close() # Windows can't replace a file that's still open
453
554
  except Exception:
454
555
  pass
455
556
  X.finish_sync(res["tmp"])
@@ -465,12 +566,21 @@ def _notify_update():
465
566
 
466
567
  def serve(port=8787, db=DB_PATH, background=None):
467
568
  global A
569
+ ensure_dirs()
570
+ if not log.handlers:
571
+ fh = logging.FileHandler(LOGFILE)
572
+ fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
573
+ log.addHandler(fh)
574
+ log.setLevel(logging.ERROR)
468
575
  if not os.path.exists(db):
469
576
  raise SystemExit(f"No warehouse at {db}. Run: python3 -m finops.etl")
470
577
  if background is None:
471
578
  background = os.environ.get("FINOPS_DETACH") == "1" or _under_claude()
472
579
  if background and hasattr(os, "fork"):
473
580
  detach(port)
581
+ from .etl import needs_rebuild
582
+ if needs_rebuild(db):
583
+ print("Warehouse schema is out of date. Run: claude-finops --rebuild", file=sys.stderr)
474
584
  A = Analytics(db)
475
585
  srv = Server(("127.0.0.1", port), Handler)
476
586
  print(f"Claude FinOps Command Center -> http://127.0.0.1:{port}")
@@ -478,11 +588,20 @@ def serve(port=8787, db=DB_PATH, background=None):
478
588
  print(f" data : {A.first_day} .. {A.last_day}")
479
589
  print(" Ctrl-C to stop.")
480
590
  # Off the main thread: a slow registry must never delay the dashboard.
481
- threading.Thread(target=_notify_update, daemon=True).start()
591
+ from .update import disabled
592
+ if not disabled():
593
+ threading.Thread(target=_notify_update, daemon=True).start()
594
+ with open(PIDFILE, "w") as fh:
595
+ fh.write(str(os.getpid()))
482
596
  try:
483
597
  srv.serve_forever()
484
598
  except KeyboardInterrupt:
485
599
  print("\nbye")
600
+ finally:
601
+ try:
602
+ os.remove(PIDFILE)
603
+ except OSError:
604
+ pass
486
605
 
487
606
 
488
607
  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 "