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.
- package/README.md +115 -4
- package/config/model_compare.json +4 -2
- package/config/pricing.json +17 -6
- package/config/settings.json +17 -0
- package/finops/actions.py +54 -27
- package/finops/agents.py +13 -39
- package/finops/analytics.py +432 -135
- package/finops/api.py +199 -16
- package/finops/classify.py +6 -0
- package/finops/cloud.py +59 -7
- package/finops/diagnose.py +18 -5
- package/finops/etl.py +171 -7
- package/finops/guard.py +412 -0
- package/finops/integrate.py +78 -0
- package/finops/jev.py +177 -0
- package/finops/localtime.py +53 -0
- package/finops/pricing.py +27 -0
- package/finops/subagents.py +358 -0
- package/package.json +1 -1
- package/run.py +18 -12
- package/web/app.js +1046 -124
- package/web/charts.js +14 -6
- package/web/guide/budgets.png +0 -0
- package/web/styles.css +73 -0
package/finops/api.py
CHANGED
|
@@ -38,7 +38,7 @@ def _int(qs, key, default, lo=0, hi=1_000_000):
|
|
|
38
38
|
|
|
39
39
|
SETTINGS_SHAPE = {
|
|
40
40
|
"budgets": dict, "limits": dict, "alert_thresholds_pct": list, "waste_rules": dict,
|
|
41
|
-
"anomaly": dict, "scorecard": dict, "account": dict, "billing_period": dict,
|
|
41
|
+
"anomaly": dict, "scorecard": dict, "account": dict, "billing_period": dict, "guard": dict,
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
|
|
@@ -62,6 +62,89 @@ def _validate_thresholds(v):
|
|
|
62
62
|
if not all(_is_num(x) and 0 <= x <= 1000 for x in v):
|
|
63
63
|
raise BadRequest("alert_thresholds_pct must be a list of numbers 0..1000")
|
|
64
64
|
|
|
65
|
+
def _validate_guard(v):
|
|
66
|
+
from .guard import validate_guard
|
|
67
|
+
_, bad = validate_guard(v, {})
|
|
68
|
+
if bad:
|
|
69
|
+
raise BadRequest(f"invalid {', '.join(bad)}: session_tokens > 0 or null, warn_pct "
|
|
70
|
+
"numbers between 0 and 100, after_approval step|once, step_pct 1..1000, "
|
|
71
|
+
"projects {path: {session_tokens: n} | {off: true}}")
|
|
72
|
+
|
|
73
|
+
class NotFound(BadRequest):
|
|
74
|
+
"""A 404 raised from a helper; handled next to BadRequest."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _session_transcript(sid):
|
|
78
|
+
from . import guard as G
|
|
79
|
+
from .etl import DEFAULT_SOURCE
|
|
80
|
+
if not G.SAFE_ID.match(sid or ""):
|
|
81
|
+
raise BadRequest("That is not a Claude Code session id.")
|
|
82
|
+
tp = G.find_transcript(sid, [os.environ.get("CLAUDE_PROJECTS") or DEFAULT_SOURCE])
|
|
83
|
+
if not tp:
|
|
84
|
+
raise NotFound("No Claude Code transcript found for that session.")
|
|
85
|
+
return tp
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _session_limit_status(a, sid):
|
|
89
|
+
"""GET /api/guard/session/<id>: tokens used so far (as the guard counts them),
|
|
90
|
+
this session's own limit, the budget that applies, and whether the hook is installed."""
|
|
91
|
+
from . import guard as G
|
|
92
|
+
from .integrate import guard_state
|
|
93
|
+
tp = _session_transcript(sid)
|
|
94
|
+
cwd = None
|
|
95
|
+
with open(tp, errors="replace") as fh:
|
|
96
|
+
for i, line in enumerate(fh):
|
|
97
|
+
if '"cwd"' in line:
|
|
98
|
+
try:
|
|
99
|
+
cwd = json.loads(line).get("cwd")
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
if cwd or i > 50:
|
|
103
|
+
break
|
|
104
|
+
cfg = a.settings.get("guard") or {}
|
|
105
|
+
try:
|
|
106
|
+
installed = guard_state() == "installed"
|
|
107
|
+
except Exception:
|
|
108
|
+
installed = False
|
|
109
|
+
return {"session_id": sid, "tokens": G.session_usage(tp, sid),
|
|
110
|
+
"limit": (cfg.get("sessions") or {}).get(sid), "applies": G.budget_for(cfg, cwd, sid),
|
|
111
|
+
"installed": installed}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _set_session_limit(sid, payload):
|
|
115
|
+
"""POST /api/do/session_limit/<id>: {session_tokens: n} | {off: true} | {remove: true}."""
|
|
116
|
+
global A
|
|
117
|
+
from . import guard as G
|
|
118
|
+
import time
|
|
119
|
+
if not G.SAFE_ID.match(sid or ""):
|
|
120
|
+
raise BadRequest("That is not a Claude Code session id.")
|
|
121
|
+
if payload.get("remove"):
|
|
122
|
+
entry = None
|
|
123
|
+
elif payload.get("off") is True:
|
|
124
|
+
entry = {"off": True}
|
|
125
|
+
elif _is_num(payload.get("session_tokens")) and payload["session_tokens"] > 0:
|
|
126
|
+
entry = {"session_tokens": payload["session_tokens"]}
|
|
127
|
+
else:
|
|
128
|
+
raise BadRequest("Enter a token limit above 0, or turn the guard off for this session.")
|
|
129
|
+
local = {}
|
|
130
|
+
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
131
|
+
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
132
|
+
local = json.load(fh)
|
|
133
|
+
g = local.setdefault("guard", {})
|
|
134
|
+
sessions = dict(g.get("sessions") or {})
|
|
135
|
+
if entry is None:
|
|
136
|
+
sessions.pop(sid, None)
|
|
137
|
+
else:
|
|
138
|
+
sessions[sid] = dict(entry, set_at=time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
139
|
+
g["sessions"] = G.prune_sessions(sessions)
|
|
140
|
+
with open(LOCAL_SETTINGS_PATH, "w") as fh:
|
|
141
|
+
json.dump(local, fh, indent=2)
|
|
142
|
+
cur = load_settings()
|
|
143
|
+
with _lock:
|
|
144
|
+
A.settings = cur
|
|
145
|
+
return {"ok": True, "limit": g["sessions"].get(sid)}
|
|
146
|
+
|
|
147
|
+
|
|
65
148
|
MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
66
149
|
".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
67
150
|
".svg": "image/svg+xml", ".ico": "image/x-icon"}
|
|
@@ -111,6 +194,14 @@ def filters_from(qs):
|
|
|
111
194
|
return f
|
|
112
195
|
|
|
113
196
|
|
|
197
|
+
def _costs_by_day_model(a, f):
|
|
198
|
+
"""One row per day and model: the cost export, distinct from the usage timeline."""
|
|
199
|
+
w, p = a.where(f)
|
|
200
|
+
return a.q(f"""SELECT r.day, r.model, COUNT(*) requests, SUM(r.billable_tokens) tokens,
|
|
201
|
+
SUM(r.est_cost_usd) est_cost_usd FROM requests r
|
|
202
|
+
WHERE {w} AND r.day <> '' GROUP BY 1, 2 ORDER BY 1, 5 DESC""", p)
|
|
203
|
+
|
|
204
|
+
|
|
114
205
|
def flatten(rows):
|
|
115
206
|
keys = []
|
|
116
207
|
for r in rows:
|
|
@@ -218,6 +309,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
218
309
|
_validate_numeric_section(section, payload[section])
|
|
219
310
|
if "alert_thresholds_pct" in payload:
|
|
220
311
|
_validate_thresholds(payload["alert_thresholds_pct"])
|
|
312
|
+
if "guard" in payload:
|
|
313
|
+
_validate_guard(payload["guard"])
|
|
221
314
|
# UI edits go to the gitignored per-machine file, never the shared defaults
|
|
222
315
|
local = {}
|
|
223
316
|
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
@@ -225,7 +318,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
225
318
|
local = json.load(fh)
|
|
226
319
|
for k, v in payload.items():
|
|
227
320
|
if k in ("budgets", "limits", "alert_thresholds_pct", "waste_rules",
|
|
228
|
-
"anomaly", "scorecard", "account", "billing_period"):
|
|
321
|
+
"anomaly", "scorecard", "account", "billing_period", "guard"):
|
|
229
322
|
if isinstance(v, dict) and isinstance(local.get(k), dict):
|
|
230
323
|
local[k].update(v)
|
|
231
324
|
else:
|
|
@@ -238,7 +331,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
238
331
|
return self.send_json({"ok": True, "settings": cur})
|
|
239
332
|
self.send_json({"error": "unknown endpoint"}, 404)
|
|
240
333
|
except BadRequest as e:
|
|
241
|
-
self.send_json({"error": str(e)}, 400)
|
|
334
|
+
self.send_json({"error": str(e)}, 404 if isinstance(e, NotFound) else 400)
|
|
242
335
|
except Exception:
|
|
243
336
|
log.exception("POST %s failed", path)
|
|
244
337
|
self.send_json({"error": "internal error; see data/server.log"}, 500)
|
|
@@ -252,10 +345,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
252
345
|
"""POST /api/do/...: actions that change this machine. Always user-initiated."""
|
|
253
346
|
from . import actions as X
|
|
254
347
|
if parts == ["sync"]:
|
|
255
|
-
|
|
256
|
-
if not cur or cur["state"] != "running":
|
|
257
|
-
_SYNC["job"] = X._job(_sync_job)["id"]
|
|
258
|
-
return self.send_json({"job": _SYNC["job"]})
|
|
348
|
+
return self.send_json({"job": _start_sync()})
|
|
259
349
|
if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "install":
|
|
260
350
|
j = X._job(X.free_model_install, parts[1], bool(payload.get("consent")),
|
|
261
351
|
payload.get("api_key"))
|
|
@@ -273,6 +363,44 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
273
363
|
if parts == ["statusline"]:
|
|
274
364
|
from .integrate import install_statusline
|
|
275
365
|
return self.send_json(install_statusline())
|
|
366
|
+
if len(parts) == 2 and parts[0] == "session_limit":
|
|
367
|
+
return self.send_json(_set_session_limit(parts[1], payload))
|
|
368
|
+
if parts in (["jev", "install"], ["jev", "uninstall"]):
|
|
369
|
+
from . import jev
|
|
370
|
+
return self.send_json({"job": X._job(jev.install if parts[1] == "install" else jev.uninstall)["id"]})
|
|
371
|
+
if parts == ["jev", "key"]:
|
|
372
|
+
from . import jev
|
|
373
|
+
try:
|
|
374
|
+
if payload.get("remove"):
|
|
375
|
+
jev.remove_key()
|
|
376
|
+
else:
|
|
377
|
+
jev.save_key(payload.get("value") if isinstance(payload.get("value"), str) else "")
|
|
378
|
+
except ValueError as e:
|
|
379
|
+
raise BadRequest(str(e))
|
|
380
|
+
return self.send_json({"ok": True, "key": jev.key_status()})
|
|
381
|
+
if len(parts) == 2 and parts[0] == "key":
|
|
382
|
+
from .cloud import save_key, key_status
|
|
383
|
+
value = payload.get("value")
|
|
384
|
+
if not payload.get("remove") and not (isinstance(value, str) and value.strip()):
|
|
385
|
+
raise BadRequest("Paste a key, or use Remove.")
|
|
386
|
+
try:
|
|
387
|
+
save_key(parts[1], "" if payload.get("remove") else value)
|
|
388
|
+
except ValueError as e:
|
|
389
|
+
raise BadRequest(str(e))
|
|
390
|
+
return self.send_json({"ok": True, "keys": key_status()})
|
|
391
|
+
if parts == ["guard"]:
|
|
392
|
+
from .integrate import install_guard
|
|
393
|
+
return self.send_json(install_guard(remove=bool(payload.get("remove"))))
|
|
394
|
+
if parts == ["subagent_experiment"]:
|
|
395
|
+
from . import subagents as SA
|
|
396
|
+
try:
|
|
397
|
+
if payload.get("action") == "start":
|
|
398
|
+
return self.send_json(SA.start_experiment(payload.get("model")))
|
|
399
|
+
if payload.get("action") == "stop":
|
|
400
|
+
return self.send_json(SA.stop_experiment())
|
|
401
|
+
except (ValueError, SA.SettingsError) as e:
|
|
402
|
+
raise BadRequest(str(e))
|
|
403
|
+
raise BadRequest("action must be start or stop")
|
|
276
404
|
if len(parts) == 2 and parts[0] == "mcp":
|
|
277
405
|
return self.send_json(X.add_mcp(parts[1], payload))
|
|
278
406
|
return self.send_json({"error": "unknown action"}, 404)
|
|
@@ -288,12 +416,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
288
416
|
with _lock:
|
|
289
417
|
a = A
|
|
290
418
|
qs = parse_qs(urlparse(self.path).query)
|
|
291
|
-
|
|
292
|
-
return self.send_json(X.compare(a,
|
|
419
|
+
f = filters_from(qs)
|
|
420
|
+
return self.send_json(X.compare(a, f["agents"] or None, f))
|
|
293
421
|
if parts == ["suggestions"]:
|
|
294
422
|
with _lock:
|
|
295
423
|
a = A
|
|
296
|
-
return self.send_json(X.suggestions(a))
|
|
424
|
+
return self.send_json(X.suggestions(a, filters_from(parse_qs(urlparse(self.path).query))))
|
|
297
425
|
if parts[0] == "job" and len(parts) == 2:
|
|
298
426
|
return self.send_json(X.job_status(parts[1]))
|
|
299
427
|
if parts == ["sync"]:
|
|
@@ -357,7 +485,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
357
485
|
except BrokenPipeError:
|
|
358
486
|
pass
|
|
359
487
|
except BadRequest as e:
|
|
360
|
-
self.send_json({"error": str(e) or "bad request"}, 400)
|
|
488
|
+
self.send_json({"error": str(e) or "bad request"}, 404 if isinstance(e, NotFound) else 400)
|
|
361
489
|
except Exception:
|
|
362
490
|
log.exception("GET %s failed", path)
|
|
363
491
|
self.send_json({"error": "internal error; see data/server.log"}, 500)
|
|
@@ -382,6 +510,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
382
510
|
from .cloud import configured
|
|
383
511
|
o = a.options()
|
|
384
512
|
o["cloud"] = configured() # nav hides "Billed vs local" until a key exists
|
|
513
|
+
from .integrate import guard_state
|
|
514
|
+
try:
|
|
515
|
+
o["guard_installed"] = guard_state() == "installed"
|
|
516
|
+
except Exception:
|
|
517
|
+
o["guard_installed"] = False
|
|
385
518
|
return self.send_json(o)
|
|
386
519
|
if route == "by_agent":
|
|
387
520
|
return self.send_json(a.by_agent(f))
|
|
@@ -457,6 +590,17 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
457
590
|
if not want or any(x != "claude" for x in want) else []
|
|
458
591
|
rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
|
|
459
592
|
return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
|
|
593
|
+
if route.startswith("guard/session/"):
|
|
594
|
+
return self.send_json(_session_limit_status(a, route[len("guard/session/"):]))
|
|
595
|
+
if route == "subagents":
|
|
596
|
+
from .subagents import report
|
|
597
|
+
return self.send_json(report(a, f))
|
|
598
|
+
if route == "jev":
|
|
599
|
+
from . import jev
|
|
600
|
+
return self.send_json({"status": jev.status(), "key": jev.key_status(), "fit": jev.fit(a, f)})
|
|
601
|
+
if route == "keys":
|
|
602
|
+
from .cloud import key_status
|
|
603
|
+
return self.send_json(key_status())
|
|
460
604
|
if route == "cloud":
|
|
461
605
|
from .cloud import report
|
|
462
606
|
return self.send_json(report(a, _int(qs, "days", 30)))
|
|
@@ -469,13 +613,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
469
613
|
pid = int(route.split("/")[1])
|
|
470
614
|
except ValueError:
|
|
471
615
|
raise BadRequest("id must be an integer")
|
|
472
|
-
|
|
616
|
+
d = a.prompt_detail(pid, f)
|
|
617
|
+
return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
|
|
473
618
|
if route.startswith("session/"):
|
|
474
|
-
|
|
619
|
+
d = a.session_detail(route.split("/", 1)[1], f)
|
|
620
|
+
return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
|
|
475
621
|
if route == "bundle":
|
|
476
622
|
return self.send_json({
|
|
477
623
|
"overview": a.overview(f), "burn": a.burn(f), "timeline": a.timeline(f),
|
|
478
|
-
"models": a.models(f), "projects": a.projects(f)
|
|
624
|
+
"models": a.models(f), "projects": a.projects(f),
|
|
479
625
|
"categories": a.categories(f), "efficiency": a.efficiency(f),
|
|
480
626
|
"context": a.context_analysis(f), "waste": a.waste(f),
|
|
481
627
|
"recommendations": a.recommendations(f), "forecast": a.forecast(f),
|
|
@@ -497,8 +643,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
497
643
|
"usage": lambda: a.timeline(f),
|
|
498
644
|
"models": lambda: a.models(f)["rows"],
|
|
499
645
|
"projects": lambda: a.projects(f),
|
|
500
|
-
"costs": lambda: a
|
|
501
|
-
"waste": lambda: [dict(x, evidence=len(x["evidence"]))
|
|
646
|
+
"costs": lambda: _costs_by_day_model(a, f),
|
|
647
|
+
"waste": lambda: [dict(x, evidence=x.get("count", len(x["evidence"])))
|
|
648
|
+
for x in a.waste(f)["findings"]],
|
|
502
649
|
"recommendations": lambda: a.recommendations(f)["recommendations"],
|
|
503
650
|
"forecast": lambda: [dict(name=k, **v) for k, v in
|
|
504
651
|
(a.forecast(f).get("scenarios") or {}).items()],
|
|
@@ -570,6 +717,36 @@ def _sync_job(log):
|
|
|
570
717
|
return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
|
|
571
718
|
|
|
572
719
|
|
|
720
|
+
def _start_sync():
|
|
721
|
+
"""Start a sync unless one is already running; returns the job id."""
|
|
722
|
+
from . import actions as X
|
|
723
|
+
cur = X.JOBS.get(_SYNC["job"] or "")
|
|
724
|
+
if not cur or cur["state"] != "running":
|
|
725
|
+
_SYNC["job"] = X._job(_sync_job)["id"]
|
|
726
|
+
return _SYNC["job"]
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _auto_sync_loop(minutes):
|
|
730
|
+
"""Keep the warehouse fresh without anyone pressing Sync.
|
|
731
|
+
|
|
732
|
+
Every few minutes, if the last build is older than `minutes`, run the same sync the
|
|
733
|
+
Sync button runs. A stale warehouse is what made recent days read as idle and
|
|
734
|
+
projections read low; this keeps "today" honest on a dashboard left open.
|
|
735
|
+
"""
|
|
736
|
+
import time as _t
|
|
737
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
738
|
+
while True:
|
|
739
|
+
try:
|
|
740
|
+
with _lock:
|
|
741
|
+
built = A.meta.get("built_at") if A else None
|
|
742
|
+
age = (_dt.now(_tz.utc) - _dt.fromisoformat(built)).total_seconds() / 60 if built else 1e9
|
|
743
|
+
if age >= minutes:
|
|
744
|
+
_start_sync()
|
|
745
|
+
except Exception:
|
|
746
|
+
log.exception("auto-sync check failed")
|
|
747
|
+
_t.sleep(min(minutes, 15) * 60)
|
|
748
|
+
|
|
749
|
+
|
|
573
750
|
def _notify_update():
|
|
574
751
|
from .update import notify
|
|
575
752
|
notify()
|
|
@@ -611,6 +788,12 @@ def serve(port=8787, db=DB_PATH, background=None):
|
|
|
611
788
|
from .update import disabled
|
|
612
789
|
if not disabled():
|
|
613
790
|
threading.Thread(target=_notify_update, daemon=True).start()
|
|
791
|
+
try:
|
|
792
|
+
auto = float(os.environ.get("CLAUDE_FINOPS_AUTOSYNC_MINUTES", "60"))
|
|
793
|
+
except ValueError:
|
|
794
|
+
auto = 60.0
|
|
795
|
+
if auto > 0:
|
|
796
|
+
threading.Thread(target=_auto_sync_loop, args=(auto,), daemon=True).start()
|
|
614
797
|
# The socket is already listening, so the page's first request just waits for
|
|
615
798
|
# serve_forever. Off the main thread: some platforms block while the browser starts.
|
|
616
799
|
threading.Thread(target=_open_browser, args=(f"http://127.0.0.1:{port}",), daemon=True).start()
|
package/finops/classify.py
CHANGED
|
@@ -13,6 +13,7 @@ CATEGORIES = [
|
|
|
13
13
|
("documentation", 2.3, r"\b(document|documentation|docs?|readme|changelog|comment(s)? (for|on)|docstring|write up|write-up)\b"),
|
|
14
14
|
("architecture", 2.2, r"\b(architect|architecture|design (the|a) (system|schema|api)|system design|data model|schema|scal(e|ing|ability)|trade[- ]?offs?|high level design)\b"),
|
|
15
15
|
("planning", 2.1, r"\b(plan|roadmap|break (this )?down|steps to|approach|strategy|estimate|milestone|backlog|prioriti[sz]e)\b"),
|
|
16
|
+
("version_control", 2.0, r"\b(commit|push|merge|rebase|cherry[- ]?pick|stash|branch|git)\b"),
|
|
16
17
|
("automation", 2.0, r"\b(script|automat|cron|pipeline|ci/?cd|workflow|deploy|jenkins|github action|makefile|bash script)\b"),
|
|
17
18
|
("research", 1.9, r"\b(research|compare|find out|look up|investigate|what (is|are)|explore options|alternatives|pros and cons|benchmark|which (library|tool|framework))\b"),
|
|
18
19
|
("learning", 1.8, r"\b(explain|how does|teach me|understand|what does .* mean|walk me through|help me learn|tutorial|eli5)\b"),
|
|
@@ -42,6 +43,11 @@ def classify(text):
|
|
|
42
43
|
scores[name] = weight * (1 + 0.25 * min(n - 1, 4))
|
|
43
44
|
evidence[name] = sorted({s.lower().strip() for s in flat if s})[:5]
|
|
44
45
|
if not scores:
|
|
46
|
+
# Most unmatched prompts are short steering turns inside a task ("do that",
|
|
47
|
+
# "fix all", "status?", or a reply in another language). They are not
|
|
48
|
+
# unknown work, they continue the turn before; say so rather than "other".
|
|
49
|
+
if len(text.strip()) < 160:
|
|
50
|
+
return "follow_up", 0.5, ["short instruction"]
|
|
45
51
|
return "other", 0.0, []
|
|
46
52
|
best = max(scores, key=scores.get)
|
|
47
53
|
total = sum(scores.values())
|
package/finops/cloud.py
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
Everything else in this dashboard reads local files. This module is the one part that
|
|
4
4
|
talks to the internet, and only when you ask it to (the Refresh button / --cloud-sync).
|
|
5
5
|
|
|
6
|
-
Keys
|
|
7
|
-
|
|
8
|
-
outside the install tree, never packaged)
|
|
6
|
+
Keys come from the environment, or are stored with `claude-finops --set-key` or the
|
|
7
|
+
API keys section on the Budgets page. Both write ~/.claude-finops/secrets.local.json
|
|
8
|
+
(0600, outside the install tree, never packaged); the dashboard never sends a stored
|
|
9
|
+
key back to the browser, only whether it is set and its last four characters:
|
|
9
10
|
|
|
10
11
|
{"anthropic_admin_key": "sk-ant-admin...", "cursor_api_key": "key_..."}
|
|
11
12
|
|
|
@@ -65,6 +66,53 @@ def configured():
|
|
|
65
66
|
return {k: bool(key_for(k)) for k in PROVIDERS}
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
# A light shape check, to catch pasting the wrong thing: not a validity test.
|
|
70
|
+
KEY_SHAPES = {
|
|
71
|
+
"anthropic": (lambda v: v.startswith("sk-ant-admin"),
|
|
72
|
+
"An Anthropic Admin key starts with sk-ant-admin (a regular API key will not work)."),
|
|
73
|
+
"cursor": (lambda v: len(v) >= 16, "That looks too short for a Cursor Admin API key."),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def key_status():
|
|
78
|
+
"""Per provider: where the key comes from and its last four characters. Never the key."""
|
|
79
|
+
stored = _secrets()
|
|
80
|
+
out = {}
|
|
81
|
+
for pid, p in PROVIDERS.items():
|
|
82
|
+
env, val = os.environ.get(p["env"]), stored.get(p["field"])
|
|
83
|
+
src = "env" if env else "stored" if val else None
|
|
84
|
+
key = env or val or ""
|
|
85
|
+
out[pid] = {"name": p["name"], "env": p["env"], "how": p["how"], "covers": p["covers"],
|
|
86
|
+
"source": src, "last4": key[-4:] if len(key) >= 12 else None}
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_key(provider, value):
|
|
91
|
+
"""Store (value) or remove (empty value) one provider key in the 0600 secrets file.
|
|
92
|
+
|
|
93
|
+
Raises ValueError for an unknown provider or a value that fails the shape check.
|
|
94
|
+
"""
|
|
95
|
+
if provider not in PROVIDERS:
|
|
96
|
+
raise ValueError(f"unknown provider {provider!r}")
|
|
97
|
+
value = (value or "").strip()
|
|
98
|
+
if value:
|
|
99
|
+
if any(ch.isspace() for ch in value):
|
|
100
|
+
raise ValueError("A key has no spaces or line breaks in it.")
|
|
101
|
+
ok, msg = KEY_SHAPES.get(provider, (lambda v: True, ""))
|
|
102
|
+
if not ok(value):
|
|
103
|
+
raise ValueError(msg)
|
|
104
|
+
data = _secrets()
|
|
105
|
+
if value:
|
|
106
|
+
data[PROVIDERS[provider]["field"]] = value
|
|
107
|
+
else:
|
|
108
|
+
data.pop(PROVIDERS[provider]["field"], None)
|
|
109
|
+
os.makedirs(os.path.dirname(SECRETS_PATH), exist_ok=True)
|
|
110
|
+
fd = os.open(SECRETS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
111
|
+
with os.fdopen(fd, "w") as fh:
|
|
112
|
+
json.dump(data, fh, indent=2)
|
|
113
|
+
os.chmod(SECRETS_PATH, 0o600) # O_CREAT's mode only applies to a new file
|
|
114
|
+
|
|
115
|
+
|
|
68
116
|
def _get(url, headers, data=None, method="GET"):
|
|
69
117
|
req = urllib.request.Request(url, data=data, method=method,
|
|
70
118
|
headers={"User-Agent": UA, **headers})
|
|
@@ -261,10 +309,13 @@ def report(analytics, days=30):
|
|
|
261
309
|
cu = c.get("cursor_usage") or []
|
|
262
310
|
cs = c.get("cursor_spend") or []
|
|
263
311
|
since = (datetime.now(timezone.utc).date() - timedelta(days=days)).isoformat()
|
|
312
|
+
# The vendor APIs report UTC days, so this page compares on UTC days too (every
|
|
313
|
+
# other page uses your local calendar days); otherwise a late-evening session would
|
|
314
|
+
# sit on different dates on the two sides.
|
|
264
315
|
local = {r["agent"]: r for r in analytics.q(
|
|
265
316
|
"SELECT agent, SUM(est_cost_usd) cost, SUM(billable_tokens) tokens,"
|
|
266
317
|
" COUNT(DISTINCT session_id) sessions, COUNT(*) requests"
|
|
267
|
-
" FROM requests WHERE
|
|
318
|
+
" FROM requests WHERE substr(ts,1,10) >= ? GROUP BY agent", (since,))}
|
|
268
319
|
|
|
269
320
|
by_day = {}
|
|
270
321
|
for r in cc:
|
|
@@ -273,9 +324,9 @@ def report(analytics, days=30):
|
|
|
273
324
|
d["billed_cost"] += r["est_cost_usd"]
|
|
274
325
|
d["billed_tokens"] += r["tokens"]
|
|
275
326
|
d["billed_sessions"] += r["sessions"]
|
|
276
|
-
for r in analytics.q("SELECT day, SUM(est_cost_usd) c, SUM(billable_tokens) t,"
|
|
327
|
+
for r in analytics.q("SELECT substr(ts,1,10) day, SUM(est_cost_usd) c, SUM(billable_tokens) t,"
|
|
277
328
|
" COUNT(DISTINCT session_id) s FROM requests"
|
|
278
|
-
" WHERE agent='claude' AND
|
|
329
|
+
" WHERE agent='claude' AND substr(ts,1,10) >= ? GROUP BY 1", (since,)):
|
|
279
330
|
d = by_day.setdefault(r["day"], {"day": r["day"], "billed_cost": 0.0, "billed_tokens": 0,
|
|
280
331
|
"billed_sessions": 0})
|
|
281
332
|
d.update(local_cost=r["c"] or 0.0, local_tokens=r["t"] or 0, local_sessions=r["s"] or 0)
|
|
@@ -331,6 +382,7 @@ def report(analytics, days=30):
|
|
|
331
382
|
for r in cost}.items()], key=lambda x: -x["cost_usd"])[:20],
|
|
332
383
|
"note": "Billed figures come from the vendor APIs (org-wide, every machine and member). "
|
|
333
384
|
"Local figures are what this machine's transcripts recorded. A gap usually means "
|
|
334
|
-
"other machines, other members, or work outside this machine."
|
|
385
|
+
"other machines, other members, or work outside this machine. Days on this "
|
|
386
|
+
"page are UTC days, as the vendors report them.",
|
|
335
387
|
"basis": "billed",
|
|
336
388
|
}
|
package/finops/diagnose.py
CHANGED
|
@@ -121,9 +121,13 @@ class Diagnoser:
|
|
|
121
121
|
"basis": "actual tokens, estimated cost"})
|
|
122
122
|
|
|
123
123
|
# 4. tool output flooding context
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
# Tokens per tool count each request once: a request that made three Bash calls
|
|
125
|
+
# was still one request, and joining calls to requests counted it three times.
|
|
126
|
+
tools = a.q(f"""SELECT x.name, SUM(x.calls) calls, SUM(r.billable_tokens) b
|
|
127
|
+
FROM (SELECT tc.request_pk, tc.name, COUNT(*) calls FROM tool_calls tc
|
|
128
|
+
GROUP BY tc.request_pk, tc.name) x
|
|
129
|
+
JOIN requests r ON r.id=x.request_pk WHERE {w}
|
|
130
|
+
GROUP BY x.name ORDER BY b DESC LIMIT 6""", p)
|
|
127
131
|
if tools:
|
|
128
132
|
top = tools[0]
|
|
129
133
|
out.append({
|
|
@@ -585,8 +589,17 @@ class Diagnoser:
|
|
|
585
589
|
MAX(tc.result_chars)/{CHARS_PER_TOKEN} largest
|
|
586
590
|
FROM tool_calls tc WHERE tc.kind IN ({ph}) AND {tcw}
|
|
587
591
|
GROUP BY tc.kind, tc.server ORDER BY carried DESC""", kinds + p)
|
|
592
|
+
# Re-reads are priced at the cache-read rate of the model that made each call,
|
|
593
|
+
# not one main-model rate for everything.
|
|
594
|
+
cost_by = defaultdict(float)
|
|
595
|
+
for x in a.q(f"""SELECT tc.kind, tc.server, rq.model,
|
|
596
|
+
SUM(tc.result_chars*1.0*tc.carry_requests)/{CHARS_PER_TOKEN} carried
|
|
597
|
+
FROM tool_calls tc JOIN requests rq ON rq.id=tc.request_pk
|
|
598
|
+
WHERE tc.kind IN ({ph}) AND {tcw} GROUP BY 1, 2, 3""", kinds + p):
|
|
599
|
+
r_ = (a.pricing.rates(x["model"]).get("cache_read") or 0) / 1e6
|
|
600
|
+
cost_by[(x["kind"], x["server"])] += (x["carried"] or 0) * r_
|
|
588
601
|
for r in rows:
|
|
589
|
-
r["carried_cost"] = (r["carried"] or 0) * rate
|
|
602
|
+
r["carried_cost"] = cost_by.get((r["kind"], r["server"]), (r["carried"] or 0) * rate)
|
|
590
603
|
r["tools"] = a.q(f"""SELECT tc.name, COUNT(*) calls,
|
|
591
604
|
SUM(tc.result_chars)/{CHARS_PER_TOKEN} injected,
|
|
592
605
|
SUM(tc.result_chars*1.0*tc.carry_requests)/{CHARS_PER_TOKEN} carried
|
|
@@ -622,7 +635,7 @@ class Diagnoser:
|
|
|
622
635
|
"note": "Injected = size of what came back into context (actual size, ~4 chars/token). "
|
|
623
636
|
"Carried = injected × later requests in the same session that re-read it — an "
|
|
624
637
|
"upper bound, since /compact isn't visible. Carried cost prices those re-reads at "
|
|
625
|
-
"
|
|
638
|
+
"the cache-read rate of the model that made each call (estimated). Subagent tokens are actual. "
|
|
626
639
|
"Slash-command figures attribute the whole turn they started.",
|
|
627
640
|
}
|
|
628
641
|
|