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/README.md +12 -47
- package/config/pricing.json +62 -42
- package/config/settings.json +11 -5
- package/finops/actions.py +1 -1
- package/finops/analytics.py +690 -611
- package/finops/api.py +174 -55
- package/finops/diagnose.py +17 -20
- package/finops/etl.py +149 -52
- package/finops/integrate.py +22 -57
- package/finops/pricing.py +57 -6
- package/finops/procs.py +7 -5
- package/finops/report.py +29 -23
- package/finops/segments.py +149 -0
- package/package.json +1 -1
- package/run.cmd +3 -2
- package/run.py +26 -59
- package/web/app.js +253 -329
- package/web/charts.js +26 -15
- package/finops/advisor.py +0 -188
- package/finops/trial.py +0 -169
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
|
|
9
|
+
import sys
|
|
9
10
|
import threading
|
|
10
|
-
import
|
|
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":
|
|
35
|
-
"end":
|
|
36
|
-
"agents": lst("agents"), "models": lst("models"), "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":
|
|
40
|
-
"max_cost":
|
|
41
|
-
"min_tokens":
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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,
|
|
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 == "
|
|
305
|
-
|
|
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
|
-
|
|
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,
|
|
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", ""),
|
|
455
|
+
return self.send_json(a.search(g("q", ""), _int(qs, "limit", 40)))
|
|
359
456
|
if route.startswith("prompt/"):
|
|
360
|
-
|
|
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.
|
|
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
|
-
|
|
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__":
|
package/finops/diagnose.py
CHANGED
|
@@ -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")
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
209
|
+
})
|
|
210
210
|
fm = d.get("frontier_model")
|
|
211
211
|
if fm and fm["share_pct"] > 70:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
recs.sort(key=lambda r:
|
|
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"
|
|
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["
|
|
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 "
|