sessioncost 0.2.0__py3-none-any.whl
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.
- sessioncost/__init__.py +2 -0
- sessioncost/__main__.py +5 -0
- sessioncost/analyse.py +385 -0
- sessioncost/cli.py +199 -0
- sessioncost/fixes.py +182 -0
- sessioncost/locate.py +178 -0
- sessioncost/model.py +175 -0
- sessioncost/prices.json +36 -0
- sessioncost/prices.py +72 -0
- sessioncost/readers/__init__.py +40 -0
- sessioncost/readers/_pb.py +75 -0
- sessioncost/readers/antigravity.py +266 -0
- sessioncost/readers/claude.py +523 -0
- sessioncost/readers/codex.py +429 -0
- sessioncost/readers/cursor.py +512 -0
- sessioncost/report.py +15 -0
- sessioncost/report_template.html +915 -0
- sessioncost/skill/SKILL.md +39 -0
- sessioncost-0.2.0.dist-info/METADATA +167 -0
- sessioncost-0.2.0.dist-info/RECORD +24 -0
- sessioncost-0.2.0.dist-info/WHEEL +5 -0
- sessioncost-0.2.0.dist-info/entry_points.txt +2 -0
- sessioncost-0.2.0.dist-info/licenses/LICENSE +21 -0
- sessioncost-0.2.0.dist-info/top_level.txt +1 -0
sessioncost/__init__.py
ADDED
sessioncost/__main__.py
ADDED
sessioncost/analyse.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""From a Session to the full report model: exact cost per call, the drill-down parts (which always add up to their
|
|
2
|
+
parent), the turn rows, where the cost went, the fixed setup, timing and the fixes.
|
|
3
|
+
|
|
4
|
+
Token sizes that the log does not give directly (how much of a call's input was the fixed setup, how much of its
|
|
5
|
+
output was a file vs a message) are estimated as characters / 4 and then scaled so the parts add up exactly to the
|
|
6
|
+
token counts the log does give. Those parts are marked `estimated`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from . import fixes as fixmod
|
|
15
|
+
from . import prices as pricemod
|
|
16
|
+
from .model import Call, Session
|
|
17
|
+
|
|
18
|
+
CATEGORIES = [
|
|
19
|
+
("start", "Start (first call)"),
|
|
20
|
+
("questions", "Asking you questions"),
|
|
21
|
+
("talk", "Talking to you"),
|
|
22
|
+
("reading", "Reading and searching"),
|
|
23
|
+
("commands", "Running commands"),
|
|
24
|
+
("writing", "Writing files"),
|
|
25
|
+
("research", "Research and helpers"),
|
|
26
|
+
("other", "Other steps"),
|
|
27
|
+
("closing", "Closing message"),
|
|
28
|
+
]
|
|
29
|
+
CLASS_LABELS = [
|
|
30
|
+
("input", "New input, not cached"),
|
|
31
|
+
("cache_write_5m", "New input, cached for 5 minutes"),
|
|
32
|
+
("cache_write_1h", "New input, cached for 1 hour"),
|
|
33
|
+
("cache_read", "Re-read from cache"),
|
|
34
|
+
("output", "Written by the model"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tok(chars: int) -> int:
|
|
39
|
+
return max(0, int(chars) // 4)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def split_exact(estimates: list[tuple[str, int]], total: int) -> list[tuple[str, int]]:
|
|
43
|
+
"""Scale estimated sizes so they add up exactly to `total` (largest-remainder rounding)."""
|
|
44
|
+
s = sum(v for _, v in estimates)
|
|
45
|
+
if total <= 0:
|
|
46
|
+
return [(k, 0) for k, _ in estimates]
|
|
47
|
+
if s <= 0:
|
|
48
|
+
return [(k, 0) for k, _ in estimates[:-1]] + [(estimates[-1][0], total)] if estimates else []
|
|
49
|
+
raw = [(k, v * total / s) for k, v in estimates]
|
|
50
|
+
out = [[k, int(v)] for k, v in raw]
|
|
51
|
+
left = total - sum(v for _, v in out)
|
|
52
|
+
order = sorted(range(len(raw)), key=lambda i: -(raw[i][1] - int(raw[i][1])))
|
|
53
|
+
for i in order[:left]:
|
|
54
|
+
out[i][1] += 1
|
|
55
|
+
return [(k, v) for k, v in out]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def iso(d: datetime | None) -> str | None:
|
|
59
|
+
return d.isoformat() if d else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def hhmm(d: datetime | None) -> str:
|
|
63
|
+
return d.astimezone().strftime("%H:%M") if d else ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def secs(a: datetime | None, b: datetime | None) -> float:
|
|
67
|
+
return max(0.0, (b - a).total_seconds()) if a and b else 0.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Pricer:
|
|
71
|
+
def __init__(self, prices: dict):
|
|
72
|
+
self.prices = prices
|
|
73
|
+
self.unchecked: set[str] = set()
|
|
74
|
+
|
|
75
|
+
def rates(self, c: Call) -> dict:
|
|
76
|
+
r, checked, _ = pricemod.rates(c.model, self.prices)
|
|
77
|
+
if not checked:
|
|
78
|
+
self.unchecked.add(c.model or "unknown model")
|
|
79
|
+
return r
|
|
80
|
+
|
|
81
|
+
def cost(self, c: Call) -> dict:
|
|
82
|
+
return pricemod.cost(c.usage, self.rates(c))
|
|
83
|
+
|
|
84
|
+
def total(self, c: Call) -> float:
|
|
85
|
+
return sum(self.cost(c).values())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def category(c: Call, first_main: bool, last_main: bool) -> str:
|
|
89
|
+
kinds = {a.kind for a in c.actions}
|
|
90
|
+
if c.agent != "main":
|
|
91
|
+
return "research"
|
|
92
|
+
if first_main:
|
|
93
|
+
return "start"
|
|
94
|
+
if "question" in kinds:
|
|
95
|
+
return "questions"
|
|
96
|
+
if kinds & {"write", "edit"}:
|
|
97
|
+
return "writing"
|
|
98
|
+
if "command" in kinds:
|
|
99
|
+
return "commands"
|
|
100
|
+
if kinds & {"read", "search"}:
|
|
101
|
+
return "reading"
|
|
102
|
+
if kinds & {"helper", "web"}:
|
|
103
|
+
return "research"
|
|
104
|
+
if kinds:
|
|
105
|
+
return "other"
|
|
106
|
+
return "closing" if last_main else "talk"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def call_label(c: Call) -> str:
|
|
110
|
+
if c.actions:
|
|
111
|
+
labels = [a.label for a in c.actions]
|
|
112
|
+
if len(labels) > 4:
|
|
113
|
+
return "; ".join(labels[:3]) + f"; and {len(labels) - 3} more"
|
|
114
|
+
return "; ".join(labels)
|
|
115
|
+
if c.first_text:
|
|
116
|
+
return f"wrote to you: “{c.first_text}”"
|
|
117
|
+
return "thought, wrote nothing visible"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def sent_parts(c: Call, r: dict, setup_tokens: int) -> list[dict]:
|
|
121
|
+
"""What was sent: fixed setup + carried history + new input = the call's context, exactly."""
|
|
122
|
+
u = c.usage
|
|
123
|
+
cr_rate, new_rate = r["cache_read"], pricemod.new_input_rate(u, r)
|
|
124
|
+
setup = min(setup_tokens, u.context) if c.agent == "main" else 0
|
|
125
|
+
s_cr = min(setup, u.cache_read)
|
|
126
|
+
s_new = setup - s_cr
|
|
127
|
+
carried = u.cache_read - s_cr
|
|
128
|
+
new = u.new_input - s_new
|
|
129
|
+
parts = []
|
|
130
|
+
if setup_tokens and c.agent == "main":
|
|
131
|
+
parts.append({"what": "fixed setup (system prompt, tool definitions, lists)", "tokens": setup,
|
|
132
|
+
"usd": (s_cr * cr_rate + s_new * new_rate) / 1e6, "estimated": True})
|
|
133
|
+
parts.append({"what": "carried history (re-read from cache)", "tokens": carried, "usd": carried * cr_rate / 1e6,
|
|
134
|
+
"estimated": bool(setup_tokens and c.agent == "main")})
|
|
135
|
+
parts.append({"what": "new input (read for the first time)", "tokens": new, "usd": new * new_rate / 1e6,
|
|
136
|
+
"estimated": bool(s_new)})
|
|
137
|
+
return parts
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def written_parts(c: Call, r: dict) -> list[dict]:
|
|
141
|
+
"""What was written: messages + files + other tool calls + hidden thinking = the call's output, exactly."""
|
|
142
|
+
u = c.usage
|
|
143
|
+
files = sum(a.written_chars for a in c.actions)
|
|
144
|
+
tools = sum(max(0, a.args_chars - a.written_chars) for a in c.actions)
|
|
145
|
+
est = [("messages to you", tok(c.text_chars)), ("files written", tok(files)),
|
|
146
|
+
("tool calls (commands, questions, searches)", tok(tools))]
|
|
147
|
+
est = [(k, v) for k, v in est if v]
|
|
148
|
+
if u.thinking is not None:
|
|
149
|
+
think = min(u.thinking, u.output)
|
|
150
|
+
exact_think = True
|
|
151
|
+
else:
|
|
152
|
+
think = max(0, u.output - sum(v for _, v in est))
|
|
153
|
+
exact_think = False
|
|
154
|
+
visible = u.output - think
|
|
155
|
+
if visible and not est:
|
|
156
|
+
est = [("other output (formatting)", 1)]
|
|
157
|
+
parts = [{"what": k, "tokens": v, "usd": v * r["output"] / 1e6, "estimated": True}
|
|
158
|
+
for k, v in split_exact(est, visible)] if visible else []
|
|
159
|
+
parts.append({"what": "hidden thinking", "tokens": think, "usd": think * r["output"] / 1e6,
|
|
160
|
+
"estimated": not exact_think})
|
|
161
|
+
return parts
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def money_label(exact: bool, as_of: str, subscription: bool, tokens_exact: bool = True) -> tuple[str, str]:
|
|
165
|
+
"""(long label, short label)"""
|
|
166
|
+
if not tokens_exact:
|
|
167
|
+
return ("estimate: this log keeps no token counts, so tokens are estimated from the size of the text, at "
|
|
168
|
+
"API prices as of %s%s" % (as_of, "; your plan is not billed per token" if subscription else ""),
|
|
169
|
+
"estimate")
|
|
170
|
+
if subscription:
|
|
171
|
+
return ("API-equivalent: what these tokens would cost at API prices (as of %s). Your plan is not billed per "
|
|
172
|
+
"token." % as_of, "API-equivalent")
|
|
173
|
+
if exact:
|
|
174
|
+
return ("exact, at the API price list as of %s" % as_of, "exact (API prices %s)" % as_of)
|
|
175
|
+
return ("estimate: at least one model has no checked price (prices as of %s)" % as_of, "estimate")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def analyse(s: Session, prices: dict, subscription: bool = False, engines: list | None = None) -> dict:
|
|
179
|
+
P = Pricer(prices)
|
|
180
|
+
subscription = subscription or not s.billed_per_token
|
|
181
|
+
setup_tokens = tok(s.setup.chars) if s.setup.known else 0
|
|
182
|
+
main_calls = s.calls
|
|
183
|
+
first_id = main_calls[0].msg_id if main_calls else None
|
|
184
|
+
last_id = main_calls[-1].msg_id if main_calls else None
|
|
185
|
+
|
|
186
|
+
# ---- calls (main first, then helpers), each with its parts
|
|
187
|
+
calls: list[dict] = []
|
|
188
|
+
by_obj: dict[int, dict] = {}
|
|
189
|
+
for c in s.all_calls:
|
|
190
|
+
r = P.rates(c)
|
|
191
|
+
cc = pricemod.cost(c.usage, r)
|
|
192
|
+
total = sum(cc.values())
|
|
193
|
+
sp = sent_parts(c, r, setup_tokens)
|
|
194
|
+
wp = written_parts(c, r)
|
|
195
|
+
d = {
|
|
196
|
+
"agent": c.agent, "n": c.n, "turn": c.turn, "model": c.model,
|
|
197
|
+
"ts": iso(c.ts_first), "end": iso(c.ts_last), "time": hhmm(c.ts_first),
|
|
198
|
+
"category": category(c, c.msg_id == first_id and c.agent == "main",
|
|
199
|
+
c.msg_id == last_id and c.agent == "main"),
|
|
200
|
+
"label": call_label(c),
|
|
201
|
+
"actions": [{"label": a.label, "kind": a.kind, "result_tokens": tok(a.result_chars), "tool": a.tool,
|
|
202
|
+
"secs": round(secs(a.ts, a.result_ts), 1) if a.ts and a.result_ts else None,
|
|
203
|
+
"error": a.is_error} for a in c.actions],
|
|
204
|
+
"usage": {"input": c.usage.fresh_input, "cache_write_5m": c.usage.cache_write_5m,
|
|
205
|
+
"cache_write_1h": c.usage.cache_write_1h, "cache_read": c.usage.cache_read,
|
|
206
|
+
"output": c.usage.output, "thinking": c.usage.thinking},
|
|
207
|
+
"context": c.usage.context,
|
|
208
|
+
"cost_by_class": cc,
|
|
209
|
+
"usd": total,
|
|
210
|
+
"sent": {"tokens": c.usage.context, "usd": sum(p["usd"] for p in sp), "parts": sp},
|
|
211
|
+
"written": {"tokens": c.usage.output, "usd": sum(p["usd"] for p in wp), "parts": wp},
|
|
212
|
+
"mix": {"written": cc["output"], "new": cc["input"] + cc["cache_write_5m"] + cc["cache_write_1h"],
|
|
213
|
+
"reread": cc["cache_read"]},
|
|
214
|
+
"new_items": [],
|
|
215
|
+
}
|
|
216
|
+
calls.append(d)
|
|
217
|
+
by_obj[id(c)] = d
|
|
218
|
+
|
|
219
|
+
# what came in before each main call (informational: names the sources of "new input")
|
|
220
|
+
prev = None
|
|
221
|
+
for t in s.turns:
|
|
222
|
+
for i, c in enumerate(t.calls):
|
|
223
|
+
items = []
|
|
224
|
+
if prev is not None:
|
|
225
|
+
items += [{"what": f"result of: {a.label}", "tokens": tok(a.result_chars)}
|
|
226
|
+
for a in prev.actions if a.result_chars]
|
|
227
|
+
for e in t.events:
|
|
228
|
+
if i == 0:
|
|
229
|
+
keep = e.ts is None or c.ts_first is None or e.ts <= c.ts_first
|
|
230
|
+
else:
|
|
231
|
+
keep = bool(e.ts and prev is not None and prev.ts_last and c.ts_first
|
|
232
|
+
and prev.ts_last < e.ts <= c.ts_first)
|
|
233
|
+
if keep:
|
|
234
|
+
said = e.kind in ("typed", "command", "queued", "answer")
|
|
235
|
+
items.append({"what": e.label + (f": “{e.text}”" if said else ""),
|
|
236
|
+
"tokens": tok(e.chars)})
|
|
237
|
+
by_obj[id(c)]["new_items"] = [x for x in items if x["tokens"]]
|
|
238
|
+
prev = c
|
|
239
|
+
|
|
240
|
+
total_usd = sum(d["usd"] for d in calls)
|
|
241
|
+
main_usd = sum(by_obj[id(c)]["usd"] for c in main_calls)
|
|
242
|
+
helper_usd = total_usd - main_usd
|
|
243
|
+
|
|
244
|
+
# ---- timing
|
|
245
|
+
question_wait = 0.0
|
|
246
|
+
turn_rows = []
|
|
247
|
+
for t in s.turns:
|
|
248
|
+
q = sum(secs(a.ts, a.result_ts) for c in t.calls for a in c.actions if a.kind == "question")
|
|
249
|
+
question_wait += q
|
|
250
|
+
start = t.ts or (t.calls[0].ts_first if t.calls else None)
|
|
251
|
+
ends = [x for c in t.calls for x in [c.ts_last] + [a.result_ts for a in c.actions] if x]
|
|
252
|
+
end = max(ends) if ends else start
|
|
253
|
+
minutes = max(0.0, secs(start, end) - q) / 60
|
|
254
|
+
tc = [by_obj[id(c)] for c in t.calls]
|
|
255
|
+
turn_rows.append({
|
|
256
|
+
"n": t.n, "kind": t.kind, "text": plain(t.text), "time": hhmm(start), "ts": iso(start),
|
|
257
|
+
"waited_min": round(t.waited_s / 60, 1), "question_wait_min": round(q / 60, 1),
|
|
258
|
+
"minutes": round(minutes, 1),
|
|
259
|
+
"usd": sum(d["usd"] for d in tc),
|
|
260
|
+
"helper_usd": sum(by_obj[id(c)]["usd"] for c in s.helper_calls if c.turn == t.n),
|
|
261
|
+
"sent": sum(d["sent"]["tokens"] for d in tc), "written": sum(d["written"]["tokens"] for d in tc),
|
|
262
|
+
"helper_sent": sum(by_obj[id(c)]["sent"]["tokens"] for c in s.helper_calls if c.turn == t.n),
|
|
263
|
+
"calls": [calls.index(d) for d in tc],
|
|
264
|
+
"mix": {k: sum(d["mix"][k] for d in tc) for k in ("written", "new", "reread")},
|
|
265
|
+
"events": [{"kind": e.kind, "label": e.label, "text": e.text, "tokens": tok(e.chars)} for e in t.events],
|
|
266
|
+
"fixes": [],
|
|
267
|
+
})
|
|
268
|
+
waiting = sum(t.waited_s for t in s.turns) + question_wait
|
|
269
|
+
working = sum(t["minutes"] for t in turn_rows)
|
|
270
|
+
|
|
271
|
+
# ---- where the cost went
|
|
272
|
+
by_step = {k: {"key": k, "label": lbl, "usd": 0.0, "calls": 0} for k, lbl in CATEGORIES}
|
|
273
|
+
for d in calls:
|
|
274
|
+
by_step[d["category"]]["usd"] += d["usd"]
|
|
275
|
+
by_step[d["category"]]["calls"] += 1
|
|
276
|
+
by_class = []
|
|
277
|
+
for k, lbl in CLASS_LABELS:
|
|
278
|
+
tokens = sum(d["usage"][k] for d in calls)
|
|
279
|
+
by_class.append({"key": k, "label": lbl, "tokens": tokens, "usd": sum(d["cost_by_class"][k] for d in calls)})
|
|
280
|
+
thinking_known = all(d["usage"]["thinking"] is not None for d in calls)
|
|
281
|
+
thinking = sum(p["tokens"] for d in calls for p in d["written"]["parts"] if p["what"] == "hidden thinking")
|
|
282
|
+
|
|
283
|
+
# ---- setup
|
|
284
|
+
used = {a.tool for a in s.actions()}
|
|
285
|
+
setup = {"known": s.setup.known, "tokens": setup_tokens,
|
|
286
|
+
"parts": [{"what": p.what, "tokens": tok(p.chars)} for p in s.setup.parts],
|
|
287
|
+
"tools": [{"name": p.what, "tokens": tok(p.chars),
|
|
288
|
+
"used": p.what in used} for p in s.setup.tools]}
|
|
289
|
+
setup["unused"] = [t["name"] for t in setup["tools"] if not t["used"]]
|
|
290
|
+
setup["unused_tokens"] = sum(t["tokens"] for t in setup["tools"] if not t["used"])
|
|
291
|
+
|
|
292
|
+
# ---- replay order: every call by time, with the running total
|
|
293
|
+
order = sorted(range(len(calls)), key=lambda i: (calls[i]["ts"] or "", i))
|
|
294
|
+
running = 0.0
|
|
295
|
+
replay = []
|
|
296
|
+
for i in order:
|
|
297
|
+
running += calls[i]["usd"]
|
|
298
|
+
replay.append({"call": i, "cum_usd": running})
|
|
299
|
+
|
|
300
|
+
as_of = str(prices.get("as_of") or "unknown date")
|
|
301
|
+
split_known = all(c.usage.write_split_known for c in s.all_calls)
|
|
302
|
+
exact = not P.unchecked and s.tokens_exact and split_known
|
|
303
|
+
long_label, short_label = money_label(exact, as_of, subscription, s.tokens_exact)
|
|
304
|
+
if not exact and long_label.startswith("exact"):
|
|
305
|
+
long_label, short_label = ("estimate: this log does not say which cache writes were kept 5 minutes or 1 hour "
|
|
306
|
+
"(they cost 1.25x and 2x the input price), so all are priced at 5 minutes "
|
|
307
|
+
"(prices as of %s)" % as_of, "estimate")
|
|
308
|
+
reasons = [] # why the total is not exact, biggest first
|
|
309
|
+
if not s.tokens_exact:
|
|
310
|
+
reasons.append("This log keeps no token counts, so tokens are estimated from the size of the text.")
|
|
311
|
+
if P.unchecked:
|
|
312
|
+
reasons.append("No confirmed price for " + ", ".join(sorted(P.unchecked)) +
|
|
313
|
+
" (a third-party or default rate was used).")
|
|
314
|
+
if not split_known:
|
|
315
|
+
reasons.append("The log does not split cache writes into 5-minute and 1-hour, so all are priced at the "
|
|
316
|
+
"5-minute rate.")
|
|
317
|
+
why = " ".join(reasons) or "Every model has a confirmed price and every token class is in the log."
|
|
318
|
+
if subscription:
|
|
319
|
+
why += " Your plan is not billed per token: read the figures as API-equivalent."
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
out = {
|
|
323
|
+
"tool": "sessioncost",
|
|
324
|
+
"meta": {
|
|
325
|
+
"title": plain(s.title), "agent": s.agent, "agent_label": {"claude-code": "Claude Code", "codex": "Codex", "antigravity": "Antigravity",
|
|
326
|
+
"cursor": "Cursor"}.get(s.agent, s.agent),
|
|
327
|
+
"agent_version": s.agent_version, "model": s.model,
|
|
328
|
+
"models": sorted({c.model for c in s.all_calls if c.model}), "session_id": s.session_id,
|
|
329
|
+
"project": _folder(s.cwd), "started": iso(s.started), "ended": iso(s.ended),
|
|
330
|
+
"date": s.started.astimezone().strftime("%d %b %Y") if s.started else "",
|
|
331
|
+
},
|
|
332
|
+
"money": {"label": long_label, "short": short_label, "as_of": as_of, "exact": exact,
|
|
333
|
+
"subscription": subscription, "unchecked_models": sorted(P.unchecked), "why": why,
|
|
334
|
+
"tokens_exact": s.tokens_exact, "write_split_known": split_known,
|
|
335
|
+
"own_cost_usd": s.own_cost_usd},
|
|
336
|
+
"totals": {
|
|
337
|
+
"usd": total_usd, "main_usd": main_usd, "helpers_usd": helper_usd,
|
|
338
|
+
"calls": len(main_calls), "helper_calls": len(s.helper_calls),
|
|
339
|
+
"turns": len([t for t in s.turns if t.n > 0]),
|
|
340
|
+
"working_min": round(working, 1), "waiting_min": round(waiting / 60, 1),
|
|
341
|
+
"context_max": max((d["context"] for d in calls if d["agent"] == "main"), default=0),
|
|
342
|
+
"sent": sum(d["sent"]["tokens"] for d in calls),
|
|
343
|
+
"output": sum(d["usage"]["output"] for d in calls), "thinking": thinking,
|
|
344
|
+
"thinking_known": thinking_known,
|
|
345
|
+
"by_class": by_class,
|
|
346
|
+
"by_step": [v for v in by_step.values() if v["calls"]],
|
|
347
|
+
},
|
|
348
|
+
"setup": setup,
|
|
349
|
+
"turns": turn_rows,
|
|
350
|
+
"calls": calls,
|
|
351
|
+
"replay": replay,
|
|
352
|
+
"helpers": [{"id": h.id, "description": h.description, "type": h.agent_type, "turn": h.turn,
|
|
353
|
+
"calls": len(h.calls), "usd": sum(by_obj[id(c)]["usd"] for c in h.calls),
|
|
354
|
+
"tokens": sum(c.usage.context + c.usage.output for c in h.calls)} for h in s.helpers],
|
|
355
|
+
"notes": list(s.notes),
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# ---- fixes: built-in detectors + any engine plugged in (see fixes.py)
|
|
359
|
+
engines = engines if engines is not None else fixmod.load_engines()
|
|
360
|
+
out["fix_engines"] = [getattr(e, "name", "built-in") for e in engines]
|
|
361
|
+
s.fixes = fixmod.find(s, P, engines)
|
|
362
|
+
out["fixes"] = [{"id": f.id, "title": f.title, "why": f.why, "saving_usd": f.saving_usd,
|
|
363
|
+
"saving_tokens": f.saving_tokens, "where": f.where, "turns": f.turns, "items": f.items,
|
|
364
|
+
"source": f.source} for f in s.fixes]
|
|
365
|
+
for f in s.fixes:
|
|
366
|
+
for tr in turn_rows:
|
|
367
|
+
if tr["n"] in f.turns:
|
|
368
|
+
tr["fixes"].append(f.id)
|
|
369
|
+
return out
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
|
|
373
|
+
PATH_RE = re.compile(r"(?<![\w:/\\])(?:[A-Za-z]:[\\/]+|~[\\/]+|[\\/]+)(?:[^\s\\/\"'`()\[\]]+[\\/]+)+([^\s\\/\"'`()\[\]]*)")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def plain(text: str) -> str:
|
|
377
|
+
"""What a person reads: markdown links become their text, file paths become their last name
|
|
378
|
+
("[$architect](C:/Users/me/SKILL.md)" -> "$architect"). Also keeps folder names out of shared screenshots."""
|
|
379
|
+
text = LINK_RE.sub(lambda m: m.group(1), text or "")
|
|
380
|
+
text = re.sub(r"@\[([^\]]+)\]", lambda m: "@" + re.split(r"[\\/]", m.group(1).rstrip("\\/"))[-1], text)
|
|
381
|
+
return PATH_RE.sub(lambda m: m.group(1) or "folder", text).strip()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _folder(cwd: str) -> str:
|
|
385
|
+
return cwd.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1] if cwd else ""
|
sessioncost/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""sessioncost last | list | <session.jsonl> - see README.md."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, locate, prices as pricemod, readers, report
|
|
11
|
+
from .analyse import analyse
|
|
12
|
+
from .readers import cursor
|
|
13
|
+
|
|
14
|
+
REPORTS = Path.home() / ".sessioncost" / "reports"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def money(x: float) -> str:
|
|
18
|
+
return f"${x:,.2f}" if x >= 0.995 or x == 0 else f"${x:.3f}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plural(n: int, word: str) -> str:
|
|
22
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def minutes(m: float) -> str:
|
|
26
|
+
m = float(m or 0)
|
|
27
|
+
if m >= 60:
|
|
28
|
+
return f"{int(m // 60)}h {int(round(m % 60)):02d}m"
|
|
29
|
+
return f"{m:.0f} min" if m >= 10 else f"{m:.1f} min"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tokens(n: float) -> str:
|
|
33
|
+
n = float(n or 0)
|
|
34
|
+
if n >= 999_500:
|
|
35
|
+
return f"{n / 1e6:.1f}M"
|
|
36
|
+
return f"{n / 1e3:.0f}k" if n >= 1000 else f"{n:.0f}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def summary(a: dict, path: Path | None) -> str:
|
|
40
|
+
t, mo = a["totals"], a["money"]
|
|
41
|
+
title = a["meta"]["title"]
|
|
42
|
+
title = title if len(title) <= 70 else title[:69] + "…"
|
|
43
|
+
lines = [f"SessionCost: {title}"]
|
|
44
|
+
helpers = f" (+{plural(t['helper_calls'], 'helper call')})" if t["helper_calls"] else ""
|
|
45
|
+
lines.append(f"{tokens(t['sent'])} tokens sent, {tokens(t['output'])} written · {plural(t['turns'], 'turn')} · "
|
|
46
|
+
f"{plural(t['calls'], 'call')}{helpers} · "
|
|
47
|
+
f"{minutes(t['working_min'])} working, {minutes(t['waiting_min'])} waiting for you")
|
|
48
|
+
turns = [x for x in a["turns"] if x["calls"]]
|
|
49
|
+
if turns and t["sent"]:
|
|
50
|
+
top = max(turns, key=lambda x: x["sent"] + x["helper_sent"])
|
|
51
|
+
sent = top["sent"] + top["helper_sent"]
|
|
52
|
+
text = top["text"] if len(top["text"]) <= 50 else top["text"][:49] + "…"
|
|
53
|
+
lines.append(f"Heaviest turn: #{top['n']} “{text}” · {tokens(sent)} tokens sent ({sent / t['sent']:.0%}) · "
|
|
54
|
+
f"{money(top['usd'] + top['helper_usd'])}")
|
|
55
|
+
if a["fixes"]:
|
|
56
|
+
f = a["fixes"][0]
|
|
57
|
+
lines.append(f"Top fix: {f['title']} · {tokens(f['saving_tokens'])} fewer tokens sent · "
|
|
58
|
+
f"saves {money(f['saving_usd'])} (measured from this session)")
|
|
59
|
+
else:
|
|
60
|
+
lines.append("Top fix: none of the built-in checks fired")
|
|
61
|
+
lines.append(f"Cost: {money(t['usd'])} {mo['short']}")
|
|
62
|
+
if path:
|
|
63
|
+
lines.append(f"Report: {path}")
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build(path, args) -> tuple[dict, Path | None]:
|
|
68
|
+
s = readers.read(path)
|
|
69
|
+
if args.cursor_usage:
|
|
70
|
+
if s.agent != "cursor":
|
|
71
|
+
print("sessioncost: --cursor-usage is for Cursor chats; ignored")
|
|
72
|
+
else:
|
|
73
|
+
used = cursor.apply_usage_csv(s, cursor.read_usage_csv(args.cursor_usage))
|
|
74
|
+
print(f"sessioncost: {used} requests from the usage export matched this chat" if used else
|
|
75
|
+
"sessioncost: no row of the usage export falls inside this chat's turns (check the export's dates)")
|
|
76
|
+
a = analyse(s, pricemod.load(args.prices), subscription=args.subscription)
|
|
77
|
+
out = None
|
|
78
|
+
if not args.json:
|
|
79
|
+
REPORTS.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
out = REPORTS / f"{s.session_id}.html"
|
|
81
|
+
out.write_text(report.render(a), encoding="utf-8")
|
|
82
|
+
return a, out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
SKILL = Path(__file__).parent / "skill" / "SKILL.md"
|
|
86
|
+
SKILL_HOMES = [ # (agent, its folder, where its personal skills live inside it)
|
|
87
|
+
("Claude Code", ".claude", "skills"),
|
|
88
|
+
("Codex", ".codex", "skills"),
|
|
89
|
+
("Antigravity", ".gemini", "config/skills"),
|
|
90
|
+
("Cursor", ".cursor", "skills"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def setup(home: Path | None = None) -> list[tuple[str, Path]]:
|
|
95
|
+
"""Add the /sessioncost skill to every agent installed for this user. Returns (agent, skill file) written."""
|
|
96
|
+
home = home or Path.home()
|
|
97
|
+
# The skill names this exact Python, so it runs even when no 'python' or 'sessioncost' is on the PATH.
|
|
98
|
+
exe = Path(sys.executable).as_posix()
|
|
99
|
+
run = f'"{exe}" -m sessioncost' if " " in exe else f"{exe} -m sessioncost"
|
|
100
|
+
text = SKILL.read_text(encoding="utf-8").replace("{{RUN}}", run)
|
|
101
|
+
if " " in exe:
|
|
102
|
+
text = text.replace(" If that fails because", " In PowerShell, put `& ` in front of the quoted path. If that fails because")
|
|
103
|
+
done = []
|
|
104
|
+
for agent, folder, sub in SKILL_HOMES:
|
|
105
|
+
if not (home / folder).is_dir():
|
|
106
|
+
continue
|
|
107
|
+
dest = home / folder / sub / "sessioncost" / "SKILL.md"
|
|
108
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
dest.write_text(text, encoding="utf-8")
|
|
110
|
+
done.append((agent, dest))
|
|
111
|
+
return done
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_setup() -> int:
|
|
115
|
+
done = setup()
|
|
116
|
+
if not done:
|
|
117
|
+
print("No Claude Code, Codex, Antigravity or Cursor folder found for this user; nothing to set up.")
|
|
118
|
+
return 1
|
|
119
|
+
for agent, dest in done:
|
|
120
|
+
print(f"{agent:12} /sessioncost added ({dest})")
|
|
121
|
+
print("\nRestart the agent if it is open, then type /sessioncost in a chat.")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_list(args) -> int:
|
|
126
|
+
found = locate.sessions(args.project)
|
|
127
|
+
if not found:
|
|
128
|
+
print(f"No Claude Code, Codex, Antigravity or Cursor sessions found for {args.project or Path.cwd()}")
|
|
129
|
+
return 1
|
|
130
|
+
P = pricemod.load(args.prices)
|
|
131
|
+
print(f"{'When':16} {'Agent':11} {'Session':8} {'Cost':>8} {'Turns':>5} {'Calls':>5} First message")
|
|
132
|
+
for when, p in found[: args.limit]:
|
|
133
|
+
try:
|
|
134
|
+
a = analyse(readers.read(p), P, engines=[])
|
|
135
|
+
except Exception as e: # one broken log must not hide the others
|
|
136
|
+
print(f"{when.astimezone():%Y-%m-%d %H:%M} {'':11} {locate.short_id(p)} {'?':>8} {'':>5} {'':>5} (could not read: {e})")
|
|
137
|
+
continue
|
|
138
|
+
t = a["totals"]
|
|
139
|
+
title = a["meta"]["title"]
|
|
140
|
+
print(f"{when.astimezone():%Y-%m-%d %H:%M} {a['meta']['agent_label']:11} {locate.short_id(p)} {money(t['usd']):>8} {t['turns']:>5} {t['calls']:>5} "
|
|
141
|
+
f"{title[:60]}{'…' if len(title) > 60 else ''}")
|
|
142
|
+
print("\nOpen one: sessioncost <session> (its first characters are enough)")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv: list[str] | None = None) -> int:
|
|
147
|
+
for stream in (sys.stdout, sys.stderr):
|
|
148
|
+
try:
|
|
149
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
150
|
+
except (AttributeError, ValueError):
|
|
151
|
+
pass
|
|
152
|
+
ap = argparse.ArgumentParser(
|
|
153
|
+
prog="sessioncost",
|
|
154
|
+
description="See where every token of your agent session went, turn by turn, and what to fix. "
|
|
155
|
+
"Local only: reads log files, sends nothing anywhere.")
|
|
156
|
+
ap.add_argument("target", nargs="?", default="last",
|
|
157
|
+
help="'last' (newest Claude Code, Codex, Antigravity or Cursor session of this project, the default), 'list', 'setup' (add /sessioncost to your agents), or a session file (.jsonl, or an Antigravity .db)")
|
|
158
|
+
ap.add_argument("--project", help="project folder (default: the current folder)")
|
|
159
|
+
ap.add_argument("--no-open", action="store_true", help="write the report but do not open it")
|
|
160
|
+
ap.add_argument("--json", action="store_true", help="print the full model as JSON instead of a report")
|
|
161
|
+
ap.add_argument("--prices", help="a prices.json to merge over the built-in one")
|
|
162
|
+
ap.add_argument("--subscription", action="store_true",
|
|
163
|
+
help="you are on a subscription plan (Claude Pro/Max, ChatGPT): label money as API-equivalent, not billed")
|
|
164
|
+
ap.add_argument("--cursor-usage", metavar="CSV",
|
|
165
|
+
help="Cursor only: your usage export from the Cursor dashboard, for exact token counts")
|
|
166
|
+
ap.add_argument("--limit", type=int, default=10, help="list: how many sessions (default 10)")
|
|
167
|
+
ap.add_argument("--version", action="version", version=f"sessioncost {__version__}")
|
|
168
|
+
args = ap.parse_args(argv)
|
|
169
|
+
|
|
170
|
+
if args.target == "setup":
|
|
171
|
+
return cmd_setup()
|
|
172
|
+
if args.target == "list":
|
|
173
|
+
return cmd_list(args)
|
|
174
|
+
if args.target == "last":
|
|
175
|
+
found = locate.sessions(args.project)
|
|
176
|
+
if not found:
|
|
177
|
+
print(f"No Claude Code, Codex, Antigravity or Cursor sessions found for {args.project or Path.cwd()} "
|
|
178
|
+
f"(looked in {locate.claude_home() / 'projects'}, {locate.codex_home() / 'sessions'} and "
|
|
179
|
+
f"{locate.antigravity_home() / 'conversations'}, and Cursor's chats)")
|
|
180
|
+
return 1
|
|
181
|
+
path = found[0][1]
|
|
182
|
+
else:
|
|
183
|
+
path = Path(args.target).expanduser()
|
|
184
|
+
if not path.is_file(): # a session id (or its first characters) from `sessioncost list`
|
|
185
|
+
hits = [p for _, p in locate.sessions(args.project) if locate.session_id(p).startswith(args.target)]
|
|
186
|
+
if len(hits) == 1:
|
|
187
|
+
path = hits[0]
|
|
188
|
+
try:
|
|
189
|
+
a, out = build(path, args)
|
|
190
|
+
except (readers.NotSupported, ValueError) as e:
|
|
191
|
+
print(f"sessioncost: {e}")
|
|
192
|
+
return 2
|
|
193
|
+
if args.json:
|
|
194
|
+
print(json.dumps(a, indent=1, ensure_ascii=False, default=str))
|
|
195
|
+
return 0
|
|
196
|
+
print(summary(a, out))
|
|
197
|
+
if out and not args.no_open:
|
|
198
|
+
webbrowser.open(out.resolve().as_uri())
|
|
199
|
+
return 0
|