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.
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/bin/claude-finops.js +74 -0
- package/config/free_models.json +49 -0
- package/config/model_compare.json +13 -0
- package/config/pricing.json +232 -0
- package/config/settings.json +57 -0
- package/finops/__init__.py +0 -0
- package/finops/actions.py +671 -0
- package/finops/agents.py +296 -0
- package/finops/analytics.py +1579 -0
- package/finops/api.py +426 -0
- package/finops/classify.py +48 -0
- package/finops/cloud.py +336 -0
- package/finops/diagnose.py +1002 -0
- package/finops/etl.py +533 -0
- package/finops/paths.py +111 -0
- package/finops/playbook.py +229 -0
- package/finops/pricing.py +74 -0
- package/finops/procs.py +499 -0
- package/finops/report.py +169 -0
- package/package.json +52 -0
- package/run.cmd +4 -0
- package/run.py +210 -0
- package/run.sh +3 -0
- package/web/app.js +2923 -0
- package/web/charts.js +340 -0
- package/web/index.html +14 -0
- package/web/styles.css +387 -0
package/finops/cloud.py
ADDED
|
@@ -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
|
+
}
|