claude-finops 0.16.0 → 0.17.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 +3 -21
- package/config/pricing.json +1 -141
- package/finops/actions.py +11 -18
- package/finops/analytics.py +31 -62
- package/finops/api.py +9 -19
- package/finops/cloud.py +10 -107
- package/finops/diagnose.py +12 -85
- package/finops/etl.py +6 -10
- package/finops/procs.py +1 -189
- package/finops/segments.py +2 -2
- package/package.json +2 -5
- package/web/app.js +170 -223
- package/finops/agents.py +0 -271
package/finops/cloud.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Billed numbers from
|
|
1
|
+
"""Billed numbers from Anthropic's Admin API, next to what this machine recorded.
|
|
2
2
|
|
|
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).
|
|
@@ -8,18 +8,15 @@ API keys section on the Budgets page. Both write ~/.claude-finops/secrets.local.
|
|
|
8
8
|
(0600, outside the install tree, never packaged); the dashboard never sends a stored
|
|
9
9
|
key back to the browser, only whether it is set and its last four characters:
|
|
10
10
|
|
|
11
|
-
{"anthropic_admin_key": "sk-ant-admin..."
|
|
11
|
+
{"anthropic_admin_key": "sk-ant-admin..."}
|
|
12
12
|
|
|
13
13
|
Anthropic Admin API key from Console → Settings → Admin keys. Org accounts only;
|
|
14
14
|
individual accounts have no Admin API.
|
|
15
15
|
/v1/organizations/usage_report/claude_code per user per day, incl. Pro/Max
|
|
16
16
|
/v1/organizations/cost_report billed USD (API spend only)
|
|
17
|
-
Cursor Team/Org admin key from Cursor dashboard → Settings → Admin API.
|
|
18
|
-
POST /teams/daily-usage-data, /teams/spend, GET /teams/members
|
|
19
17
|
|
|
20
18
|
Responses are cached in ~/.claude-finops/data/cloud_cache.json so the dashboard never calls out on its own.
|
|
21
19
|
"""
|
|
22
|
-
import base64
|
|
23
20
|
import json
|
|
24
21
|
import os
|
|
25
22
|
import time
|
|
@@ -40,12 +37,6 @@ PROVIDERS = {
|
|
|
40
37
|
"covers": "Per-user Claude Code sessions, tokens and estimated cost (including Pro/Max "
|
|
41
38
|
"subscription users), plus billed API cost for the org.",
|
|
42
39
|
},
|
|
43
|
-
"cursor": {
|
|
44
|
-
"name": "Cursor", "agent": "cursor", "env": "CURSOR_API_KEY", "field": "cursor_api_key",
|
|
45
|
-
"how": "Cursor dashboard → Settings → Admin API → create a key. Team/Business plans only; "
|
|
46
|
-
"an individual Pro account has no admin API.",
|
|
47
|
-
"covers": "Per-member daily activity and billed spend for the team.",
|
|
48
|
-
},
|
|
49
40
|
}
|
|
50
41
|
|
|
51
42
|
|
|
@@ -70,7 +61,6 @@ def configured():
|
|
|
70
61
|
KEY_SHAPES = {
|
|
71
62
|
"anthropic": (lambda v: v.startswith("sk-ant-admin"),
|
|
72
63
|
"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
64
|
}
|
|
75
65
|
|
|
76
66
|
|
|
@@ -198,63 +188,6 @@ def anthropic_cost(key, days=30):
|
|
|
198
188
|
return out
|
|
199
189
|
|
|
200
190
|
|
|
201
|
-
# --------------------------------------------------------------------- Cursor ----
|
|
202
|
-
def _cursor_headers(key):
|
|
203
|
-
tok = base64.b64encode(f"{key}:".encode()).decode() # basic auth, key as username
|
|
204
|
-
return {"Authorization": f"Basic {tok}", "Content-Type": "application/json"}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def _cursor_post(key, path, body):
|
|
208
|
-
try:
|
|
209
|
-
return _get(f"https://api.cursor.com{path}", _cursor_headers(key),
|
|
210
|
-
data=json.dumps(body).encode(), method="POST")
|
|
211
|
-
except RuntimeError as e:
|
|
212
|
-
if "401" in str(e): # the key is fine; the account just isn't a team
|
|
213
|
-
raise RuntimeError(
|
|
214
|
-
"Cursor rejected the key for team endpoints (401 Invalid Team API Key). The key "
|
|
215
|
-
"itself is valid, but /teams/* answers only for Team/Enterprise accounts — an "
|
|
216
|
-
"individual account has no team data to report. Cursor stays local-only.") from None
|
|
217
|
-
raise
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def cursor_usage(key, days=30):
|
|
221
|
-
"""Daily per-member activity. The API allows at most 30 days per call."""
|
|
222
|
-
end = int(time.time() * 1000)
|
|
223
|
-
start = end - min(days, 30) * 86400000
|
|
224
|
-
out, page = [], 1
|
|
225
|
-
while True:
|
|
226
|
-
d = _cursor_post(key, "/teams/daily-usage-data",
|
|
227
|
-
{"startDate": start, "endDate": end, "page": page, "pageSize": 1000})
|
|
228
|
-
rows = d.get("data") or []
|
|
229
|
-
for r in rows:
|
|
230
|
-
ts = r.get("date")
|
|
231
|
-
day = datetime.fromtimestamp(int(ts) / 1000, timezone.utc).date().isoformat() \
|
|
232
|
-
if ts else None
|
|
233
|
-
out.append({"day": day, "member": r.get("email") or r.get("userId") or "unknown",
|
|
234
|
-
"is_active": r.get("isActive"),
|
|
235
|
-
"lines_added": r.get("totalLinesAdded") or 0,
|
|
236
|
-
"lines_removed": r.get("totalLinesDeleted") or 0,
|
|
237
|
-
"accepted": r.get("acceptedLinesAdded") or 0,
|
|
238
|
-
"requests": r.get("composerRequests") or r.get("totalApplies") or 0,
|
|
239
|
-
"model": r.get("mostUsedModel")})
|
|
240
|
-
if len(rows) < 1000 or page > 20:
|
|
241
|
-
break
|
|
242
|
-
page += 1
|
|
243
|
-
return out
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def cursor_spend(key):
|
|
247
|
-
d = _cursor_post(key, "/teams/spend", {"page": 1, "pageSize": 500})
|
|
248
|
-
out = []
|
|
249
|
-
for r in d.get("teamMemberSpend") or d.get("data") or []:
|
|
250
|
-
cents = r.get("spendCents")
|
|
251
|
-
out.append({"member": r.get("email") or r.get("name") or "unknown",
|
|
252
|
-
"spend_usd": (float(cents) / 100.0) if cents is not None else None,
|
|
253
|
-
"requests": r.get("fastPremiumRequests") or r.get("requests"),
|
|
254
|
-
"role": r.get("role")})
|
|
255
|
-
return out
|
|
256
|
-
|
|
257
|
-
|
|
258
191
|
# ---------------------------------------------------------------------- cache ----
|
|
259
192
|
def load_cache():
|
|
260
193
|
try:
|
|
@@ -281,18 +214,6 @@ def sync(days=30, log=print):
|
|
|
281
214
|
cache["anthropic_cost"] = anthropic_cost(k, days)
|
|
282
215
|
except RuntimeError as e:
|
|
283
216
|
errors["anthropic_cost"] = str(e)
|
|
284
|
-
k = key_for("cursor")
|
|
285
|
-
if k:
|
|
286
|
-
log("Cursor: daily usage…")
|
|
287
|
-
try:
|
|
288
|
-
cache["cursor_usage"] = cursor_usage(k, days)
|
|
289
|
-
except RuntimeError as e:
|
|
290
|
-
errors["cursor_usage"] = str(e)
|
|
291
|
-
log("Cursor: spend…")
|
|
292
|
-
try:
|
|
293
|
-
cache["cursor_spend"] = cursor_spend(k)
|
|
294
|
-
except RuntimeError as e:
|
|
295
|
-
errors["cursor_spend"] = str(e)
|
|
296
217
|
cache["errors"] = errors
|
|
297
218
|
cache["fetched_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
298
219
|
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
|
@@ -302,20 +223,18 @@ def sync(days=30, log=print):
|
|
|
302
223
|
|
|
303
224
|
|
|
304
225
|
def report(analytics, days=30):
|
|
305
|
-
"""Billed (
|
|
226
|
+
"""Billed (Anthropic API) next to local (this machine), per day and per user."""
|
|
306
227
|
c = load_cache()
|
|
307
228
|
cc = c.get("anthropic_claude_code") or []
|
|
308
229
|
cost = c.get("anthropic_cost") or []
|
|
309
|
-
cu = c.get("cursor_usage") or []
|
|
310
|
-
cs = c.get("cursor_spend") or []
|
|
311
230
|
since = (datetime.now(timezone.utc).date() - timedelta(days=days)).isoformat()
|
|
312
|
-
# The
|
|
231
|
+
# The Anthropic API reports UTC days, so this page compares on UTC days too (every
|
|
313
232
|
# other page uses your local calendar days); otherwise a late-evening session would
|
|
314
233
|
# sit on different dates on the two sides.
|
|
315
|
-
|
|
316
|
-
"SELECT
|
|
234
|
+
claude_local = analytics.one(
|
|
235
|
+
"SELECT SUM(est_cost_usd) cost, SUM(billable_tokens) tokens,"
|
|
317
236
|
" COUNT(DISTINCT session_id) sessions, COUNT(*) requests"
|
|
318
|
-
" FROM requests WHERE substr(ts,1,10) >= ?
|
|
237
|
+
" FROM requests WHERE agent='claude' AND substr(ts,1,10) >= ?", (since,))
|
|
319
238
|
|
|
320
239
|
by_day = {}
|
|
321
240
|
for r in cc:
|
|
@@ -346,19 +265,6 @@ def report(analytics, days=30):
|
|
|
346
265
|
for u in users.values():
|
|
347
266
|
u["terminals"] = sorted(u["terminals"])
|
|
348
267
|
|
|
349
|
-
cur = {}
|
|
350
|
-
for r in cu:
|
|
351
|
-
m = cur.setdefault(r["member"], {"member": r["member"], "days": 0, "lines_added": 0,
|
|
352
|
-
"lines_removed": 0, "accepted": 0, "requests": 0})
|
|
353
|
-
m["days"] += 1 if r.get("is_active") is not False else 0
|
|
354
|
-
for k2 in ("lines_added", "lines_removed", "accepted", "requests"):
|
|
355
|
-
m[k2] += r.get(k2) or 0
|
|
356
|
-
spend = {s["member"]: s for s in cs}
|
|
357
|
-
for m in cur.values():
|
|
358
|
-
m["spend_usd"] = (spend.get(m["member"]) or {}).get("spend_usd")
|
|
359
|
-
|
|
360
|
-
claude_local = local.get("claude") or {}
|
|
361
|
-
cursor_local = local.get("cursor") or {}
|
|
362
268
|
return {
|
|
363
269
|
"configured": configured(), "providers": PROVIDERS,
|
|
364
270
|
"fetched_at": c.get("fetched_at"), "errors": c.get("errors") or {},
|
|
@@ -369,20 +275,17 @@ def report(analytics, days=30):
|
|
|
369
275
|
"local_claude_cost": claude_local.get("cost") or 0.0,
|
|
370
276
|
"local_claude_tokens": claude_local.get("tokens") or 0,
|
|
371
277
|
"billed_api_cost": sum(r["cost_usd"] for r in cost),
|
|
372
|
-
"
|
|
373
|
-
"local_cursor_requests": cursor_local.get("requests") or 0,
|
|
374
|
-
"org_users": len(users), "cursor_members": len(cur),
|
|
278
|
+
"org_users": len(users),
|
|
375
279
|
},
|
|
376
280
|
"by_day": sorted(by_day.values(), key=lambda x: x["day"]),
|
|
377
281
|
"users": sorted(users.values(), key=lambda x: -x["cost"]),
|
|
378
|
-
"cursor_members": sorted(cur.values(), key=lambda x: -(x["spend_usd"] or 0)),
|
|
379
282
|
"api_cost_by_description": sorted(
|
|
380
283
|
[{"description": k2, "cost_usd": v} for k2, v in
|
|
381
284
|
{r["description"]: sum(x["cost_usd"] for x in cost if x["description"] == r["description"])
|
|
382
285
|
for r in cost}.items()], key=lambda x: -x["cost_usd"])[:20],
|
|
383
|
-
"note": "Billed figures come from the
|
|
286
|
+
"note": "Billed figures come from the Anthropic Admin API (org-wide, every machine and member). "
|
|
384
287
|
"Local figures are what this machine's transcripts recorded. A gap usually means "
|
|
385
288
|
"other machines, other members, or work outside this machine. Days on this "
|
|
386
|
-
"page are UTC days, as
|
|
289
|
+
"page are UTC days, as Anthropic reports them.",
|
|
387
290
|
"basis": "billed",
|
|
388
291
|
}
|
package/finops/diagnose.py
CHANGED
|
@@ -19,7 +19,7 @@ CHARS_PER_TOKEN = 4 # rough, stated wherever it is used
|
|
|
19
19
|
CLAUDE_MD_WARN_TOKENS = 2500
|
|
20
20
|
MEMORY_WARN_TOKENS = 1500
|
|
21
21
|
EXPLORE_BASH = re.compile(r"^\s*(cd [^;&]+[;&]+\s*)?(ls|find|grep|rg|cat|head|tail|sed -n|tree|wc)\b")
|
|
22
|
-
#
|
|
22
|
+
# The names advice uses for Claude Code's files and commands.
|
|
23
23
|
VOCAB = {
|
|
24
24
|
"claude": {"name": "Claude Code", "md": "CLAUDE.md", "md_paths": ("CLAUDE.md", ".claude/CLAUDE.md"),
|
|
25
25
|
"clear": "/clear", "compact": "/compact", "shell": ("Bash",),
|
|
@@ -28,18 +28,6 @@ VOCAB = {
|
|
|
28
28
|
"\"model\" in ~/.claude/settings.json). Give subagents a cheaper model in "
|
|
29
29
|
"their agent definition.",
|
|
30
30
|
"cheaper": "Default to Sonnet; switch to Opus only for hard problems"},
|
|
31
|
-
"codex": {"name": "Codex", "md": "AGENTS.md", "md_paths": ("AGENTS.md",),
|
|
32
|
-
"clear": "/new", "compact": "/compact", "shell": ("exec_command", "shell", "local_shell"),
|
|
33
|
-
"explore": (),
|
|
34
|
-
"model_how": "Use /model to pick a smaller GPT-5 model (or lower reasoning effort) for "
|
|
35
|
-
"routine edits, tests and docs; set model = \"...\" in ~/.codex/config.toml.",
|
|
36
|
-
"cheaper": "Use a cheaper GPT model for routine work"},
|
|
37
|
-
"gemini": {"name": "Gemini CLI", "md": "GEMINI.md", "md_paths": ("GEMINI.md", ".gemini/GEMINI.md"),
|
|
38
|
-
"clear": "/clear", "compact": "/compress", "shell": ("run_shell_command",),
|
|
39
|
-
"explore": ("read_file", "read_many_files", "glob", "search_file_content", "list_directory"),
|
|
40
|
-
"model_how": "Run gemini -m gemini-2.5-flash (or /model) for routine edits, tests "
|
|
41
|
-
"and docs; set \"model\" in ~/.gemini/settings.json.",
|
|
42
|
-
"cheaper": "Default to Flash; switch to Pro only for hard problems"},
|
|
43
31
|
}
|
|
44
32
|
NOISY_PATH = re.compile(r"(node_modules/|/dist/|/build/|/\.next/|/coverage/|package-lock\.json|"
|
|
45
33
|
r"yarn\.lock|pnpm-lock\.yaml|\.min\.js|\.map$|/data/.*\.(json|csv)$)")
|
|
@@ -300,40 +288,6 @@ class Diagnoser:
|
|
|
300
288
|
"custom_agents": sorted(os.listdir(agents)) if os.path.isdir(agents) else [],
|
|
301
289
|
"default_model": st.get("model"), "issues": issues}
|
|
302
290
|
|
|
303
|
-
def agent_global_config(self):
|
|
304
|
-
"""Codex / Gemini equivalent of global_config(): instructions file, default model, MCP."""
|
|
305
|
-
v, issues, files, model, mcp = self.v, [], [], None, []
|
|
306
|
-
base = os.path.join(HOME, ".codex" if self.agent == "codex" else ".gemini")
|
|
307
|
-
for rel in (v["md"], "config.toml", "settings.json"):
|
|
308
|
-
p = os.path.join(base, rel)
|
|
309
|
-
if os.path.exists(p):
|
|
310
|
-
files.append({"path": p, "tokens": _tok(p)})
|
|
311
|
-
if self.agent == "codex":
|
|
312
|
-
try:
|
|
313
|
-
txt = open(os.path.join(base, "config.toml"), errors="replace").read()
|
|
314
|
-
except OSError:
|
|
315
|
-
txt = ""
|
|
316
|
-
m = re.search(r'^\s*model\s*=\s*"([^"]+)"', txt, re.M)
|
|
317
|
-
model = m.group(1) if m else None
|
|
318
|
-
mcp = re.findall(r'^\s*\[mcp_servers\.([^\]]+)\]', txt, re.M)
|
|
319
|
-
where = "model = \"...\" in ~/.codex/config.toml"
|
|
320
|
-
else:
|
|
321
|
-
st = _load_json(os.path.join(base, "settings.json"))
|
|
322
|
-
model = (st.get("model") or {}).get("name") if isinstance(st.get("model"), dict) else st.get("model")
|
|
323
|
-
mcp = list((st.get("mcpServers") or {}).keys())
|
|
324
|
-
where = "\"model\": {\"name\": \"...\"} in ~/.gemini/settings.json"
|
|
325
|
-
g = next((x for x in files if x["path"].endswith(v["md"])), None)
|
|
326
|
-
if g and g["tokens"] and g["tokens"] > CLAUDE_MD_WARN_TOKENS:
|
|
327
|
-
issues.append({"severity": "medium", "title": f"Global {v['md']} is large",
|
|
328
|
-
"fix": f"~{g['tokens']:,} tokens load into every session in every "
|
|
329
|
-
f"project. Move project-specific parts into that project."})
|
|
330
|
-
if not model:
|
|
331
|
-
issues.append({"severity": "low", "title": "No default model set",
|
|
332
|
-
"fix": f"Set a cheaper everyday default with {where}; switch up with "
|
|
333
|
-
f"/model when a task needs it."})
|
|
334
|
-
return {"files": files, "mcp_servers": mcp, "custom_agents": [],
|
|
335
|
-
"default_model": model, "issues": issues}
|
|
336
|
-
|
|
337
291
|
def projects(self, f):
|
|
338
292
|
a = self.a
|
|
339
293
|
w, p = a.where(f)
|
|
@@ -352,7 +306,6 @@ class Diagnoser:
|
|
|
352
306
|
ph = ",".join("?" * len(pids))
|
|
353
307
|
exists = bool(path) and os.path.isdir(path)
|
|
354
308
|
v = getattr(self, "v", VOCAB["claude"])
|
|
355
|
-
claude = v is VOCAB["claude"]
|
|
356
309
|
cm = [os.path.join(path, x) for x in v["md_paths"]] if path else []
|
|
357
310
|
cm_found = [x for x in cm if os.path.exists(x)]
|
|
358
311
|
cm_tok = sum(_tok(x) or 0 for x in cm_found)
|
|
@@ -415,18 +368,15 @@ class Diagnoser:
|
|
|
415
368
|
"\"Read(./dist/**)\", \"Read(./**/*.lock)\"]}. Seen: "
|
|
416
369
|
+ ", ".join(os.path.basename(x["path"]) for x in noisy[:3]),
|
|
417
370
|
"evidence": noisy})
|
|
418
|
-
unused = [m for m in mcp_per.get(path, []) if m not in mcp_used]
|
|
371
|
+
unused = [m for m in mcp_per.get(path, []) if m not in mcp_used]
|
|
419
372
|
if unused:
|
|
420
373
|
issues.append({"severity": "low", "title": "Unused MCP servers enabled",
|
|
421
374
|
"fix": f"{', '.join(unused)} never called here but their tool "
|
|
422
375
|
f"definitions load every session. Remove with "
|
|
423
376
|
f"`claude mcp remove <name>`."})
|
|
424
|
-
if
|
|
377
|
+
if exists and cm_found:
|
|
425
378
|
issues += self.claude_md_quality(path, cm_found)
|
|
426
|
-
|
|
427
|
-
issues += self.memory_quality(pj["slug"], heavy)
|
|
428
|
-
else: # Claude-only files: don't flag them
|
|
429
|
-
issues = [i for i in issues if not i["title"].startswith(("MEMORY.md", "Generated/large"))]
|
|
379
|
+
issues += self.memory_quality(pj["slug"], heavy)
|
|
430
380
|
if not exists:
|
|
431
381
|
issues = []
|
|
432
382
|
out.append({
|
|
@@ -440,8 +390,7 @@ class Diagnoser:
|
|
|
440
390
|
"deny_rules": len(((s_json or {}).get("permissions") or {}).get("deny") or [])},
|
|
441
391
|
"mcp_servers": mcp_per.get(path, []),
|
|
442
392
|
"issues": issues,
|
|
443
|
-
"harness": self.harness(pj, path, exists, cm_found, s_json, explore_pct, pids, ph)
|
|
444
|
-
if claude else None,
|
|
393
|
+
"harness": self.harness(pj, path, exists, cm_found, s_json, explore_pct, pids, ph),
|
|
445
394
|
})
|
|
446
395
|
return out
|
|
447
396
|
|
|
@@ -629,8 +578,7 @@ class Diagnoser:
|
|
|
629
578
|
"skills": skills, "slash_commands": slash,
|
|
630
579
|
"mcp": [r for r in mcp if r["kind"] == "mcp"],
|
|
631
580
|
"connectors": [r for r in mcp if r["kind"] == "connector"],
|
|
632
|
-
"configured_unused_mcp": sorted(configured - used)
|
|
633
|
-
if "claude" in ((f or {}).get("agents") or ["claude"]) else [],
|
|
581
|
+
"configured_unused_mcp": sorted(configured - used),
|
|
634
582
|
"cache_read_rate_per_mtok": rate * 1e6,
|
|
635
583
|
"note": "Injected = size of what came back into context (actual size, ~4 chars/token). "
|
|
636
584
|
"Carried = injected × later requests in the same session that re-read it — an "
|
|
@@ -971,46 +919,25 @@ class Diagnoser:
|
|
|
971
919
|
return out[:40]
|
|
972
920
|
|
|
973
921
|
# ------------------------------------------------------------------ entry point
|
|
974
|
-
def _agent(self, f):
|
|
975
|
-
"""The agent the advice is written for: the one that spent most tokens in range."""
|
|
976
|
-
w, p = self.a.where(f)
|
|
977
|
-
r = self.a.one(f"SELECT r.agent a FROM requests r WHERE {w} AND r.agent IN "
|
|
978
|
-
f"('claude','codex','gemini') GROUP BY r.agent "
|
|
979
|
-
f"ORDER BY SUM(r.billable_tokens) DESC LIMIT 1", p)
|
|
980
|
-
return r.get("a") or "claude"
|
|
981
|
-
|
|
982
922
|
def run(self, f=None):
|
|
983
923
|
f = f or {}
|
|
984
|
-
self.
|
|
985
|
-
self.v = VOCAB[self.agent]
|
|
924
|
+
self.v = VOCAB["claude"]
|
|
986
925
|
drv = self.drivers(f)
|
|
987
926
|
tr = self.trend(f)
|
|
988
927
|
pj = self.projects(f)
|
|
989
|
-
gl = self.global_config()
|
|
928
|
+
gl = self.global_config()
|
|
990
929
|
recs = self.recommendations(f, drv, pj)
|
|
991
930
|
top = drv["drivers"][:3]
|
|
992
931
|
headline = ("Your tokens are high mainly because of: "
|
|
993
932
|
+ "; ".join(f"{d['title'].lower()} ({d['share_pct']}%)" for d in top)
|
|
994
933
|
+ ".") if top else "No usage in the selected range."
|
|
995
934
|
from .playbook import attach
|
|
996
|
-
wants = f.get("agents") or ["claude"]
|
|
997
935
|
res = attach({"headline": headline, "trend": tr, **drv, "recommendations": recs,
|
|
998
|
-
"live_sessions": self.live_sessions()
|
|
936
|
+
"live_sessions": self.live_sessions(), "session_health": self.session_health(f),
|
|
999
937
|
"memory_suggestions": self.memory_suggestions(f),
|
|
1000
938
|
"projects": pj, "global": gl,
|
|
1001
939
|
"note": "Token counts are actual. Dollar figures are estimated. Config checks "
|
|
1002
940
|
f"read files on disk now; token sizes use ~{CHARS_PER_TOKEN} chars/token.",
|
|
1003
|
-
"basis": "mixed", "
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
def _swap_md(obj, md):
|
|
1009
|
-
"""Advice text for a non-Claude agent: point at its instructions file, not CLAUDE.md."""
|
|
1010
|
-
if isinstance(obj, str):
|
|
1011
|
-
return obj.replace("CLAUDE.md", md).replace("Claude Code", "the agent")
|
|
1012
|
-
if isinstance(obj, list):
|
|
1013
|
-
return [_swap_md(x, md) for x in obj]
|
|
1014
|
-
if isinstance(obj, dict):
|
|
1015
|
-
return {k: (x if k in ("path", "paths", "session_id") else _swap_md(x, md)) for k, x in obj.items()}
|
|
1016
|
-
return obj
|
|
941
|
+
"basis": "mixed", "vocab": {k: v for k, v in self.v.items() if isinstance(v, str)}})
|
|
942
|
+
return res
|
|
943
|
+
|
package/finops/etl.py
CHANGED
|
@@ -110,7 +110,7 @@ CREATE TABLE requests (
|
|
|
110
110
|
tool_call_count INTEGER DEFAULT 0,
|
|
111
111
|
is_sidechain INTEGER DEFAULT 0,
|
|
112
112
|
agent_id TEXT, agent_type TEXT, agent_desc TEXT, -- set for subagent requests
|
|
113
|
-
agent TEXT DEFAULT 'claude'
|
|
113
|
+
agent TEXT DEFAULT 'claude'
|
|
114
114
|
);
|
|
115
115
|
|
|
116
116
|
CREATE TABLE tool_calls (
|
|
@@ -218,9 +218,8 @@ def keep_deleted_history():
|
|
|
218
218
|
|
|
219
219
|
|
|
220
220
|
class Loader:
|
|
221
|
-
def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None,
|
|
221
|
+
def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None,
|
|
222
222
|
desktop_roots=None, keep_from=None):
|
|
223
|
-
self.other_agents = other_agents
|
|
224
223
|
# An explicit source means "load exactly this"; only the default also picks up
|
|
225
224
|
# the desktop app's Cowork sessions (same JSONL format, different config dir).
|
|
226
225
|
self.desktop_roots = (desktop_roots if desktop_roots is not None
|
|
@@ -294,12 +293,6 @@ class Loader:
|
|
|
294
293
|
except Exception as exc: # a corrupt transcript must not kill the load
|
|
295
294
|
print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
|
|
296
295
|
self.cowork = False
|
|
297
|
-
if self.other_agents:
|
|
298
|
-
from .agents import AgentLoader
|
|
299
|
-
counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
|
|
300
|
-
if verbose and counts:
|
|
301
|
-
print(" other agents: " + ", ".join(f"{k} {v:,} requests" for k, v in counts.items()),
|
|
302
|
-
file=sys.stderr)
|
|
303
296
|
carried = 0
|
|
304
297
|
if prev and keep_deleted_history() and os.path.exists(prev):
|
|
305
298
|
self.db.commit() # the fresh read is safe whatever happens next
|
|
@@ -647,8 +640,11 @@ class Loader:
|
|
|
647
640
|
d.row_factory = sqlite3.Row
|
|
648
641
|
cols = lambda db, t: [r[1] for r in d.execute(f"PRAGMA {db}.table_info({t})")]
|
|
649
642
|
common = lambda t: [c for c in cols("main", t) if c in set(cols("old", t)) and c != "id"]
|
|
643
|
+
# Only Claude Code sessions: warehouses from before 0.17 also held Codex,
|
|
644
|
+
# Gemini CLI and Cursor rows, which are no longer read.
|
|
645
|
+
claude_only = " AND COALESCE(agent, 'claude') = 'claude'" if "agent" in cols("old", "sessions") else ""
|
|
650
646
|
gone = [r for r in d.execute(
|
|
651
|
-
"SELECT * FROM old.sessions WHERE id NOT IN (SELECT id FROM main.sessions)")
|
|
647
|
+
"SELECT * FROM old.sessions WHERE id NOT IN (SELECT id FROM main.sessions)" + claude_only)
|
|
652
648
|
if r["source_file"] and not os.path.exists(r["source_file"])]
|
|
653
649
|
if not gone:
|
|
654
650
|
return 0
|
package/finops/procs.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Running
|
|
1
|
+
"""Running Claude Code sessions and the actions the dashboard can take on them.
|
|
2
2
|
|
|
3
3
|
Source of truth is ~/.claude/sessions/<pid>.json (written by Claude Code), checked
|
|
4
4
|
against the live process table. A signal is only ever sent to a PID that, at the
|
|
@@ -191,194 +191,6 @@ def act(pid, action):
|
|
|
191
191
|
return {"ok": True, "message": msg}
|
|
192
192
|
|
|
193
193
|
|
|
194
|
-
# ------------------------------------------------------------ other agents ----
|
|
195
|
-
# Codex, Gemini CLI and Cursor keep no pid registry, so a session counts as running
|
|
196
|
-
# when its transcript was written recently. A CLI session is tied to a process by
|
|
197
|
-
# working directory; Cursor sessions live inside the IDE and are never signalled.
|
|
198
|
-
LIVE_WINDOW_S = 20 * 60
|
|
199
|
-
BUSY_WINDOW_S = 60
|
|
200
|
-
AGENT_BINS = {"codex": ("codex",), "gemini": ("gemini",)}
|
|
201
|
-
RESUME = {"codex": "codex resume {sid}", "gemini": "gemini --resume"}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def _is_agent(p, agent):
|
|
205
|
-
if agent == "claude":
|
|
206
|
-
return _is_claude(p)
|
|
207
|
-
words = [os.path.basename(w.strip('"').replace("\\", "/")).lower()
|
|
208
|
-
for w in (p["command"] or "").split()[:3]]
|
|
209
|
-
return any(w in AGENT_BINS.get(agent, ()) or w in tuple(b + ".exe" for b in AGENT_BINS.get(agent, ()))
|
|
210
|
-
for w in words)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def _cwd_of(pid):
|
|
214
|
-
if os.name == "nt":
|
|
215
|
-
return None
|
|
216
|
-
try:
|
|
217
|
-
out = subprocess.run(["lsof", "-a", "-d", "cwd", "-p", str(pid), "-Fn"],
|
|
218
|
-
capture_output=True, text=True, timeout=5).stdout
|
|
219
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
220
|
-
return None
|
|
221
|
-
return next((l[1:] for l in out.splitlines() if l.startswith("n")), None)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def _recent(pattern, now):
|
|
225
|
-
out = []
|
|
226
|
-
for fp in glob.glob(pattern, recursive=True):
|
|
227
|
-
try:
|
|
228
|
-
age = now - os.path.getmtime(fp)
|
|
229
|
-
except OSError:
|
|
230
|
-
continue
|
|
231
|
-
if age <= LIVE_WINDOW_S:
|
|
232
|
-
out.append((fp, age))
|
|
233
|
-
return out
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _codex_live(fp, pricing):
|
|
237
|
-
meta, model, steps, tokens, ctx, cost, prev = {}, "unknown", 0, 0, None, 0.0, None
|
|
238
|
-
with open(fp, errors="replace") as fh:
|
|
239
|
-
for line in fh:
|
|
240
|
-
try:
|
|
241
|
-
r = json.loads(line)
|
|
242
|
-
except ValueError:
|
|
243
|
-
continue
|
|
244
|
-
typ, p = r.get("type"), r.get("payload") or {}
|
|
245
|
-
if typ == "session_meta":
|
|
246
|
-
meta = p
|
|
247
|
-
elif typ == "turn_context":
|
|
248
|
-
model = p.get("model") or model
|
|
249
|
-
elif typ == "event_msg" and p.get("type") == "token_count" and p.get("info"):
|
|
250
|
-
tot = tuple(sorted(((p["info"] or {}).get("total_token_usage") or {}).items()))
|
|
251
|
-
if tot == prev:
|
|
252
|
-
continue
|
|
253
|
-
prev = tot
|
|
254
|
-
last = (p["info"] or {}).get("last_token_usage") or {}
|
|
255
|
-
cached = int(last.get("cached_input_tokens") or 0)
|
|
256
|
-
inp, out = int(last.get("input_tokens") or 0), int(last.get("output_tokens") or 0)
|
|
257
|
-
steps += 1
|
|
258
|
-
tokens += inp + out
|
|
259
|
-
ctx = inp
|
|
260
|
-
if pricing.is_known(model):
|
|
261
|
-
cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
|
|
262
|
-
return {"session_id": meta.get("id"), "cwd": meta.get("cwd"), "version": meta.get("cli_version"),
|
|
263
|
-
"model": model, "steps": steps, "tokens": tokens, "context": ctx, "est_cost_usd": cost}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def _gemini_live(fp, pricing):
|
|
267
|
-
with open(fp, errors="replace") as fh:
|
|
268
|
-
d = json.load(fh)
|
|
269
|
-
steps, tokens, ctx, cost, model = 0, 0, None, 0.0, None
|
|
270
|
-
for m in d.get("messages") or []:
|
|
271
|
-
if m.get("type") != "gemini":
|
|
272
|
-
continue
|
|
273
|
-
t = m.get("tokens") or {}
|
|
274
|
-
model = m.get("model") or model
|
|
275
|
-
cached, inp = int(t.get("cached") or 0), int(t.get("input") or 0)
|
|
276
|
-
out = int(t.get("output") or 0) + int(t.get("thoughts") or 0)
|
|
277
|
-
steps += 1
|
|
278
|
-
tokens += inp + out
|
|
279
|
-
ctx = inp
|
|
280
|
-
if pricing.is_known(model):
|
|
281
|
-
cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
|
|
282
|
-
return {"session_id": d.get("sessionId"), "cwd": None, "model": model, "steps": steps,
|
|
283
|
-
"tokens": tokens, "context": ctx, "est_cost_usd": cost}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _cursor_live(fp, pricing):
|
|
287
|
-
steps = 0
|
|
288
|
-
with open(fp, errors="replace") as fh:
|
|
289
|
-
for line in fh:
|
|
290
|
-
if '"tool_use"' in line:
|
|
291
|
-
steps += line.count('"tool_use"')
|
|
292
|
-
slug = os.path.relpath(fp, os.path.join(HOME, ".cursor", "projects")).split(os.sep)[0]
|
|
293
|
-
guess = "/" + slug.replace("-", "/")
|
|
294
|
-
return {"session_id": os.path.splitext(os.path.basename(fp))[0],
|
|
295
|
-
"cwd": guess if os.path.isdir(guess) else None, "project_hint": slug.rsplit("-", 1)[-1],
|
|
296
|
-
"model": None, "steps": steps, "tokens": None, "context": None, "est_cost_usd": None}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
LIVE_SOURCES = {
|
|
300
|
-
"codex": (os.path.join(HOME, ".codex", "sessions", "**", "*.jsonl"), _codex_live),
|
|
301
|
-
"gemini": (os.path.join(HOME, ".gemini", "tmp", "*", "chats", "*.json"), _gemini_live),
|
|
302
|
-
"cursor": (os.path.join(HOME, ".cursor", "projects", "*", "agent-transcripts", "*", "*.jsonl"), _cursor_live),
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def list_agent_sessions(pricing, agents=None, procs=None):
|
|
307
|
-
procs = procs if procs is not None else _ps()
|
|
308
|
-
now = time.time()
|
|
309
|
-
cli = {a: [(pid, p) for pid, p in procs.items() if _is_agent(p, a)] for a in AGENT_BINS}
|
|
310
|
-
cwds = {}
|
|
311
|
-
out = []
|
|
312
|
-
for agent, (pattern, parse) in LIVE_SOURCES.items():
|
|
313
|
-
if agents and agent not in agents:
|
|
314
|
-
continue
|
|
315
|
-
for fp, age in _recent(pattern, now):
|
|
316
|
-
try:
|
|
317
|
-
st = parse(fp, pricing)
|
|
318
|
-
except (OSError, ValueError):
|
|
319
|
-
continue
|
|
320
|
-
match = None
|
|
321
|
-
for pid, p in cli.get(agent, []):
|
|
322
|
-
if pid not in cwds:
|
|
323
|
-
cwds[pid] = _cwd_of(pid)
|
|
324
|
-
if st.get("cwd") is None or cwds[pid] == st.get("cwd"):
|
|
325
|
-
match = (pid, p)
|
|
326
|
-
break
|
|
327
|
-
ctx = st.get("context") or 0
|
|
328
|
-
cwd = st.get("cwd")
|
|
329
|
-
out.append({
|
|
330
|
-
"agent": agent, "pid": match[0] if match else None,
|
|
331
|
-
"signalable": bool(match), "session_id": st.get("session_id"), "name": None,
|
|
332
|
-
"cwd": cwd, "project": os.path.basename(cwd or "") or st.get("project_hint") or agent,
|
|
333
|
-
"status": "busy" if age <= BUSY_WINDOW_S else "idle", "version": st.get("version"),
|
|
334
|
-
"model": st.get("model"), "uptime": match[1]["etime"] if match else "",
|
|
335
|
-
"memory_mb": round(match[1]["rss_kb"] / 1024) if match else None,
|
|
336
|
-
"hosts_dashboard": False, "transcript": fp, "last_write_s": age,
|
|
337
|
-
"steps": st.get("steps"), "tokens": st.get("tokens"), "context": st.get("context"),
|
|
338
|
-
"est_cost_usd": st.get("est_cost_usd"),
|
|
339
|
-
"resume": RESUME.get(agent, "").format(sid=st.get("session_id") or "") or None,
|
|
340
|
-
"severity": "high" if ctx >= 300_000 else "medium" if ctx >= 150_000 else "ok",
|
|
341
|
-
})
|
|
342
|
-
return out
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def act_agent(pid, action, agent):
|
|
346
|
-
"""Signal a Codex / Gemini CLI process — only one the live list currently shows."""
|
|
347
|
-
if action not in ACTIONS:
|
|
348
|
-
return {"ok": False, "error": f"unknown action {action}"}
|
|
349
|
-
if agent not in AGENT_BINS:
|
|
350
|
-
return {"ok": False, "error": "This agent's sessions can't be stopped from here."}
|
|
351
|
-
procs = _ps()
|
|
352
|
-
p = procs.get(pid)
|
|
353
|
-
if not p or not _is_agent(p, agent):
|
|
354
|
-
return {"ok": False, "error": "Process is no longer running."}
|
|
355
|
-
live = [s for s in list_agent_sessions(_NoPricing(), [agent], procs) if s["pid"] == pid]
|
|
356
|
-
if not live:
|
|
357
|
-
return {"ok": False, "error": "Not a live session the dashboard shows; refusing."}
|
|
358
|
-
sig, msg = ACTIONS[action]
|
|
359
|
-
try:
|
|
360
|
-
os.kill(pid, sig)
|
|
361
|
-
except ProcessLookupError:
|
|
362
|
-
return {"ok": False, "error": "Process already exited."}
|
|
363
|
-
except PermissionError:
|
|
364
|
-
return {"ok": False, "error": "Not permitted to signal this process."}
|
|
365
|
-
res = {"ok": True, "message": msg.replace("`claude --resume`", "its resume command"),
|
|
366
|
-
"resume": live[0]["resume"]}
|
|
367
|
-
if action in ("close", "kill"):
|
|
368
|
-
for _ in range(20):
|
|
369
|
-
time.sleep(0.1)
|
|
370
|
-
if pid not in _ps():
|
|
371
|
-
res["exited"] = True
|
|
372
|
-
return res
|
|
373
|
-
res["exited"] = False
|
|
374
|
-
return res
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
class _NoPricing:
|
|
378
|
-
def is_known(self, model):
|
|
379
|
-
return False
|
|
380
|
-
|
|
381
|
-
|
|
382
194
|
# ---------------------------------------------------------------- handover ----
|
|
383
195
|
HANDOFFS = os.path.join(HOME, ".claude", "finops-handoffs")
|
|
384
196
|
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
package/finops/segments.py
CHANGED
|
@@ -19,8 +19,8 @@ Boundaries this can see:
|
|
|
19
19
|
from datetime import datetime
|
|
20
20
|
|
|
21
21
|
# Multipliers are derived per model from the price table rather than hardcoded: the
|
|
22
|
-
# 0.1x read / 1.25x 5m / 2.0x 1h shape holds for
|
|
23
|
-
#
|
|
22
|
+
# 0.1x read / 1.25x 5m / 2.0x 1h shape holds for today's Claude models, but a fixed
|
|
23
|
+
# constant would quietly go wrong the day a model is priced differently.
|
|
24
24
|
READ = "cache_read"
|
|
25
25
|
W5M = "cache_write_5m"
|
|
26
26
|
W1H = "cache_write_1h"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
|
@@ -33,10 +33,7 @@
|
|
|
33
33
|
"dashboard",
|
|
34
34
|
"finops",
|
|
35
35
|
"analytics",
|
|
36
|
-
"cli"
|
|
37
|
-
"codex",
|
|
38
|
-
"gemini-cli",
|
|
39
|
-
"cursor"
|
|
36
|
+
"cli"
|
|
40
37
|
],
|
|
41
38
|
"license": "MIT",
|
|
42
39
|
"private": false,
|