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.
@@ -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
- """The signed-in Claude Code account, read from ~/.claude.json (actual, not guessed)."""
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
- return {"label": acct.get("emailAddress")} if acct.get("emailAddress") else {}
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
- if not cur.get("account", {}).get("label"):
48
- cur.setdefault("account", {}).update(detect_account())
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 = ThreadingHTTPServer(("127.0.0.1", port), Handler)
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}")
@@ -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
- r["avoidable_tokens"] = r["over_base"]
702
- r["avoidable_cost"] = r["over_base"] * rate
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 "&mdash;"
@@ -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(str(a.settings['account'].get('label')))} &middot;
64
+ <div class="sub">Account {html.escape(_who(a.settings['account']))} &middot;
56
65
  Billing period {bp['start']} &rarr; {bp['end']} &middot;
57
66
  Data {ov['date_range']['first']} &ndash; {ov['date_range']['last']} &middot;
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.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></aside>
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; }