claude-finops 0.3.0 → 0.4.1
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/analytics.py +39 -4
- package/finops/api.py +23 -1
- package/finops/diagnose.py +7 -3
- package/finops/report.py +10 -1
- package/package.json +1 -1
- package/web/app.js +20 -1
- package/web/styles.css +20 -1
package/finops/analytics.py
CHANGED
|
@@ -30,22 +30,57 @@ def _merge(base, over):
|
|
|
30
30
|
return base
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# claude_max -> "Max", claude_pro -> "Pro": the tier string carries a 5x/20x
|
|
34
|
+
# suffix we keep, because which Max you are on changes every limit in the app.
|
|
35
|
+
_PLANS = {"claude_max": "Max", "claude_pro": "Pro", "claude_team": "Team",
|
|
36
|
+
"claude_enterprise": "Enterprise"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plan(acct):
|
|
40
|
+
base = _PLANS.get(acct.get("organizationType") or "")
|
|
41
|
+
mult = ""
|
|
42
|
+
tier = str(acct.get("organizationRateLimitTier") or "")
|
|
43
|
+
if base == "Max":
|
|
44
|
+
for m in ("5x", "20x"):
|
|
45
|
+
if tier.endswith(m):
|
|
46
|
+
mult = " " + m
|
|
47
|
+
return (base + mult) if base else ""
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
def detect_account():
|
|
34
|
-
"""
|
|
51
|
+
"""Who Claude Code is signed in as, read from ~/.claude.json (actual, not guessed).
|
|
52
|
+
|
|
53
|
+
Everything here is already on this machine, written by Claude Code itself at
|
|
54
|
+
login. We only surface it, so a shared screenshot says whose numbers these
|
|
55
|
+
are — a dashboard with no name on it is the one people misread.
|
|
56
|
+
"""
|
|
35
57
|
try:
|
|
36
58
|
with open(os.path.expanduser("~/.claude.json")) as fh:
|
|
37
59
|
acct = json.load(fh).get("oauthAccount") or {}
|
|
38
60
|
except (OSError, ValueError):
|
|
39
61
|
return {}
|
|
40
|
-
|
|
62
|
+
email = acct.get("emailAddress") or ""
|
|
63
|
+
name = acct.get("fullName") or acct.get("displayName") or ""
|
|
64
|
+
org = acct.get("organizationName") or ""
|
|
65
|
+
out = {"name": name, "email": email,
|
|
66
|
+
# A personal plan names the org after the person; repeating it is noise.
|
|
67
|
+
"org": "" if org == name else org,
|
|
68
|
+
"plan": _plan(acct)}
|
|
69
|
+
if email:
|
|
70
|
+
out["label"] = email
|
|
71
|
+
return {k: v for k, v in out.items() if v}
|
|
41
72
|
|
|
42
73
|
|
|
43
74
|
def load_settings():
|
|
44
75
|
"""Shared defaults (settings.json) + this machine's overrides (settings.local.json)."""
|
|
45
76
|
with open(SETTINGS_PATH) as fh:
|
|
46
77
|
cur = json.load(fh)
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
# Detected identity first, so a configured settings.json still wins below.
|
|
79
|
+
detected = detect_account()
|
|
80
|
+
acct = cur.setdefault("account", {})
|
|
81
|
+
for k, v in detected.items():
|
|
82
|
+
if not acct.get(k):
|
|
83
|
+
acct[k] = v
|
|
49
84
|
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
50
85
|
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
51
86
|
_merge(cur, json.load(fh))
|
package/finops/api.py
CHANGED
|
@@ -58,12 +58,34 @@ def flatten(rows):
|
|
|
58
58
|
return buf.getvalue()
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
# A browser that navigates away, reloads, or is closed mid-response resets the
|
|
62
|
+
# socket. That is the client's normal behaviour, not our error, but socketserver
|
|
63
|
+
# prints a full traceback for it — pages of noise in server.log that look like a
|
|
64
|
+
# crash. These two are the only disconnect shapes it produces.
|
|
65
|
+
DISCONNECTS = (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Server(ThreadingHTTPServer):
|
|
69
|
+
def handle_error(self, request, client_address):
|
|
70
|
+
import sys
|
|
71
|
+
if not isinstance(sys.exception(), DISCONNECTS):
|
|
72
|
+
super().handle_error(request, client_address)
|
|
73
|
+
|
|
74
|
+
|
|
61
75
|
class Handler(BaseHTTPRequestHandler):
|
|
62
76
|
protocol_version = "HTTP/1.1"
|
|
63
77
|
|
|
64
78
|
def log_message(self, *a):
|
|
65
79
|
pass
|
|
66
80
|
|
|
81
|
+
def handle_one_request(self):
|
|
82
|
+
# The reset can also land while we are still writing the response, past
|
|
83
|
+
# the point handle_error covers.
|
|
84
|
+
try:
|
|
85
|
+
super().handle_one_request()
|
|
86
|
+
except DISCONNECTS:
|
|
87
|
+
self.close_connection = True
|
|
88
|
+
|
|
67
89
|
def send_json(self, obj, code=200):
|
|
68
90
|
body = json.dumps(obj, default=str).encode()
|
|
69
91
|
self.send_response(code)
|
|
@@ -423,7 +445,7 @@ def serve(port=8787, db=DB_PATH, background=None):
|
|
|
423
445
|
if background and hasattr(os, "fork"):
|
|
424
446
|
detach(port)
|
|
425
447
|
A = Analytics(db)
|
|
426
|
-
srv =
|
|
448
|
+
srv = Server(("127.0.0.1", port), Handler)
|
|
427
449
|
print(f"Claude FinOps Command Center -> http://127.0.0.1:{port}")
|
|
428
450
|
print(f" warehouse: {db}")
|
|
429
451
|
print(f" data : {A.first_day} .. {A.last_day}")
|
package/finops/diagnose.py
CHANGED
|
@@ -686,7 +686,7 @@ class Diagnoser:
|
|
|
686
686
|
rows = a.q(f"""SELECT r.session_id, s.title, pj.name project, COUNT(*) steps,
|
|
687
687
|
MAX(r.context_tokens) peak, AVG(r.context_tokens) avg_ctx,
|
|
688
688
|
SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
|
|
689
|
-
SUM(CASE WHEN r.context_tokens > {base} THEN r.context_tokens - {base} ELSE 0 END) over_base,
|
|
689
|
+
COALESCE(SUM(CASE WHEN r.context_tokens > {base} THEN r.context_tokens - {base} ELSE 0 END), 0) over_base,
|
|
690
690
|
SUM(CASE WHEN r.context_tokens > {self.CTX_WARN} THEN 1 ELSE 0 END) heavy_steps,
|
|
691
691
|
COUNT(DISTINCT r.prompt_id) prompts, SUM(r.is_sidechain) side
|
|
692
692
|
FROM requests r JOIN sessions s ON s.id=r.session_id JOIN projects pj ON pj.id=r.project_id
|
|
@@ -698,8 +698,12 @@ class Diagnoser:
|
|
|
698
698
|
for r in rows:
|
|
699
699
|
sid = r["session_id"]
|
|
700
700
|
fixes = []
|
|
701
|
-
|
|
702
|
-
|
|
701
|
+
# A session with no priced requests sums to NULL, not 0 — and one
|
|
702
|
+
# NULL used to take the whole diagnose page down with a 500.
|
|
703
|
+
over = r["over_base"] or 0
|
|
704
|
+
r["over_base"] = over
|
|
705
|
+
r["avoidable_tokens"] = over
|
|
706
|
+
r["avoidable_cost"] = over * rate
|
|
703
707
|
if not compacts.get(sid):
|
|
704
708
|
fixes.append(f"Never compacted. {r['heavy_steps']:,} steps ran above "
|
|
705
709
|
f"{self.CTX_WARN // 1000}K context; /compact (or /clear between the "
|
package/finops/report.py
CHANGED
|
@@ -3,6 +3,15 @@ import html
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
def _who(acct):
|
|
7
|
+
""""Mohit Raj Purohit <mohit@example.com>" when we know both, else whichever
|
|
8
|
+
one we have. A report that leaves the house should name its account."""
|
|
9
|
+
name, email = acct.get("name") or "", acct.get("email") or acct.get("label") or ""
|
|
10
|
+
if name and email:
|
|
11
|
+
return f"{name} <{email}>"
|
|
12
|
+
return name or email or "unknown"
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
def _f(v, kind="usd"):
|
|
7
16
|
if v is None:
|
|
8
17
|
return "—"
|
|
@@ -52,7 +61,7 @@ td:nth-child(n+2) {{ font-variant-numeric: tabular-nums; }}
|
|
|
52
61
|
ul {{ margin:6px 0 12px 18px; padding:0; }} li {{ margin-bottom:4px; }}
|
|
53
62
|
</style></head><body>
|
|
54
63
|
<h1>Claude AI FinOps Report</h1>
|
|
55
|
-
<div class="sub">Account {html.escape(
|
|
64
|
+
<div class="sub">Account {html.escape(_who(a.settings['account']))} ·
|
|
56
65
|
Billing period {bp['start']} → {bp['end']} ·
|
|
57
66
|
Data {ov['date_range']['first']} – {ov['date_range']['last']} ·
|
|
58
67
|
Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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"
|
package/web/app.js
CHANGED
|
@@ -151,7 +151,8 @@ function shell() {
|
|
|
151
151
|
<nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
|
|
152
152
|
items.map(([id, ic, label]) =>
|
|
153
153
|
`<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${ic}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
|
|
154
|
-
</nav
|
|
154
|
+
</nav>
|
|
155
|
+
<div class="who" id="who"></div></aside>
|
|
155
156
|
<div class="main">
|
|
156
157
|
<header class="topbar">
|
|
157
158
|
<div class="r1">
|
|
@@ -2223,9 +2224,27 @@ const navAllowed = scope => !scope || (scope === 'claude' ? hasClaude()
|
|
|
2223
2224
|
: scope === 'cloud' ? hasCloudKey() : hasPriced());
|
|
2224
2225
|
// "Claude Code", "Codex", or "agent" for a mixed selection: used in data-availability text
|
|
2225
2226
|
const agentWord = () => { const a = selAgents(); return a.length === 1 ? a[0].name : 'agent'; };
|
|
2227
|
+
// Whose usage this is. Read from ~/.claude.json by the server, so a screenshot
|
|
2228
|
+
// or a shared dashboard always says which account the numbers belong to.
|
|
2229
|
+
function whoBlock() {
|
|
2230
|
+
const el = $('#who'), a = S.opts?.settings?.account || {};
|
|
2231
|
+
if (!el) return;
|
|
2232
|
+
if (!a.name && !a.email) { el.innerHTML = ''; return; }
|
|
2233
|
+
const initials = (a.name || a.email || '?').split(/[\s@.]+/).filter(Boolean)
|
|
2234
|
+
.slice(0, 2).map(w => w[0].toUpperCase()).join('');
|
|
2235
|
+
el.innerHTML = `<div class="av">${esc(initials)}</div>
|
|
2236
|
+
<div class="id">
|
|
2237
|
+
<div class="nm">${esc(a.name || a.email)}</div>
|
|
2238
|
+
<div class="em" title="${esc(a.email || '')}">${esc(a.email || '')}</div>
|
|
2239
|
+
${a.plan || a.org ? `<div class="pl">${esc([a.plan, a.org].filter(Boolean).join(' · '))}</div>` : ''}
|
|
2240
|
+
</div>`;
|
|
2241
|
+
el.title = `Claude Code is signed in as ${a.name || ''} <${a.email || ''}>`.trim();
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2226
2244
|
function applyAgentChrome() {
|
|
2227
2245
|
const a = selAgents();
|
|
2228
2246
|
const label = a.length === 1 ? a[0].name.replace(/ (Code|CLI)$/, '') : a.length ? 'Multi-agent' : 'AI';
|
|
2247
|
+
whoBlock();
|
|
2229
2248
|
const mark = $('.brand .mark');
|
|
2230
2249
|
if (mark) mark.innerHTML = `<span class="dot"></span>${esc(label)} FinOps`;
|
|
2231
2250
|
const sub = $('.brand .sub');
|
package/web/styles.css
CHANGED
|
@@ -77,7 +77,8 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
77
77
|
/* ---------- shell ---------- */
|
|
78
78
|
.app { display: grid; grid-template-columns: 216px 1fr; min-height: 100vh; }
|
|
79
79
|
.sidebar { background: var(--surface); border-right: 1px solid var(--border);
|
|
80
|
-
padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto;
|
|
80
|
+
padding: 14px 0; position: sticky; top:0; height:100vh; overflow-y:auto;
|
|
81
|
+
display:flex; flex-direction:column; }
|
|
81
82
|
.brand { padding: 0 16px 14px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
|
|
82
83
|
.brand .mark { display:flex; align-items:center; gap:8px; font-weight:660; font-size:14px;
|
|
83
84
|
letter-spacing:-0.01em; }
|
|
@@ -395,3 +396,21 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
395
396
|
white-space: nowrap;
|
|
396
397
|
}
|
|
397
398
|
.iconbtn.upd:hover { background: var(--accent, #eb6834); color: #fff; }
|
|
399
|
+
|
|
400
|
+
/* Whose account this dashboard is reading — pinned to the foot of the sidebar,
|
|
401
|
+
so every screenshot carries the name of the account it came from. */
|
|
402
|
+
/* Sticky, because the nav is taller than the viewport and an identity you have
|
|
403
|
+
to scroll to find is one nobody checks. */
|
|
404
|
+
.who { margin-top:auto; position:sticky; bottom:0; z-index:2;
|
|
405
|
+
display:flex; align-items:center; gap:9px;
|
|
406
|
+
padding:12px 16px; background:var(--surface);
|
|
407
|
+
border-top:1px solid var(--border); }
|
|
408
|
+
.who:empty { display:none; }
|
|
409
|
+
.who .av { flex:none; width:26px; height:26px; border-radius:50%; display:grid;
|
|
410
|
+
place-items:center; font-size:10.5px; font-weight:700; color:#fff;
|
|
411
|
+
background: var(--accent, #eb6834); }
|
|
412
|
+
.who .id { min-width:0; }
|
|
413
|
+
.who .nm { font-size:11.5px; font-weight:620; line-height:1.25;
|
|
414
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
415
|
+
.who .em, .who .pl { font-size:10px; color:var(--muted); line-height:1.35;
|
|
416
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|