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/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))