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