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/etl.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Parse Claude Code JSONL transcripts into a normalized SQLite warehouse.
|
|
2
|
+
|
|
3
|
+
Entities: account -> billing_period -> project -> session -> prompt -> request
|
|
4
|
+
(UsageEvent) -> tool_call. Nothing is synthesized: fields absent from the source
|
|
5
|
+
transcripts are stored as NULL and rendered as "Unavailable from connected Claude
|
|
6
|
+
data" by the UI.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sqlite3
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
from .classify import classify
|
|
16
|
+
from .pricing import Pricing
|
|
17
|
+
|
|
18
|
+
from .paths import ROOT, DB_PATH
|
|
19
|
+
DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
|
|
20
|
+
|
|
21
|
+
SCHEMA = """
|
|
22
|
+
PRAGMA journal_mode=WAL;
|
|
23
|
+
|
|
24
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE projects (
|
|
27
|
+
id INTEGER PRIMARY KEY,
|
|
28
|
+
slug TEXT UNIQUE, -- transcript directory name
|
|
29
|
+
path TEXT, -- real cwd observed in the transcript
|
|
30
|
+
name TEXT,
|
|
31
|
+
is_sandbox INTEGER DEFAULT 0,
|
|
32
|
+
agent TEXT DEFAULT 'claude'
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE sessions (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
project_id INTEGER REFERENCES projects(id),
|
|
38
|
+
source_file TEXT,
|
|
39
|
+
title TEXT,
|
|
40
|
+
git_branch TEXT,
|
|
41
|
+
cli_version TEXT,
|
|
42
|
+
started_at TEXT, ended_at TEXT, duration_s REAL,
|
|
43
|
+
prompt_count INTEGER DEFAULT 0,
|
|
44
|
+
request_count INTEGER DEFAULT 0,
|
|
45
|
+
tool_call_count INTEGER DEFAULT 0,
|
|
46
|
+
input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0,
|
|
47
|
+
thinking_tokens INTEGER DEFAULT 0,
|
|
48
|
+
cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0,
|
|
49
|
+
billable_tokens INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0,
|
|
50
|
+
est_cost_usd REAL DEFAULT 0,
|
|
51
|
+
max_context_tokens INTEGER DEFAULT 0, avg_context_tokens REAL DEFAULT 0,
|
|
52
|
+
models TEXT, files_touched INTEGER DEFAULT 0, is_sidechain_only INTEGER DEFAULT 0,
|
|
53
|
+
agent TEXT DEFAULT 'claude'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE prompts (
|
|
57
|
+
id INTEGER PRIMARY KEY,
|
|
58
|
+
uuid TEXT, session_id TEXT REFERENCES sessions(id),
|
|
59
|
+
project_id INTEGER REFERENCES projects(id),
|
|
60
|
+
ts TEXT, day TEXT,
|
|
61
|
+
text TEXT, char_len INTEGER, word_len INTEGER,
|
|
62
|
+
category TEXT, category_confidence REAL, category_evidence TEXT,
|
|
63
|
+
source TEXT, -- typed / slash-command / queued ...
|
|
64
|
+
request_count INTEGER DEFAULT 0,
|
|
65
|
+
input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0,
|
|
66
|
+
cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0,
|
|
67
|
+
billable_tokens INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0,
|
|
68
|
+
est_cost_usd REAL DEFAULT 0,
|
|
69
|
+
tool_calls INTEGER DEFAULT 0, files_touched INTEGER DEFAULT 0,
|
|
70
|
+
latency_ms REAL, models TEXT, max_context_tokens INTEGER DEFAULT 0,
|
|
71
|
+
norm_hash TEXT,
|
|
72
|
+
agent TEXT DEFAULT 'claude'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE requests (
|
|
76
|
+
id INTEGER PRIMARY KEY,
|
|
77
|
+
uuid TEXT, request_id TEXT,
|
|
78
|
+
session_id TEXT REFERENCES sessions(id),
|
|
79
|
+
project_id INTEGER REFERENCES projects(id),
|
|
80
|
+
prompt_id INTEGER REFERENCES prompts(id),
|
|
81
|
+
ts TEXT, day TEXT, hour INTEGER,
|
|
82
|
+
model TEXT, model_known INTEGER, effort TEXT, service_tier TEXT,
|
|
83
|
+
stop_reason TEXT,
|
|
84
|
+
input_tokens INTEGER, output_tokens INTEGER, thinking_tokens INTEGER,
|
|
85
|
+
cache_read_tokens INTEGER, cache_write_5m INTEGER, cache_write_1h INTEGER,
|
|
86
|
+
cache_write_tokens INTEGER,
|
|
87
|
+
billable_tokens INTEGER, -- input + output + cache read + cache write
|
|
88
|
+
context_tokens INTEGER, -- input + cache read + cache write (prompt side)
|
|
89
|
+
est_cost_usd REAL,
|
|
90
|
+
est_cost_no_cache_usd REAL,
|
|
91
|
+
latency_ms REAL,
|
|
92
|
+
tool_call_count INTEGER DEFAULT 0,
|
|
93
|
+
is_sidechain INTEGER DEFAULT 0,
|
|
94
|
+
agent_id TEXT, agent_type TEXT, agent_desc TEXT, -- set for subagent requests
|
|
95
|
+
agent TEXT DEFAULT 'claude' -- claude / codex / gemini / cursor
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE tool_calls (
|
|
99
|
+
id INTEGER PRIMARY KEY,
|
|
100
|
+
request_pk INTEGER REFERENCES requests(id),
|
|
101
|
+
session_id TEXT, project_id INTEGER, prompt_id INTEGER,
|
|
102
|
+
ts TEXT, day TEXT, name TEXT, target TEXT,
|
|
103
|
+
tool_use_id TEXT,
|
|
104
|
+
kind TEXT, -- builtin / mcp / connector / skill / agent
|
|
105
|
+
server TEXT, -- MCP server, skill name or subagent type
|
|
106
|
+
result_chars INTEGER DEFAULT 0, -- size of what came back into context
|
|
107
|
+
carry_requests INTEGER DEFAULT 0, -- later requests in the session that re-read it
|
|
108
|
+
agent TEXT DEFAULT 'claude'
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE files_touched (
|
|
112
|
+
id INTEGER PRIMARY KEY,
|
|
113
|
+
session_id TEXT, project_id INTEGER, prompt_id INTEGER,
|
|
114
|
+
path TEXT, op TEXT, ts TEXT
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
CREATE INDEX idx_req_day ON requests(day);
|
|
118
|
+
CREATE INDEX idx_req_model ON requests(model);
|
|
119
|
+
CREATE INDEX idx_req_sess ON requests(session_id);
|
|
120
|
+
CREATE INDEX idx_req_proj ON requests(project_id);
|
|
121
|
+
CREATE INDEX idx_req_prompt ON requests(prompt_id);
|
|
122
|
+
CREATE INDEX idx_prompt_day ON prompts(day);
|
|
123
|
+
CREATE INDEX idx_prompt_cat ON prompts(category);
|
|
124
|
+
CREATE INDEX idx_prompt_sess ON prompts(session_id);
|
|
125
|
+
CREATE INDEX idx_tool_name ON tool_calls(name);
|
|
126
|
+
CREATE INDEX idx_tool_use ON tool_calls(tool_use_id);
|
|
127
|
+
CREATE INDEX idx_req_sess_ts ON requests(session_id, ts);
|
|
128
|
+
CREATE INDEX idx_sess_proj ON sessions(project_id);
|
|
129
|
+
CREATE INDEX idx_req_agent ON requests(agent);
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
FILE_TOOLS = {"Edit": "edit", "Write": "write", "Read": "read", "NotebookEdit": "edit"}
|
|
133
|
+
SLASH = re.compile(r"^\s*/([a-z0-9][\w:-]*)(?=\s|$)", re.I)
|
|
134
|
+
CMD_NAME = re.compile(r"<command-name>/?([\w:-]+)</command-name>")
|
|
135
|
+
WS = re.compile(r"\s+")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _ts(s):
|
|
139
|
+
if not s:
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
143
|
+
except ValueError:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _text_of(content):
|
|
148
|
+
"""Flatten a message content payload to plain text."""
|
|
149
|
+
if content is None:
|
|
150
|
+
return ""
|
|
151
|
+
if isinstance(content, str):
|
|
152
|
+
return content
|
|
153
|
+
out = []
|
|
154
|
+
for c in content:
|
|
155
|
+
if isinstance(c, str):
|
|
156
|
+
out.append(c)
|
|
157
|
+
elif isinstance(c, dict):
|
|
158
|
+
if c.get("type") == "text":
|
|
159
|
+
out.append(c.get("text", ""))
|
|
160
|
+
elif c.get("type") == "thinking":
|
|
161
|
+
continue
|
|
162
|
+
return "\n".join(x for x in out if x)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
IMAGE_CHARS = 1600 * 4 # an image costs ~1.6K tokens, not its base64 length
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _result_chars(body):
|
|
169
|
+
n = 0
|
|
170
|
+
for c in body or []:
|
|
171
|
+
if isinstance(c, dict) and c.get("type") == "image":
|
|
172
|
+
n += IMAGE_CHARS
|
|
173
|
+
elif isinstance(c, dict):
|
|
174
|
+
n += len(c.get("text") or "")
|
|
175
|
+
elif isinstance(c, str):
|
|
176
|
+
n += len(c)
|
|
177
|
+
return n
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _slug_to_name(slug):
|
|
181
|
+
p = slug.replace("-", "/")
|
|
182
|
+
return os.path.basename(p.rstrip("/")) or slug
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Loader:
|
|
186
|
+
def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True):
|
|
187
|
+
self.other_agents = other_agents
|
|
188
|
+
self.db_path = db_path
|
|
189
|
+
self.source = source
|
|
190
|
+
self.pricing = pricing or Pricing()
|
|
191
|
+
self.projects = {}
|
|
192
|
+
self.agent = None
|
|
193
|
+
self.pending_skill = None
|
|
194
|
+
|
|
195
|
+
# ---------- infrastructure ----------
|
|
196
|
+
def build(self, verbose=True):
|
|
197
|
+
if os.path.exists(self.db_path):
|
|
198
|
+
os.remove(self.db_path)
|
|
199
|
+
for suffix in ("-wal", "-shm"):
|
|
200
|
+
p = self.db_path + suffix
|
|
201
|
+
if os.path.exists(p):
|
|
202
|
+
os.remove(p)
|
|
203
|
+
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
|
204
|
+
self.db = sqlite3.connect(self.db_path)
|
|
205
|
+
self.db.executescript(SCHEMA)
|
|
206
|
+
files = []
|
|
207
|
+
for dirpath, _, names in os.walk(self.source):
|
|
208
|
+
for n in names:
|
|
209
|
+
if n.endswith(".jsonl"):
|
|
210
|
+
files.append(os.path.join(dirpath, n))
|
|
211
|
+
files.sort()
|
|
212
|
+
for i, fp in enumerate(files, 1):
|
|
213
|
+
if verbose and i % 20 == 0:
|
|
214
|
+
print(f" ...{i}/{len(files)} transcripts", file=sys.stderr)
|
|
215
|
+
try:
|
|
216
|
+
self.load_file(fp)
|
|
217
|
+
except Exception as exc: # a corrupt transcript must not kill the load
|
|
218
|
+
print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
|
|
219
|
+
if self.other_agents:
|
|
220
|
+
from .agents import AgentLoader
|
|
221
|
+
counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
|
|
222
|
+
if verbose and counts:
|
|
223
|
+
print(" other agents: " + ", ".join(f"{k} {v:,} requests" for k, v in counts.items()),
|
|
224
|
+
file=sys.stderr)
|
|
225
|
+
self.rollup()
|
|
226
|
+
self.db.execute(
|
|
227
|
+
"INSERT INTO meta VALUES (?,?)",
|
|
228
|
+
("built_at", datetime.now(timezone.utc).isoformat()),
|
|
229
|
+
)
|
|
230
|
+
for k, v in (
|
|
231
|
+
("source_dir", self.source),
|
|
232
|
+
("transcript_files", str(len(files))),
|
|
233
|
+
("pricing_updated", str(self.pricing.updated)),
|
|
234
|
+
("pricing_source", str(self.pricing.source)),
|
|
235
|
+
("cost_basis", "estimated"),
|
|
236
|
+
):
|
|
237
|
+
self.db.execute("INSERT INTO meta VALUES (?,?)", (k, v))
|
|
238
|
+
self.db.commit()
|
|
239
|
+
return len(files)
|
|
240
|
+
|
|
241
|
+
def project_id(self, slug, cwd):
|
|
242
|
+
if slug in self.projects:
|
|
243
|
+
pid = self.projects[slug]
|
|
244
|
+
if cwd:
|
|
245
|
+
self.db.execute(
|
|
246
|
+
"UPDATE projects SET path=COALESCE(path,?) WHERE id=?", (cwd, pid))
|
|
247
|
+
return pid
|
|
248
|
+
is_sandbox = 1 if ("sandbox" in slug or slug.startswith("-private")) else 0
|
|
249
|
+
name = os.path.basename(cwd) if cwd else _slug_to_name(slug)
|
|
250
|
+
cur = self.db.execute(
|
|
251
|
+
"INSERT INTO projects (slug, path, name, is_sandbox) VALUES (?,?,?,?)",
|
|
252
|
+
(slug, cwd, name or slug, is_sandbox))
|
|
253
|
+
self.projects[slug] = cur.lastrowid
|
|
254
|
+
return cur.lastrowid
|
|
255
|
+
|
|
256
|
+
# ---------- per-transcript ----------
|
|
257
|
+
def load_file(self, path):
|
|
258
|
+
slug = os.path.basename(os.path.dirname(path))
|
|
259
|
+
agent = None
|
|
260
|
+
if slug == "subagents":
|
|
261
|
+
# <project>/<session>/subagents/agent-<id>.jsonl belongs to <session>
|
|
262
|
+
sess_dir = os.path.dirname(os.path.dirname(path))
|
|
263
|
+
slug = os.path.basename(os.path.dirname(sess_dir))
|
|
264
|
+
meta = {}
|
|
265
|
+
try:
|
|
266
|
+
with open(os.path.splitext(path)[0] + ".meta.json") as fh:
|
|
267
|
+
meta = json.load(fh)
|
|
268
|
+
except (OSError, ValueError):
|
|
269
|
+
pass
|
|
270
|
+
agent = {"id": os.path.splitext(os.path.basename(path))[0],
|
|
271
|
+
"parent": os.path.basename(sess_dir),
|
|
272
|
+
"type": meta.get("agentType") or "unknown",
|
|
273
|
+
"desc": meta.get("description")}
|
|
274
|
+
rows = []
|
|
275
|
+
with open(path, errors="replace") as fh:
|
|
276
|
+
for line in fh:
|
|
277
|
+
line = line.strip()
|
|
278
|
+
if not line:
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
rows.append(json.loads(line))
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
continue
|
|
284
|
+
if not rows:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
session_id = agent["parent"] if agent else os.path.splitext(os.path.basename(path))[0]
|
|
288
|
+
self.agent = agent
|
|
289
|
+
self.pending_skill = None
|
|
290
|
+
cwd = next((r.get("cwd") for r in rows if r.get("cwd")), None)
|
|
291
|
+
pid = self.project_id(slug, cwd)
|
|
292
|
+
title = next((r.get("aiTitle") for r in rows if r.get("type") == "ai-title"), None)
|
|
293
|
+
branch = next((r.get("gitBranch") for r in rows if r.get("gitBranch")), None)
|
|
294
|
+
version = next((r.get("version") for r in rows if r.get("version")), None)
|
|
295
|
+
|
|
296
|
+
self.db.execute(
|
|
297
|
+
("INSERT OR IGNORE" if agent else "INSERT OR REPLACE") +
|
|
298
|
+
" INTO sessions (id, project_id, source_file, title, "
|
|
299
|
+
"git_branch, cli_version) VALUES (?,?,?,?,?,?)",
|
|
300
|
+
(session_id, pid, path, title, branch, version))
|
|
301
|
+
|
|
302
|
+
cur_prompt = None
|
|
303
|
+
prev_time = None
|
|
304
|
+
|
|
305
|
+
for r in rows:
|
|
306
|
+
typ = r.get("type")
|
|
307
|
+
ts = r.get("timestamp")
|
|
308
|
+
t = _ts(ts)
|
|
309
|
+
|
|
310
|
+
if typ == "user":
|
|
311
|
+
msg = r.get("message") or {}
|
|
312
|
+
self.record_results(r, msg)
|
|
313
|
+
# tool results and meta lines are not human prompts
|
|
314
|
+
if r.get("toolUseResult") is not None or r.get("isMeta"):
|
|
315
|
+
prev_time = t or prev_time
|
|
316
|
+
continue
|
|
317
|
+
text = _text_of(msg.get("content"))
|
|
318
|
+
if agent:
|
|
319
|
+
prev_time = t or prev_time
|
|
320
|
+
continue
|
|
321
|
+
if not text.strip():
|
|
322
|
+
prev_time = t or prev_time
|
|
323
|
+
continue
|
|
324
|
+
cur_prompt = self.insert_prompt(r, text, session_id, pid)
|
|
325
|
+
prev_time = t or prev_time
|
|
326
|
+
|
|
327
|
+
elif typ == "assistant":
|
|
328
|
+
if agent and cur_prompt is None:
|
|
329
|
+
cur_prompt = self.parent_prompt(session_id, r.get("timestamp"))
|
|
330
|
+
self.insert_request(r, session_id, pid, cur_prompt, prev_time)
|
|
331
|
+
prev_time = t or prev_time
|
|
332
|
+
|
|
333
|
+
elif typ in ("attachment", "system"):
|
|
334
|
+
prev_time = t or prev_time
|
|
335
|
+
|
|
336
|
+
def parent_prompt(self, session_id, ts):
|
|
337
|
+
row = self.db.execute("SELECT id FROM prompts WHERE session_id=? AND ts<=? "
|
|
338
|
+
"ORDER BY ts DESC LIMIT 1", (session_id, ts or "")).fetchone()
|
|
339
|
+
return row[0] if row else None
|
|
340
|
+
|
|
341
|
+
def record_results(self, r, msg):
|
|
342
|
+
"""Size of tool results, and of skill bodies injected as meta messages."""
|
|
343
|
+
content = msg.get("content")
|
|
344
|
+
if r.get("isMeta") and getattr(self, "pending_skill", None):
|
|
345
|
+
self.db.execute("UPDATE tool_calls SET result_chars=result_chars+? WHERE id=?",
|
|
346
|
+
(len(_text_of(content)), self.pending_skill))
|
|
347
|
+
self.pending_skill = None
|
|
348
|
+
return
|
|
349
|
+
if not isinstance(content, list):
|
|
350
|
+
return
|
|
351
|
+
for c in content:
|
|
352
|
+
if isinstance(c, dict) and c.get("type") == "tool_result":
|
|
353
|
+
body = c.get("content")
|
|
354
|
+
n = len(body) if isinstance(body, str) else _result_chars(body)
|
|
355
|
+
self.db.execute("UPDATE tool_calls SET result_chars=? WHERE tool_use_id=?",
|
|
356
|
+
(n, c.get("tool_use_id")))
|
|
357
|
+
|
|
358
|
+
def insert_prompt(self, r, text, session_id, pid):
|
|
359
|
+
cat, conf, ev = classify(text)
|
|
360
|
+
src = r.get("promptSource") or r.get("origin")
|
|
361
|
+
m = CMD_NAME.search(text) or SLASH.match(text)
|
|
362
|
+
if m:
|
|
363
|
+
src = f"slash:/{m.group(1)}"
|
|
364
|
+
cat = cat if cat != "other" else "automation"
|
|
365
|
+
ts = r.get("timestamp")
|
|
366
|
+
norm = WS.sub(" ", text.strip().lower())[:500]
|
|
367
|
+
cur = self.db.execute(
|
|
368
|
+
"INSERT INTO prompts (uuid, session_id, project_id, ts, day, text, char_len,"
|
|
369
|
+
" word_len, category, category_confidence, category_evidence, source, norm_hash)"
|
|
370
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
371
|
+
(r.get("uuid"), session_id, pid, ts, (ts or "")[:10], text, len(text),
|
|
372
|
+
len(text.split()), cat, conf, json.dumps(ev), src, str(hash(norm))))
|
|
373
|
+
return cur.lastrowid
|
|
374
|
+
|
|
375
|
+
def insert_request(self, r, session_id, pid, prompt_id, prev_time):
|
|
376
|
+
msg = r.get("message") or {}
|
|
377
|
+
u = msg.get("usage") or {}
|
|
378
|
+
model = msg.get("model") or "unknown"
|
|
379
|
+
inp = int(u.get("input_tokens") or 0)
|
|
380
|
+
out = int(u.get("output_tokens") or 0)
|
|
381
|
+
think = int((u.get("output_tokens_details") or {}).get("thinking_tokens") or 0)
|
|
382
|
+
cr = int(u.get("cache_read_input_tokens") or 0)
|
|
383
|
+
cc = u.get("cache_creation") or {}
|
|
384
|
+
c5 = int(cc.get("ephemeral_5m_input_tokens") or 0)
|
|
385
|
+
c1 = int(cc.get("ephemeral_1h_input_tokens") or 0)
|
|
386
|
+
cw = int(u.get("cache_creation_input_tokens") or (c5 + c1))
|
|
387
|
+
if c5 + c1 == 0 and cw: # older transcripts omit the breakdown
|
|
388
|
+
c5 = cw
|
|
389
|
+
billable = inp + out + cr + cw
|
|
390
|
+
context = inp + cr + cw
|
|
391
|
+
|
|
392
|
+
cost = self.pricing.estimate(model, inp, out, cr, c5, c1)
|
|
393
|
+
no_cache_part, cache_part = self.pricing.uncached_baseline(model, cr, c5, c1)
|
|
394
|
+
cost_no_cache = cost - cache_part + no_cache_part
|
|
395
|
+
|
|
396
|
+
ts = r.get("timestamp")
|
|
397
|
+
t = _ts(ts)
|
|
398
|
+
latency = None
|
|
399
|
+
if t and prev_time:
|
|
400
|
+
d = (t - prev_time).total_seconds() * 1000.0
|
|
401
|
+
if 0 <= d <= 900_000: # ignore idle gaps > 15 min, they are not latency
|
|
402
|
+
latency = d
|
|
403
|
+
|
|
404
|
+
tools = [c for c in (msg.get("content") or [])
|
|
405
|
+
if isinstance(c, dict) and c.get("type") == "tool_use"]
|
|
406
|
+
|
|
407
|
+
cur = self.db.execute(
|
|
408
|
+
"INSERT INTO requests (uuid, request_id, session_id, project_id, prompt_id,"
|
|
409
|
+
" ts, day, hour, model, model_known, effort, service_tier, stop_reason,"
|
|
410
|
+
" input_tokens, output_tokens, thinking_tokens, cache_read_tokens,"
|
|
411
|
+
" cache_write_5m, cache_write_1h, cache_write_tokens, billable_tokens,"
|
|
412
|
+
" context_tokens, est_cost_usd, est_cost_no_cache_usd, latency_ms,"
|
|
413
|
+
" tool_call_count, is_sidechain, agent_id, agent_type, agent_desc)"
|
|
414
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
415
|
+
(r.get("uuid"), r.get("requestId"), session_id, pid, prompt_id, ts,
|
|
416
|
+
(ts or "")[:10], (t.hour if t else None), model,
|
|
417
|
+
1 if self.pricing.is_known(model) else 0, r.get("effort"),
|
|
418
|
+
u.get("service_tier"), msg.get("stop_reason"), inp, out, think, cr,
|
|
419
|
+
c5, c1, cw, billable, context, cost, cost_no_cache, latency,
|
|
420
|
+
len(tools), 1 if (r.get("isSidechain") or self.agent) else 0,
|
|
421
|
+
*((self.agent["id"], self.agent["type"], self.agent["desc"]) if self.agent
|
|
422
|
+
else (None, "inline" if r.get("isSidechain") else None, None))))
|
|
423
|
+
rpk = cur.lastrowid
|
|
424
|
+
|
|
425
|
+
day = (ts or "")[:10]
|
|
426
|
+
for c in tools:
|
|
427
|
+
name = c.get("name")
|
|
428
|
+
args = c.get("input") or {}
|
|
429
|
+
target = None
|
|
430
|
+
kind, server = "builtin", None
|
|
431
|
+
if isinstance(name, str) and name.startswith("mcp__"):
|
|
432
|
+
server = name.split("__")[1]
|
|
433
|
+
kind = "connector" if server.startswith("claude_ai_") else "mcp"
|
|
434
|
+
elif name == "Skill" and isinstance(args, dict):
|
|
435
|
+
kind, server = "skill", args.get("skill")
|
|
436
|
+
elif name in ("Agent", "Task") and isinstance(args, dict):
|
|
437
|
+
kind, server = "agent", args.get("subagent_type") or "general-purpose"
|
|
438
|
+
if isinstance(args, dict):
|
|
439
|
+
target = (args.get("file_path") or args.get("path")
|
|
440
|
+
or args.get("pattern") or args.get("command")
|
|
441
|
+
or args.get("description"))
|
|
442
|
+
if isinstance(target, str):
|
|
443
|
+
target = target[:300]
|
|
444
|
+
else:
|
|
445
|
+
target = None
|
|
446
|
+
self.db.execute(
|
|
447
|
+
"INSERT INTO tool_calls (request_pk, session_id, project_id, prompt_id,"
|
|
448
|
+
" ts, day, name, target, tool_use_id, kind, server)"
|
|
449
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
450
|
+
(rpk, session_id, pid, prompt_id, ts, day, name, target, c.get("id"),
|
|
451
|
+
kind, server))
|
|
452
|
+
if kind == "skill":
|
|
453
|
+
self.pending_skill = self.db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
454
|
+
if name in FILE_TOOLS and isinstance(args, dict) and args.get("file_path"):
|
|
455
|
+
self.db.execute(
|
|
456
|
+
"INSERT INTO files_touched (session_id, project_id, prompt_id, path,"
|
|
457
|
+
" op, ts) VALUES (?,?,?,?,?,?)",
|
|
458
|
+
(session_id, pid, prompt_id, args["file_path"], FILE_TOOLS[name], ts))
|
|
459
|
+
|
|
460
|
+
# ---------- aggregates ----------
|
|
461
|
+
def rollup(self):
|
|
462
|
+
d = self.db
|
|
463
|
+
d.execute("""
|
|
464
|
+
UPDATE prompts SET
|
|
465
|
+
request_count = (SELECT COUNT(*) FROM requests r WHERE r.prompt_id=prompts.id),
|
|
466
|
+
input_tokens = COALESCE((SELECT SUM(input_tokens) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
467
|
+
output_tokens = COALESCE((SELECT SUM(output_tokens) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
468
|
+
cache_read_tokens = COALESCE((SELECT SUM(cache_read_tokens) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
469
|
+
cache_write_tokens = COALESCE((SELECT SUM(cache_write_tokens) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
470
|
+
billable_tokens = COALESCE((SELECT SUM(billable_tokens) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
471
|
+
est_cost_usd = COALESCE((SELECT SUM(est_cost_usd) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
472
|
+
tool_calls = COALESCE((SELECT SUM(tool_call_count) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
473
|
+
latency_ms = (SELECT AVG(latency_ms) FROM requests r WHERE r.prompt_id=prompts.id),
|
|
474
|
+
max_context_tokens = COALESCE((SELECT MAX(context_tokens) FROM requests r WHERE r.prompt_id=prompts.id),0),
|
|
475
|
+
models = (SELECT GROUP_CONCAT(DISTINCT r.model) FROM requests r WHERE r.prompt_id=prompts.id),
|
|
476
|
+
files_touched = COALESCE((SELECT COUNT(DISTINCT path) FROM files_touched f WHERE f.prompt_id=prompts.id),0)
|
|
477
|
+
""")
|
|
478
|
+
d.execute("UPDATE prompts SET total_tokens = billable_tokens")
|
|
479
|
+
# how many later main-thread requests re-read each tool result (upper bound:
|
|
480
|
+
# /compact and /clear are not visible, so this over-counts after a compaction)
|
|
481
|
+
d.execute("""
|
|
482
|
+
UPDATE tool_calls SET carry_requests = (
|
|
483
|
+
SELECT COUNT(*) FROM requests r WHERE r.session_id=tool_calls.session_id
|
|
484
|
+
AND r.ts > tool_calls.ts AND r.is_sidechain=0
|
|
485
|
+
AND (SELECT is_sidechain FROM requests q WHERE q.id=tool_calls.request_pk)=0)
|
|
486
|
+
""")
|
|
487
|
+
d.execute("""
|
|
488
|
+
UPDATE sessions SET
|
|
489
|
+
prompt_count = COALESCE((SELECT COUNT(*) FROM prompts p WHERE p.session_id=sessions.id),0),
|
|
490
|
+
request_count = COALESCE((SELECT COUNT(*) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
491
|
+
tool_call_count = COALESCE((SELECT COUNT(*) FROM tool_calls t WHERE t.session_id=sessions.id),0),
|
|
492
|
+
input_tokens = COALESCE((SELECT SUM(input_tokens) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
493
|
+
output_tokens = COALESCE((SELECT SUM(output_tokens) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
494
|
+
thinking_tokens = COALESCE((SELECT SUM(thinking_tokens) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
495
|
+
cache_read_tokens = COALESCE((SELECT SUM(cache_read_tokens) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
496
|
+
cache_write_tokens = COALESCE((SELECT SUM(cache_write_tokens) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
497
|
+
billable_tokens = COALESCE((SELECT SUM(billable_tokens) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
498
|
+
est_cost_usd = COALESCE((SELECT SUM(est_cost_usd) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
499
|
+
max_context_tokens = COALESCE((SELECT MAX(context_tokens) FROM requests r WHERE r.session_id=sessions.id),0),
|
|
500
|
+
avg_context_tokens = (SELECT AVG(context_tokens) FROM requests r WHERE r.session_id=sessions.id),
|
|
501
|
+
models = (SELECT GROUP_CONCAT(DISTINCT r.model) FROM requests r WHERE r.session_id=sessions.id),
|
|
502
|
+
files_touched = COALESCE((SELECT COUNT(DISTINCT path) FROM files_touched f WHERE f.session_id=sessions.id),0),
|
|
503
|
+
started_at = (SELECT MIN(ts) FROM requests r WHERE r.session_id=sessions.id),
|
|
504
|
+
ended_at = (SELECT MAX(ts) FROM requests r WHERE r.session_id=sessions.id)
|
|
505
|
+
""")
|
|
506
|
+
d.execute("UPDATE sessions SET total_tokens = billable_tokens")
|
|
507
|
+
d.execute("""
|
|
508
|
+
UPDATE sessions SET duration_s =
|
|
509
|
+
(julianday(ended_at) - julianday(started_at)) * 86400.0
|
|
510
|
+
WHERE started_at IS NOT NULL AND ended_at IS NOT NULL
|
|
511
|
+
""")
|
|
512
|
+
d.execute("DELETE FROM sessions WHERE request_count = 0 AND prompt_count = 0")
|
|
513
|
+
d.commit()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def main():
|
|
517
|
+
src = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_SOURCE
|
|
518
|
+
print(f"Loading Claude transcripts from {src}", file=sys.stderr)
|
|
519
|
+
n = Loader(source=src).build()
|
|
520
|
+
con = sqlite3.connect(DB_PATH)
|
|
521
|
+
q = lambda s: con.execute(s).fetchone()[0]
|
|
522
|
+
print(f"\nLoaded {n} transcripts -> {DB_PATH}")
|
|
523
|
+
print(f" projects {q('SELECT COUNT(*) FROM projects')}")
|
|
524
|
+
print(f" sessions {q('SELECT COUNT(*) FROM sessions')}")
|
|
525
|
+
print(f" prompts {q('SELECT COUNT(*) FROM prompts')}")
|
|
526
|
+
print(f" requests {q('SELECT COUNT(*) FROM requests')}")
|
|
527
|
+
print(f" tools {q('SELECT COUNT(*) FROM tool_calls')}")
|
|
528
|
+
print(f" tokens {q('SELECT SUM(billable_tokens) FROM requests'):,}")
|
|
529
|
+
print(f" est cost ${q('SELECT SUM(est_cost_usd) FROM requests'):,.2f} (ESTIMATED)")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
if __name__ == "__main__":
|
|
533
|
+
main()
|
package/finops/paths.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Where the app reads code from, and where it writes your data to.
|
|
2
|
+
|
|
3
|
+
Everything the app *writes* lives under one state directory, outside the
|
|
4
|
+
install tree:
|
|
5
|
+
|
|
6
|
+
$CLAUDE_FINOPS_HOME if set, else ~/.claude-finops
|
|
7
|
+
|
|
8
|
+
That separation is what lets the app ship as a frozen binary or an npm/pipx
|
|
9
|
+
package: those install directories are cache-managed and read-only, and get
|
|
10
|
+
replaced wholesale on upgrade. Keeping the warehouse and your local settings
|
|
11
|
+
out of them means an upgrade can never take your data with it.
|
|
12
|
+
|
|
13
|
+
Anything the app only *reads* (config defaults, web assets) stays in the
|
|
14
|
+
install tree next to the code.
|
|
15
|
+
|
|
16
|
+
On first run, an older in-tree layout (ROOT/data, ROOT/config/*.local.json)
|
|
17
|
+
is copied across by migrate(). It is a copy: the originals are left exactly
|
|
18
|
+
where they are, so a half-finished migration can never lose anything.
|
|
19
|
+
"""
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import sqlite3
|
|
23
|
+
|
|
24
|
+
# Install tree: read-only at runtime (code, config defaults, web/).
|
|
25
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
CONFIG_DIR = os.path.join(ROOT, "config")
|
|
27
|
+
WEB_DIR = os.path.join(ROOT, "web")
|
|
28
|
+
|
|
29
|
+
# The legacy in-tree locations we migrate away from.
|
|
30
|
+
LEGACY_DATA_DIR = os.path.join(ROOT, "data")
|
|
31
|
+
|
|
32
|
+
# State tree: everything we write.
|
|
33
|
+
_EXPLICIT_HOME = bool(os.environ.get("CLAUDE_FINOPS_HOME"))
|
|
34
|
+
HOME_DIR = os.environ.get("CLAUDE_FINOPS_HOME") or os.path.join(
|
|
35
|
+
os.path.expanduser("~"), ".claude-finops")
|
|
36
|
+
DATA_DIR = os.path.join(HOME_DIR, "data")
|
|
37
|
+
|
|
38
|
+
DB_PATH = os.path.join(DATA_DIR, "finops.db")
|
|
39
|
+
CACHE_PATH = os.path.join(DATA_DIR, "cloud_cache.json")
|
|
40
|
+
PIDFILE = os.path.join(DATA_DIR, "server.pid")
|
|
41
|
+
LOGFILE = os.path.join(DATA_DIR, "server.log")
|
|
42
|
+
|
|
43
|
+
# Read-only shared defaults stay in the install tree; per-machine overrides
|
|
44
|
+
# (written by the Settings screen) move to the state tree.
|
|
45
|
+
SETTINGS_PATH = os.path.join(CONFIG_DIR, "settings.json")
|
|
46
|
+
PRICING_PATH = os.path.join(CONFIG_DIR, "pricing.json")
|
|
47
|
+
LOCAL_SETTINGS_PATH = os.path.join(HOME_DIR, "settings.local.json")
|
|
48
|
+
SECRETS_PATH = os.path.join(HOME_DIR, "secrets.local.json")
|
|
49
|
+
|
|
50
|
+
_MARKER = os.path.join(HOME_DIR, ".migrated")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_dirs():
|
|
54
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _copy_db(src, dst):
|
|
58
|
+
"""Copy a SQLite database safely, even if a server is holding it open.
|
|
59
|
+
|
|
60
|
+
The backup API resolves the WAL for us; a plain file copy of a live
|
|
61
|
+
database can capture a torn snapshot with its -wal left behind.
|
|
62
|
+
"""
|
|
63
|
+
src_con = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
|
|
64
|
+
try:
|
|
65
|
+
dst_con = sqlite3.connect(dst)
|
|
66
|
+
try:
|
|
67
|
+
src_con.backup(dst_con)
|
|
68
|
+
finally:
|
|
69
|
+
dst_con.close()
|
|
70
|
+
finally:
|
|
71
|
+
src_con.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def migrate(log=print):
|
|
75
|
+
"""Copy an older in-tree layout into the state tree. Never moves, never
|
|
76
|
+
deletes, and never overwrites something already migrated."""
|
|
77
|
+
ensure_dirs()
|
|
78
|
+
if os.path.exists(_MARKER):
|
|
79
|
+
return False
|
|
80
|
+
if _EXPLICIT_HOME:
|
|
81
|
+
# An explicit CLAUDE_FINOPS_HOME means a deliberately separate profile
|
|
82
|
+
# (a demo, a test, a second account). Copying the default profile's
|
|
83
|
+
# database and API keys into it would be the opposite of what was asked.
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
moved = []
|
|
87
|
+
|
|
88
|
+
legacy_db = os.path.join(LEGACY_DATA_DIR, "finops.db")
|
|
89
|
+
if os.path.exists(legacy_db) and not os.path.exists(DB_PATH):
|
|
90
|
+
_copy_db(legacy_db, DB_PATH)
|
|
91
|
+
moved.append("data/finops.db")
|
|
92
|
+
|
|
93
|
+
legacy_cache = os.path.join(LEGACY_DATA_DIR, "cloud_cache.json")
|
|
94
|
+
if os.path.exists(legacy_cache) and not os.path.exists(CACHE_PATH):
|
|
95
|
+
shutil.copy2(legacy_cache, CACHE_PATH)
|
|
96
|
+
moved.append("data/cloud_cache.json")
|
|
97
|
+
|
|
98
|
+
for name, dst in (("settings.local.json", LOCAL_SETTINGS_PATH),
|
|
99
|
+
("secrets.local.json", SECRETS_PATH)):
|
|
100
|
+
legacy = os.path.join(CONFIG_DIR, name)
|
|
101
|
+
if os.path.exists(legacy) and not os.path.exists(dst):
|
|
102
|
+
shutil.copy2(legacy, dst)
|
|
103
|
+
os.chmod(dst, 0o600)
|
|
104
|
+
moved.append(f"config/{name}")
|
|
105
|
+
|
|
106
|
+
if moved:
|
|
107
|
+
log(f"Moved your data to {HOME_DIR}: {', '.join(moved)}")
|
|
108
|
+
log("The originals were left untouched; delete them once you are happy.")
|
|
109
|
+
with open(_MARKER, "w") as fh:
|
|
110
|
+
fh.write("state dir in use; delete this file to re-run migration\n")
|
|
111
|
+
return bool(moved)
|