claude-finops 0.1.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.
@@ -0,0 +1,336 @@
1
+ """Billed numbers from the vendors' own APIs, next to what this machine recorded.
2
+
3
+ Everything else in this dashboard reads local files. This module is the one part that
4
+ talks to the internet, and only when you ask it to (the Refresh button / --cloud-sync).
5
+
6
+ Keys are never stored by the UI. Put them in the environment, or store one with
7
+ `claude-finops --set-key`, which writes ~/.claude-finops/secrets.local.json (0600,
8
+ outside the install tree, never packaged):
9
+
10
+ {"anthropic_admin_key": "sk-ant-admin...", "cursor_api_key": "key_..."}
11
+
12
+ Anthropic Admin API key from Console → Settings → Admin keys. Org accounts only;
13
+ individual accounts have no Admin API.
14
+ /v1/organizations/usage_report/claude_code per user per day, incl. Pro/Max
15
+ /v1/organizations/cost_report billed USD (API spend only)
16
+ Cursor Team/Org admin key from Cursor dashboard → Settings → Admin API.
17
+ POST /teams/daily-usage-data, /teams/spend, GET /teams/members
18
+
19
+ Responses are cached in ~/.claude-finops/data/cloud_cache.json so the dashboard never calls out on its own.
20
+ """
21
+ import base64
22
+ import json
23
+ import os
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+ from datetime import datetime, timedelta, timezone
28
+
29
+ from .paths import ROOT, SECRETS_PATH, CACHE_PATH
30
+ UA = "claude-finops/1.0 (local dashboard)"
31
+ TIMEOUT = 30
32
+
33
+ PROVIDERS = {
34
+ "anthropic": {
35
+ "name": "Anthropic (Claude)", "agent": "claude", "env": "ANTHROPIC_ADMIN_KEY",
36
+ "field": "anthropic_admin_key",
37
+ "how": "Console → Settings → Admin keys → Create Admin key (sk-ant-admin…). "
38
+ "Requires an organization; individual accounts have no Admin API.",
39
+ "covers": "Per-user Claude Code sessions, tokens and estimated cost (including Pro/Max "
40
+ "subscription users), plus billed API cost for the org.",
41
+ },
42
+ "cursor": {
43
+ "name": "Cursor", "agent": "cursor", "env": "CURSOR_API_KEY", "field": "cursor_api_key",
44
+ "how": "Cursor dashboard → Settings → Admin API → create a key. Team/Business plans only; "
45
+ "an individual Pro account has no admin API.",
46
+ "covers": "Per-member daily activity and billed spend for the team.",
47
+ },
48
+ }
49
+
50
+
51
+ def _secrets():
52
+ try:
53
+ with open(SECRETS_PATH) as fh:
54
+ return json.load(fh)
55
+ except (OSError, ValueError):
56
+ return {}
57
+
58
+
59
+ def key_for(provider):
60
+ p = PROVIDERS[provider]
61
+ return os.environ.get(p["env"]) or _secrets().get(p["field"]) or None
62
+
63
+
64
+ def configured():
65
+ return {k: bool(key_for(k)) for k in PROVIDERS}
66
+
67
+
68
+ def _get(url, headers, data=None, method="GET"):
69
+ req = urllib.request.Request(url, data=data, method=method,
70
+ headers={"User-Agent": UA, **headers})
71
+ try:
72
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
73
+ return json.loads(r.read() or b"{}")
74
+ except urllib.error.HTTPError as e:
75
+ body = (e.read() or b"")[:400].decode("utf-8", "replace")
76
+ raise RuntimeError(f"{e.code} from {url.split('?')[0]}: {body}") from None
77
+ except urllib.error.URLError as e:
78
+ raise RuntimeError(f"Could not reach {url.split('?')[0]}: {e.reason}") from None
79
+
80
+
81
+ # ------------------------------------------------------------------ Anthropic ----
82
+ def _anthropic_headers(key):
83
+ return {"x-api-key": key, "anthropic-version": "2023-06-01"}
84
+
85
+
86
+ def anthropic_claude_code(key, days=30, log=print):
87
+ """One request per day (the endpoint reports a single UTC day), paged."""
88
+ out, today = [], datetime.now(timezone.utc).date()
89
+ for i in range(days, 0, -1):
90
+ day = (today - timedelta(days=i)).isoformat()
91
+ page = None
92
+ while True:
93
+ q = f"starting_at={day}&limit=1000" + (f"&page={page}" if page else "")
94
+ d = _get(f"https://api.anthropic.com/v1/organizations/usage_report/claude_code?{q}",
95
+ _anthropic_headers(key))
96
+ for rec in d.get("data") or []:
97
+ actor = rec.get("actor") or {}
98
+ cm = rec.get("core_metrics") or {}
99
+ loc = cm.get("lines_of_code") or {}
100
+ models = []
101
+ cost = tokens = 0.0
102
+ for m in rec.get("model_breakdown") or []:
103
+ t = m.get("tokens") or {}
104
+ n = sum(int(t.get(k) or 0) for k in
105
+ ("input", "output", "cache_read", "cache_creation"))
106
+ c = (m.get("estimated_cost") or {}).get("amount") or 0
107
+ cost += float(c) / 100.0 # cents -> USD
108
+ tokens += n
109
+ models.append({"model": m.get("model"), "tokens": n,
110
+ "est_cost_usd": float(c) / 100.0})
111
+ out.append({
112
+ "day": day,
113
+ "actor": actor.get("email_address") or actor.get("api_key_name") or "unknown",
114
+ "customer_type": rec.get("customer_type"), "terminal": rec.get("terminal_type"),
115
+ "sessions": cm.get("num_sessions") or 0,
116
+ "lines_added": loc.get("added") or 0, "lines_removed": loc.get("removed") or 0,
117
+ "commits": cm.get("commits_by_claude_code") or 0,
118
+ "prs": cm.get("pull_requests_by_claude_code") or 0,
119
+ "tokens": tokens, "est_cost_usd": cost, "models": models})
120
+ if not d.get("has_more"):
121
+ break
122
+ page = d.get("next_page")
123
+ log(f" Anthropic Claude Code {day}: {len(out)} records so far")
124
+ return out
125
+
126
+
127
+ def anthropic_cost(key, days=30):
128
+ """Billed USD per day. Covers API spend only — subscription plans bill separately."""
129
+ end = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
130
+ start = end - timedelta(days=days)
131
+ iso = lambda d: d.strftime("%Y-%m-%dT%H:%M:%SZ")
132
+ out, page = [], None
133
+ while True:
134
+ q = f"starting_at={iso(start)}&ending_at={iso(end)}&group_by[]=description"
135
+ d = _get(f"https://api.anthropic.com/v1/organizations/cost_report?{q}"
136
+ + (f"&page={page}" if page else ""), _anthropic_headers(key))
137
+ for b in d.get("data") or []:
138
+ for r in b.get("results") or []:
139
+ amt = r.get("amount")
140
+ try:
141
+ usd = float(amt) / 100.0 # decimal string, in cents
142
+ except (TypeError, ValueError):
143
+ usd = 0.0
144
+ out.append({"day": (b.get("starting_at") or "")[:10],
145
+ "description": r.get("description") or r.get("model") or "usage",
146
+ "model": r.get("model"), "cost_usd": usd})
147
+ if not d.get("has_more"):
148
+ break
149
+ page = d.get("next_page")
150
+ return out
151
+
152
+
153
+ # --------------------------------------------------------------------- Cursor ----
154
+ def _cursor_headers(key):
155
+ tok = base64.b64encode(f"{key}:".encode()).decode() # basic auth, key as username
156
+ return {"Authorization": f"Basic {tok}", "Content-Type": "application/json"}
157
+
158
+
159
+ def _cursor_post(key, path, body):
160
+ try:
161
+ return _get(f"https://api.cursor.com{path}", _cursor_headers(key),
162
+ data=json.dumps(body).encode(), method="POST")
163
+ except RuntimeError as e:
164
+ if "401" in str(e): # the key is fine; the account just isn't a team
165
+ raise RuntimeError(
166
+ "Cursor rejected the key for team endpoints (401 Invalid Team API Key). The key "
167
+ "itself is valid, but /teams/* answers only for Team/Enterprise accounts — an "
168
+ "individual account has no team data to report. Cursor stays local-only.") from None
169
+ raise
170
+
171
+
172
+ def cursor_usage(key, days=30):
173
+ """Daily per-member activity. The API allows at most 30 days per call."""
174
+ end = int(time.time() * 1000)
175
+ start = end - min(days, 30) * 86400000
176
+ out, page = [], 1
177
+ while True:
178
+ d = _cursor_post(key, "/teams/daily-usage-data",
179
+ {"startDate": start, "endDate": end, "page": page, "pageSize": 1000})
180
+ rows = d.get("data") or []
181
+ for r in rows:
182
+ ts = r.get("date")
183
+ day = datetime.fromtimestamp(int(ts) / 1000, timezone.utc).date().isoformat() \
184
+ if ts else None
185
+ out.append({"day": day, "member": r.get("email") or r.get("userId") or "unknown",
186
+ "is_active": r.get("isActive"),
187
+ "lines_added": r.get("totalLinesAdded") or 0,
188
+ "lines_removed": r.get("totalLinesDeleted") or 0,
189
+ "accepted": r.get("acceptedLinesAdded") or 0,
190
+ "requests": r.get("composerRequests") or r.get("totalApplies") or 0,
191
+ "model": r.get("mostUsedModel")})
192
+ if len(rows) < 1000 or page > 20:
193
+ break
194
+ page += 1
195
+ return out
196
+
197
+
198
+ def cursor_spend(key):
199
+ d = _cursor_post(key, "/teams/spend", {"page": 1, "pageSize": 500})
200
+ out = []
201
+ for r in d.get("teamMemberSpend") or d.get("data") or []:
202
+ cents = r.get("spendCents")
203
+ out.append({"member": r.get("email") or r.get("name") or "unknown",
204
+ "spend_usd": (float(cents) / 100.0) if cents is not None else None,
205
+ "requests": r.get("fastPremiumRequests") or r.get("requests"),
206
+ "role": r.get("role")})
207
+ return out
208
+
209
+
210
+ # ---------------------------------------------------------------------- cache ----
211
+ def load_cache():
212
+ try:
213
+ with open(CACHE_PATH) as fh:
214
+ return json.load(fh)
215
+ except (OSError, ValueError):
216
+ return {}
217
+
218
+
219
+ def sync(days=30, log=print):
220
+ """Fetch what the configured keys allow, write the cache, return it."""
221
+ cache = load_cache()
222
+ cache["days"] = days
223
+ errors = {}
224
+ k = key_for("anthropic")
225
+ if k:
226
+ log("Anthropic: Claude Code analytics…")
227
+ try:
228
+ cache["anthropic_claude_code"] = anthropic_claude_code(k, days, log)
229
+ except RuntimeError as e:
230
+ errors["anthropic_claude_code"] = str(e)
231
+ log("Anthropic: cost report…")
232
+ try:
233
+ cache["anthropic_cost"] = anthropic_cost(k, days)
234
+ except RuntimeError as e:
235
+ errors["anthropic_cost"] = str(e)
236
+ k = key_for("cursor")
237
+ if k:
238
+ log("Cursor: daily usage…")
239
+ try:
240
+ cache["cursor_usage"] = cursor_usage(k, days)
241
+ except RuntimeError as e:
242
+ errors["cursor_usage"] = str(e)
243
+ log("Cursor: spend…")
244
+ try:
245
+ cache["cursor_spend"] = cursor_spend(k)
246
+ except RuntimeError as e:
247
+ errors["cursor_spend"] = str(e)
248
+ cache["errors"] = errors
249
+ cache["fetched_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
250
+ os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
251
+ with open(CACHE_PATH, "w") as fh:
252
+ json.dump(cache, fh)
253
+ return cache
254
+
255
+
256
+ def report(analytics, days=30):
257
+ """Billed (vendor API) next to local (this machine), per agent and per day."""
258
+ c = load_cache()
259
+ cc = c.get("anthropic_claude_code") or []
260
+ cost = c.get("anthropic_cost") or []
261
+ cu = c.get("cursor_usage") or []
262
+ cs = c.get("cursor_spend") or []
263
+ since = (datetime.now(timezone.utc).date() - timedelta(days=days)).isoformat()
264
+ local = {r["agent"]: r for r in analytics.q(
265
+ "SELECT agent, SUM(est_cost_usd) cost, SUM(billable_tokens) tokens,"
266
+ " COUNT(DISTINCT session_id) sessions, COUNT(*) requests"
267
+ " FROM requests WHERE day >= ? GROUP BY agent", (since,))}
268
+
269
+ by_day = {}
270
+ for r in cc:
271
+ d = by_day.setdefault(r["day"], {"day": r["day"], "billed_cost": 0.0, "billed_tokens": 0,
272
+ "billed_sessions": 0})
273
+ d["billed_cost"] += r["est_cost_usd"]
274
+ d["billed_tokens"] += r["tokens"]
275
+ d["billed_sessions"] += r["sessions"]
276
+ for r in analytics.q("SELECT day, SUM(est_cost_usd) c, SUM(billable_tokens) t,"
277
+ " COUNT(DISTINCT session_id) s FROM requests"
278
+ " WHERE agent='claude' AND day >= ? GROUP BY day", (since,)):
279
+ d = by_day.setdefault(r["day"], {"day": r["day"], "billed_cost": 0.0, "billed_tokens": 0,
280
+ "billed_sessions": 0})
281
+ d.update(local_cost=r["c"] or 0.0, local_tokens=r["t"] or 0, local_sessions=r["s"] or 0)
282
+
283
+ users = {}
284
+ for r in cc:
285
+ u = users.setdefault(r["actor"], {"actor": r["actor"], "cost": 0.0, "tokens": 0,
286
+ "sessions": 0, "lines_added": 0, "lines_removed": 0,
287
+ "commits": 0, "prs": 0, "customer_type": r["customer_type"],
288
+ "terminals": set()})
289
+ for k2, v in (("cost", "est_cost_usd"), ("tokens", "tokens"), ("sessions", "sessions"),
290
+ ("lines_added", "lines_added"), ("lines_removed", "lines_removed"),
291
+ ("commits", "commits"), ("prs", "prs")):
292
+ u[k2] += r[v] or 0
293
+ if r["terminal"]:
294
+ u["terminals"].add(r["terminal"])
295
+ for u in users.values():
296
+ u["terminals"] = sorted(u["terminals"])
297
+
298
+ cur = {}
299
+ for r in cu:
300
+ m = cur.setdefault(r["member"], {"member": r["member"], "days": 0, "lines_added": 0,
301
+ "lines_removed": 0, "accepted": 0, "requests": 0})
302
+ m["days"] += 1 if r.get("is_active") is not False else 0
303
+ for k2 in ("lines_added", "lines_removed", "accepted", "requests"):
304
+ m[k2] += r.get(k2) or 0
305
+ spend = {s["member"]: s for s in cs}
306
+ for m in cur.values():
307
+ m["spend_usd"] = (spend.get(m["member"]) or {}).get("spend_usd")
308
+
309
+ claude_local = local.get("claude") or {}
310
+ cursor_local = local.get("cursor") or {}
311
+ return {
312
+ "configured": configured(), "providers": PROVIDERS,
313
+ "fetched_at": c.get("fetched_at"), "errors": c.get("errors") or {},
314
+ "days": days,
315
+ "totals": {
316
+ "billed_claude_cost": sum(r["est_cost_usd"] for r in cc),
317
+ "billed_claude_tokens": sum(r["tokens"] for r in cc),
318
+ "local_claude_cost": claude_local.get("cost") or 0.0,
319
+ "local_claude_tokens": claude_local.get("tokens") or 0,
320
+ "billed_api_cost": sum(r["cost_usd"] for r in cost),
321
+ "cursor_spend": sum(s["spend_usd"] or 0 for s in cs) if cs else None,
322
+ "local_cursor_requests": cursor_local.get("requests") or 0,
323
+ "org_users": len(users), "cursor_members": len(cur),
324
+ },
325
+ "by_day": sorted(by_day.values(), key=lambda x: x["day"]),
326
+ "users": sorted(users.values(), key=lambda x: -x["cost"]),
327
+ "cursor_members": sorted(cur.values(), key=lambda x: -(x["spend_usd"] or 0)),
328
+ "api_cost_by_description": sorted(
329
+ [{"description": k2, "cost_usd": v} for k2, v in
330
+ {r["description"]: sum(x["cost_usd"] for x in cost if x["description"] == r["description"])
331
+ for r in cost}.items()], key=lambda x: -x["cost_usd"])[:20],
332
+ "note": "Billed figures come from the vendor APIs (org-wide, every machine and member). "
333
+ "Local figures are what this machine's transcripts recorded. A gap usually means "
334
+ "other machines, other members, or work outside this machine.",
335
+ "basis": "billed",
336
+ }