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/agents.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Loaders for other coding agents on this machine, into the same warehouse as Claude Code.
|
|
2
|
+
|
|
3
|
+
Each agent's rows carry `agent` so every view can be filtered to one agent or several.
|
|
4
|
+
Only what the agent actually records is loaded; missing fields stay NULL/0 and the UI
|
|
5
|
+
says so (see AGENTS[...]["data"]).
|
|
6
|
+
|
|
7
|
+
codex ~/.codex/sessions/**/rollout-*.jsonl tokens per turn, model, prompts, tool calls
|
|
8
|
+
gemini ~/.gemini/tmp/<hash>/chats/*.json tokens per reply, model, prompts
|
|
9
|
+
cursor ~/.cursor/projects/*/agent-transcripts prompts + tool calls (no tokens, no model)
|
|
10
|
+
Cursor IDE state.vscdb tokens on some replies (no model)
|
|
11
|
+
"""
|
|
12
|
+
import glob
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sqlite3
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
HOME = os.path.expanduser("~")
|
|
21
|
+
IS_WIN, IS_MAC = os.name == "nt", sys.platform == "darwin"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cursor_state_db():
|
|
25
|
+
if IS_MAC:
|
|
26
|
+
base = os.path.join(HOME, "Library", "Application Support", "Cursor")
|
|
27
|
+
elif IS_WIN:
|
|
28
|
+
base = os.path.join(os.environ.get("APPDATA", ""), "Cursor")
|
|
29
|
+
else:
|
|
30
|
+
base = os.path.join(HOME, ".config", "Cursor")
|
|
31
|
+
return os.path.join(base, "User", "globalStorage", "state.vscdb")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
AGENTS = {
|
|
35
|
+
"claude": {"name": "Claude Code", "data": "full",
|
|
36
|
+
"note": "Tokens, model, cost, prompts and tool calls per request."},
|
|
37
|
+
"codex": {"name": "Codex", "data": "tokens",
|
|
38
|
+
"paths": [os.path.join(HOME, ".codex", "sessions")],
|
|
39
|
+
"note": "Tokens and model per turn. Cost estimated at OpenAI API list prices; "
|
|
40
|
+
"on a ChatGPT plan you don't pay per token."},
|
|
41
|
+
"gemini": {"name": "Gemini CLI", "data": "tokens",
|
|
42
|
+
"paths": [os.path.join(HOME, ".gemini", "tmp")],
|
|
43
|
+
"note": "Tokens and model per reply. Cost estimated at Gemini API list prices; "
|
|
44
|
+
"the free tier costs nothing."},
|
|
45
|
+
"cursor": {"name": "Cursor", "data": "activity",
|
|
46
|
+
"paths": [os.path.join(HOME, ".cursor", "projects"), _cursor_state_db()],
|
|
47
|
+
"note": "Prompts and tool calls from agent transcripts; token counts only where "
|
|
48
|
+
"the Cursor IDE stored them, with no model. Cursor bills by subscription, "
|
|
49
|
+
"so no cost is estimated."},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def detect():
|
|
54
|
+
"""Which agents have data here (Claude is always listed)."""
|
|
55
|
+
out = {"claude": True}
|
|
56
|
+
for k, a in AGENTS.items():
|
|
57
|
+
if k != "claude":
|
|
58
|
+
out[k] = any(os.path.exists(p) for p in a.get("paths", []))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _day(ts):
|
|
63
|
+
return (ts or "")[:10]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _iso_ms(ms):
|
|
67
|
+
try:
|
|
68
|
+
return datetime.fromtimestamp(int(ms) / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
69
|
+
except (TypeError, ValueError, OSError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _iso_mtime(path):
|
|
74
|
+
return datetime.fromtimestamp(os.path.getmtime(path), timezone.utc).isoformat().replace("+00:00", "Z")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AgentLoader:
|
|
78
|
+
"""Writes other agents' usage through the Claude Loader's tables and helpers."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, loader, log=print):
|
|
81
|
+
self.L = loader
|
|
82
|
+
self.db = loader.db
|
|
83
|
+
self.log = log
|
|
84
|
+
self.counts = {}
|
|
85
|
+
|
|
86
|
+
def run(self):
|
|
87
|
+
found = detect()
|
|
88
|
+
for key, fn in (("codex", self.codex), ("gemini", self.gemini), ("cursor", self.cursor)):
|
|
89
|
+
if not found.get(key):
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
fn()
|
|
93
|
+
except Exception as exc: # one agent's odd data must not break the load
|
|
94
|
+
self.log(f" ! {key}: {exc}")
|
|
95
|
+
return self.counts
|
|
96
|
+
|
|
97
|
+
# ---------- shared writers ----------
|
|
98
|
+
def project(self, agent, cwd, fallback):
|
|
99
|
+
slug = f"{agent}:{cwd or fallback}"
|
|
100
|
+
pid = self.L.project_id(slug, cwd)
|
|
101
|
+
self.db.execute("UPDATE projects SET agent=? WHERE id=?", (agent, pid))
|
|
102
|
+
if not cwd:
|
|
103
|
+
self.db.execute("UPDATE projects SET name=? WHERE id=?", (fallback, pid))
|
|
104
|
+
return pid
|
|
105
|
+
|
|
106
|
+
def session(self, agent, sid, pid, src, title=None, version=None):
|
|
107
|
+
self.db.execute("INSERT OR REPLACE INTO sessions (id, project_id, source_file, title,"
|
|
108
|
+
" cli_version, agent) VALUES (?,?,?,?,?,?)",
|
|
109
|
+
(sid, pid, src, title, version, agent))
|
|
110
|
+
|
|
111
|
+
def prompt(self, agent, sid, pid, ts, text, uid=None):
|
|
112
|
+
prompt_id = self.L.insert_prompt({"timestamp": ts, "uuid": uid, "promptSource": "typed"},
|
|
113
|
+
text, sid, pid)
|
|
114
|
+
self.db.execute("UPDATE prompts SET agent=? WHERE id=?", (agent, prompt_id))
|
|
115
|
+
return prompt_id
|
|
116
|
+
|
|
117
|
+
def request(self, agent, sid, pid, prompt_id, ts, model, inp=0, out=0, cached=0,
|
|
118
|
+
think=0, tools=()):
|
|
119
|
+
p = self.L.pricing
|
|
120
|
+
priced = p.is_known(model)
|
|
121
|
+
cost = p.estimate(model, inp, out, cached) if priced else 0.0
|
|
122
|
+
nc_part, c_part = p.uncached_baseline(model, cached, 0, 0) if priced else (0.0, 0.0)
|
|
123
|
+
t = None
|
|
124
|
+
try:
|
|
125
|
+
t = datetime.fromisoformat((ts or "").replace("Z", "+00:00"))
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
cur = self.db.execute(
|
|
129
|
+
"INSERT INTO requests (session_id, project_id, prompt_id, ts, day, hour, model,"
|
|
130
|
+
" model_known, input_tokens, output_tokens, thinking_tokens, cache_read_tokens,"
|
|
131
|
+
" cache_write_5m, cache_write_1h, cache_write_tokens, billable_tokens,"
|
|
132
|
+
" context_tokens, est_cost_usd, est_cost_no_cache_usd, tool_call_count,"
|
|
133
|
+
" is_sidechain, agent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0,0,0,?,?,?,?,?,0,?)",
|
|
134
|
+
(sid, pid, prompt_id, ts, _day(ts), t.hour if t else None, model,
|
|
135
|
+
1 if priced else 0, inp, out, think, cached, inp + out + cached, inp + cached,
|
|
136
|
+
cost, cost - c_part + nc_part, len(tools), agent))
|
|
137
|
+
rpk = cur.lastrowid
|
|
138
|
+
for name, target in tools:
|
|
139
|
+
self.db.execute("INSERT INTO tool_calls (request_pk, session_id, project_id,"
|
|
140
|
+
" prompt_id, ts, day, name, target, kind, agent)"
|
|
141
|
+
" VALUES (?,?,?,?,?,?,?,?,'builtin',?)",
|
|
142
|
+
(rpk, sid, pid, prompt_id, ts, _day(ts), name,
|
|
143
|
+
(target or "")[:300] or None, agent))
|
|
144
|
+
self.counts[agent] = self.counts.get(agent, 0) + 1
|
|
145
|
+
|
|
146
|
+
# ---------- Codex ----------
|
|
147
|
+
def codex(self):
|
|
148
|
+
files = sorted(glob.glob(os.path.join(HOME, ".codex", "sessions", "**", "*.jsonl"),
|
|
149
|
+
recursive=True))
|
|
150
|
+
for fp in files:
|
|
151
|
+
rows = []
|
|
152
|
+
with open(fp, encoding="utf-8", errors="replace") as fh:
|
|
153
|
+
for line in fh:
|
|
154
|
+
try:
|
|
155
|
+
rows.append(json.loads(line))
|
|
156
|
+
except ValueError:
|
|
157
|
+
continue
|
|
158
|
+
meta = next((r.get("payload") or {} for r in rows if r.get("type") == "session_meta"), {})
|
|
159
|
+
sid = "codex-" + (meta.get("id") or os.path.basename(fp))
|
|
160
|
+
cwd = meta.get("cwd")
|
|
161
|
+
pid = self.project("codex", cwd, "Codex")
|
|
162
|
+
self.session("codex", sid, pid, fp, version=meta.get("cli_version"))
|
|
163
|
+
model, prompt_id, prev_total, tools = "unknown", None, None, []
|
|
164
|
+
for r in rows:
|
|
165
|
+
typ, p = r.get("type"), r.get("payload") or {}
|
|
166
|
+
if typ == "turn_context":
|
|
167
|
+
model = p.get("model") or model
|
|
168
|
+
elif typ == "event_msg" and p.get("type") == "user_message":
|
|
169
|
+
text = (p.get("message") or "").strip()
|
|
170
|
+
if text:
|
|
171
|
+
prompt_id = self.prompt("codex", sid, pid, r.get("timestamp"), text)
|
|
172
|
+
elif typ == "response_item" and p.get("type") in ("function_call", "custom_tool_call"):
|
|
173
|
+
try:
|
|
174
|
+
args = json.loads(p.get("arguments") or "{}")
|
|
175
|
+
except ValueError:
|
|
176
|
+
args = {}
|
|
177
|
+
target = args.get("cmd") or args.get("command") or args.get("path") \
|
|
178
|
+
if isinstance(args, dict) else None
|
|
179
|
+
tools.append((p.get("name"), target if isinstance(target, str) else
|
|
180
|
+
" ".join(target) if isinstance(target, list) else None))
|
|
181
|
+
elif typ == "event_msg" and p.get("type") == "token_count" and p.get("info"):
|
|
182
|
+
tot = (p["info"] or {}).get("total_token_usage") or {}
|
|
183
|
+
key = tuple(sorted(tot.items()))
|
|
184
|
+
if key == prev_total: # Codex repeats the same count; skip duplicates
|
|
185
|
+
continue
|
|
186
|
+
prev_total = key
|
|
187
|
+
last = (p["info"] or {}).get("last_token_usage") or {}
|
|
188
|
+
cached = int(last.get("cached_input_tokens") or 0)
|
|
189
|
+
inp = max(int(last.get("input_tokens") or 0) - cached, 0)
|
|
190
|
+
self.request("codex", sid, pid, prompt_id, r.get("timestamp"), model,
|
|
191
|
+
inp, int(last.get("output_tokens") or 0), cached,
|
|
192
|
+
int(last.get("reasoning_output_tokens") or 0), tools)
|
|
193
|
+
tools = []
|
|
194
|
+
|
|
195
|
+
# ---------- Gemini CLI ----------
|
|
196
|
+
def gemini(self):
|
|
197
|
+
known = {hashlib.sha256(p.encode()).hexdigest(): p for (p,) in
|
|
198
|
+
self.db.execute("SELECT DISTINCT path FROM projects WHERE path IS NOT NULL")}
|
|
199
|
+
for fp in sorted(glob.glob(os.path.join(HOME, ".gemini", "tmp", "*", "chats", "*.json"))):
|
|
200
|
+
with open(fp, encoding="utf-8", errors="replace") as fh:
|
|
201
|
+
d = json.load(fh)
|
|
202
|
+
h = d.get("projectHash") or os.path.basename(os.path.dirname(os.path.dirname(fp)))
|
|
203
|
+
cwd = known.get(h)
|
|
204
|
+
pid = self.project("gemini", cwd, f"Gemini project {h[:8]}")
|
|
205
|
+
sid = "gemini-" + (d.get("sessionId") or os.path.basename(fp))
|
|
206
|
+
self.session("gemini", sid, pid, fp)
|
|
207
|
+
prompt_id = None
|
|
208
|
+
for m in d.get("messages") or []:
|
|
209
|
+
ts = m.get("timestamp")
|
|
210
|
+
if m.get("type") == "user":
|
|
211
|
+
text = m.get("content") if isinstance(m.get("content"), str) else \
|
|
212
|
+
" ".join(x.get("text", "") for x in m.get("content") or [] if isinstance(x, dict))
|
|
213
|
+
if (text or "").strip():
|
|
214
|
+
prompt_id = self.prompt("gemini", sid, pid, ts, text, m.get("id"))
|
|
215
|
+
elif m.get("type") == "gemini":
|
|
216
|
+
t = m.get("tokens") or {}
|
|
217
|
+
cached = int(t.get("cached") or 0)
|
|
218
|
+
tools = [(c.get("name"), json.dumps(c.get("args"))[:300] if c.get("args") else None)
|
|
219
|
+
for c in m.get("toolCalls") or [] if isinstance(c, dict)]
|
|
220
|
+
self.request("gemini", sid, pid, prompt_id, ts, m.get("model") or "gemini",
|
|
221
|
+
max(int(t.get("input") or 0) - cached, 0),
|
|
222
|
+
int(t.get("output") or 0) + int(t.get("thoughts") or 0),
|
|
223
|
+
cached, int(t.get("thoughts") or 0), tools)
|
|
224
|
+
|
|
225
|
+
# ---------- Cursor ----------
|
|
226
|
+
def cursor(self):
|
|
227
|
+
root = os.path.join(HOME, ".cursor", "projects")
|
|
228
|
+
for fp in sorted(glob.glob(os.path.join(root, "*", "agent-transcripts", "*", "*.jsonl"))):
|
|
229
|
+
slug = os.path.relpath(fp, root).split(os.sep)[0]
|
|
230
|
+
# "Users-me-Documents-JIRA" -> best effort real path, else show the slug
|
|
231
|
+
guess = "/" + slug.replace("-", "/")
|
|
232
|
+
cwd = guess if os.path.isdir(guess) else None
|
|
233
|
+
pid = self.project("cursor", cwd, slug.rsplit("-", 1)[-1] or slug)
|
|
234
|
+
sid = "cursor-" + os.path.splitext(os.path.basename(fp))[0]
|
|
235
|
+
ts = _iso_mtime(fp) # transcripts carry no timestamps; use last write
|
|
236
|
+
self.session("cursor", sid, pid, fp)
|
|
237
|
+
prompt_id = None
|
|
238
|
+
with open(fp, encoding="utf-8", errors="replace") as fh:
|
|
239
|
+
for line in fh:
|
|
240
|
+
try:
|
|
241
|
+
o = json.loads(line)
|
|
242
|
+
except ValueError:
|
|
243
|
+
continue
|
|
244
|
+
content = (o.get("message") or {}).get("content") or []
|
|
245
|
+
if o.get("role") == "user":
|
|
246
|
+
text = " ".join(c.get("text", "") for c in content if isinstance(c, dict))
|
|
247
|
+
text = text.replace("<user_query>", "").replace("</user_query>", "").strip()
|
|
248
|
+
if text:
|
|
249
|
+
prompt_id = self.prompt("cursor", sid, pid, ts, text)
|
|
250
|
+
elif o.get("role") == "assistant":
|
|
251
|
+
tools = [(c.get("name"), (c.get("input") or "")[:300]
|
|
252
|
+
if isinstance(c.get("input"), str) else
|
|
253
|
+
json.dumps(c.get("input"))[:300])
|
|
254
|
+
for c in content if isinstance(c, dict) and c.get("type") == "tool_use"]
|
|
255
|
+
self.request("cursor", sid, pid, prompt_id, ts, "cursor", tools=tools)
|
|
256
|
+
self.cursor_ide()
|
|
257
|
+
|
|
258
|
+
def cursor_ide(self):
|
|
259
|
+
"""Cursor IDE chat: token counts where Cursor stored them (read-only, never locks)."""
|
|
260
|
+
db = _cursor_state_db()
|
|
261
|
+
if not os.path.exists(db):
|
|
262
|
+
return
|
|
263
|
+
uri = "file:" + db.replace("\\", "/") + "?immutable=1"
|
|
264
|
+
con = sqlite3.connect(uri, uri=True)
|
|
265
|
+
rows = con.execute("""
|
|
266
|
+
SELECT substr(key, 10, 36) composer, json_extract(value,'$.type') typ,
|
|
267
|
+
json_extract(value,'$.createdAt') ts, json_extract(value,'$.text') text,
|
|
268
|
+
json_extract(value,'$.tokenCount.inputTokens') i,
|
|
269
|
+
json_extract(value,'$.tokenCount.outputTokens') o
|
|
270
|
+
FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'
|
|
271
|
+
AND (json_extract(value,'$.type') = 1
|
|
272
|
+
OR json_extract(value,'$.tokenCount.inputTokens') > 0)""").fetchall()
|
|
273
|
+
comps = {c for c, *_ in rows}
|
|
274
|
+
started = {}
|
|
275
|
+
for c in comps:
|
|
276
|
+
r = con.execute("SELECT json_extract(value,'$.createdAt'), json_extract(value,'$.name')"
|
|
277
|
+
" FROM cursorDiskKV WHERE key=?", ("composerData:" + c,)).fetchone()
|
|
278
|
+
if r:
|
|
279
|
+
started[c] = (_iso_ms(r[0]), r[1])
|
|
280
|
+
con.close()
|
|
281
|
+
pid = self.project("cursor", None, "Cursor IDE chats")
|
|
282
|
+
seen, prompt_for = set(), {}
|
|
283
|
+
for c, typ, ts, text, i, o in rows:
|
|
284
|
+
ts = ts or (started.get(c) or (None,))[0]
|
|
285
|
+
if not ts:
|
|
286
|
+
continue
|
|
287
|
+
sid = "cursor-ide-" + c
|
|
288
|
+
if sid not in seen:
|
|
289
|
+
self.session("cursor", sid, pid, db, title=(started.get(c) or (None, None))[1])
|
|
290
|
+
seen.add(sid)
|
|
291
|
+
if typ == 1:
|
|
292
|
+
if (text or "").strip():
|
|
293
|
+
prompt_for[sid] = self.prompt("cursor", sid, pid, ts, text)
|
|
294
|
+
elif i:
|
|
295
|
+
self.request("cursor", sid, pid, prompt_for.get(sid), ts, "cursor",
|
|
296
|
+
int(i or 0), int(o or 0))
|