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/fixes.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Fixes: each one names what to change and a saving measured from this session's own calls.
|
|
2
|
+
|
|
3
|
+
The open tool ships three simple detectors (BasicFixes). Every saving is the cost of the tokens or calls involved
|
|
4
|
+
in THIS session, priced like the rest of the report - nothing is projected or guessed.
|
|
5
|
+
|
|
6
|
+
PLUG-IN POINT: a separate fix engine can add detectors without changing this repo. Set the environment variable
|
|
7
|
+
SESSIONCOST_ENGINE to an importable module name; that module must expose `Engine()`, an object with a `name` and
|
|
8
|
+
`find(session, pricer) -> list[Fix]` (the FixEngine interface below). It runs after the built-in detectors and its
|
|
9
|
+
fixes are ranked together with theirs.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
from . import prices as pricemod
|
|
19
|
+
from .model import Call, Fix, Session
|
|
20
|
+
|
|
21
|
+
MAX_FIXES = 3
|
|
22
|
+
CONFIRM = {"y", "yes", "yes please", "yep", "yeah", "ok", "okay", "k", "sure", "go", "go ahead", "go on", "do it",
|
|
23
|
+
"proceed", "continue", "save", "save it", "confirm", "confirmed", "approve", "approved", "lgtm",
|
|
24
|
+
"sounds good", "looks good", "ship it", "fine"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tok(chars: int) -> int:
|
|
28
|
+
return max(0, int(chars) // 4)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_confirm(text: str) -> bool:
|
|
32
|
+
t = re.sub(r"[^\w\s]", "", (text or "").lower()).strip()
|
|
33
|
+
t = re.sub(r"\s+", " ", t)
|
|
34
|
+
return bool(t) and len(t) <= 25 and t in CONFIRM
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sent_usd(c: Call, r: dict) -> float:
|
|
38
|
+
cc = pricemod.cost(c.usage, r)
|
|
39
|
+
return cc["input"] + cc["cache_write_5m"] + cc["cache_write_1h"] + cc["cache_read"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FixEngine(Protocol):
|
|
43
|
+
name: str
|
|
44
|
+
|
|
45
|
+
def find(self, session: Session, pricer) -> list[Fix]: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unused_tools(s: Session, P) -> Fix | None:
|
|
49
|
+
"""Tool definitions sent with every call that the session never used."""
|
|
50
|
+
if not s.setup.tools or not s.calls:
|
|
51
|
+
return None
|
|
52
|
+
used = {a.tool for a in s.actions()}
|
|
53
|
+
unused = [t for t in s.setup.tools if t.what not in used]
|
|
54
|
+
tokens = sum(tok(t.chars) for t in unused)
|
|
55
|
+
if not tokens:
|
|
56
|
+
return None
|
|
57
|
+
usd = 0.0
|
|
58
|
+
for c in s.calls:
|
|
59
|
+
# The setup is the front of the prompt, so it comes from the cache first; only what the call really
|
|
60
|
+
# wrote as new input can be priced as new input. The saving can never exceed what the call paid to send.
|
|
61
|
+
r, u = P.rates(c), c.usage
|
|
62
|
+
from_cache = min(tokens, u.cache_read)
|
|
63
|
+
as_new = min(tokens - from_cache, u.new_input)
|
|
64
|
+
usd += (from_cache * r["cache_read"] + as_new * pricemod.new_input_rate(u, r)) / 1e6
|
|
65
|
+
top = sorted(unused, key=lambda t: -t.chars)
|
|
66
|
+
return Fix(
|
|
67
|
+
id="unused-tools", title="Turn off tools this session never used",
|
|
68
|
+
why=(f"{len(unused)} of {len(s.setup.tools)} tool definitions (~{tokens:,} tokens) went out with every one "
|
|
69
|
+
f"of the {len(s.calls)} calls and were never used. If this project does not need them (MCP servers and "
|
|
70
|
+
"plugins are the usual ones), turning them off saves this much on a session like this one."),
|
|
71
|
+
saving_usd=usd, saving_tokens=tokens * len(s.calls), where="every call",
|
|
72
|
+
items=[f"{t.what} ~{tok(t.chars):,} tokens" for t in top[:8]] + (
|
|
73
|
+
[f"and {len(top) - 8} more"] if len(top) > 8 else []))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def repeated_reads(s: Session, P) -> Fix | None:
|
|
77
|
+
"""The same file read again with exactly the same content, in the same thread."""
|
|
78
|
+
threads = [s.calls] + [h.calls for h in s.helpers]
|
|
79
|
+
usd, tokens, items, turns = 0.0, 0, [], set()
|
|
80
|
+
for calls in threads:
|
|
81
|
+
seen: dict[str, str] = {}
|
|
82
|
+
for i, c in enumerate(calls):
|
|
83
|
+
for a in c.actions:
|
|
84
|
+
if a.kind in ("write", "edit"):
|
|
85
|
+
for f in a.target.splitlines() or [""]:
|
|
86
|
+
seen.pop(f, None)
|
|
87
|
+
for k in [k for k in seen if f and f in k.splitlines()]:
|
|
88
|
+
seen.pop(k)
|
|
89
|
+
continue
|
|
90
|
+
if a.kind != "read" or a.result_chars < 200 or not a.result_hash:
|
|
91
|
+
continue
|
|
92
|
+
key = a.target
|
|
93
|
+
if seen.get(key) == a.result_hash + str(a.label):
|
|
94
|
+
t = tok(a.result_chars)
|
|
95
|
+
if i + 1 < len(calls):
|
|
96
|
+
nxt = calls[i + 1]
|
|
97
|
+
cost = t * pricemod.new_input_rate(nxt.usage, P.rates(nxt))
|
|
98
|
+
cost += sum(t * P.rates(k)["cache_read"] for k in calls[i + 2:])
|
|
99
|
+
usd += cost / 1e6
|
|
100
|
+
tokens += t
|
|
101
|
+
turns.add(c.turn)
|
|
102
|
+
items.append(f"{a.label} again (~{t:,} tokens, unchanged), call {c.n}"
|
|
103
|
+
+ ("" if c.agent == "main" else f" of helper {c.agent.split(':')[-1]}"))
|
|
104
|
+
else:
|
|
105
|
+
seen[key] = a.result_hash + str(a.label)
|
|
106
|
+
if not items:
|
|
107
|
+
return None
|
|
108
|
+
return Fix(
|
|
109
|
+
id="repeated-reads", title="Don't re-read a file that has not changed",
|
|
110
|
+
why=(f"{len(items)} read{'s' if len(items) > 1 else ''} returned exactly the same text as an earlier read in "
|
|
111
|
+
"the same chat. The copy was paid for when it came in and again on every later call that carried it."),
|
|
112
|
+
saving_usd=usd, saving_tokens=tokens, where=_where(turns), turns=sorted(turns), items=items[:8])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def confirm_turns(s: Session, P) -> Fix | None:
|
|
116
|
+
"""A stop where the user only said yes / ok / save, and the next call just acted on it."""
|
|
117
|
+
usd, hits, turns, tokens = 0.0, [], set(), 0
|
|
118
|
+
for t in s.turns:
|
|
119
|
+
if t.kind in ("typed", "queued") and t.calls and is_confirm(t.text):
|
|
120
|
+
c = t.calls[0]
|
|
121
|
+
usd += sent_usd(c, P.rates(c))
|
|
122
|
+
tokens += c.usage.context
|
|
123
|
+
hits.append(f"turn {t.n}: you typed “{t.text}”; the next call re-sent "
|
|
124
|
+
f"{c.usage.context:,} tokens to receive it")
|
|
125
|
+
turns.add(t.n)
|
|
126
|
+
for e in t.events:
|
|
127
|
+
if e.kind != "answer" or not e.ts:
|
|
128
|
+
continue
|
|
129
|
+
answers = [x for x in e.text.split("; ") if x.strip()]
|
|
130
|
+
if answers and all(is_confirm(x) for x in answers):
|
|
131
|
+
nxt = next((c for c in t.calls if c.ts_first and c.ts_first >= e.ts), None)
|
|
132
|
+
if nxt is None:
|
|
133
|
+
continue
|
|
134
|
+
usd += sent_usd(nxt, P.rates(nxt))
|
|
135
|
+
tokens += nxt.usage.context
|
|
136
|
+
hits.append(f"turn {t.n}: you picked “{e.text}” on a question card; the next call re-sent "
|
|
137
|
+
f"{nxt.usage.context:,} tokens to receive it")
|
|
138
|
+
turns.add(t.n)
|
|
139
|
+
if not hits:
|
|
140
|
+
return None
|
|
141
|
+
return Fix(
|
|
142
|
+
id="confirm-turn", title="Skip the stop that only asks you to confirm",
|
|
143
|
+
why=("The agent stopped and waited for a yes. Receiving it meant re-sending the whole conversation once "
|
|
144
|
+
"more. Let it go ahead and ask “change anything?” afterwards instead."),
|
|
145
|
+
saving_usd=usd, saving_tokens=tokens, where=_where(turns), turns=sorted(turns), items=hits)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _where(turns) -> str:
|
|
149
|
+
ts = sorted(turns)
|
|
150
|
+
return ("turn " if len(ts) == 1 else "turns ") + ", ".join(str(t) for t in ts) if ts else ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BasicFixes:
|
|
154
|
+
name = "built-in"
|
|
155
|
+
|
|
156
|
+
def find(self, session: Session, pricer) -> list[Fix]:
|
|
157
|
+
return [f for f in (unused_tools(session, pricer), repeated_reads(session, pricer),
|
|
158
|
+
confirm_turns(session, pricer)) if f]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_engines() -> list:
|
|
162
|
+
engines: list = [BasicFixes()]
|
|
163
|
+
# ---- PLUG-IN POINT (see the module docstring). Only loaded when the user names it explicitly.
|
|
164
|
+
mod = os.environ.get("SESSIONCOST_ENGINE", "").strip()
|
|
165
|
+
if mod:
|
|
166
|
+
try:
|
|
167
|
+
engines.append(importlib.import_module(mod).Engine())
|
|
168
|
+
except Exception as e: # an engine problem must never break the report
|
|
169
|
+
print(f"sessioncost: fix engine {mod!r} not loaded: {e}")
|
|
170
|
+
return engines
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def find(session: Session, pricer, engines: list | None = None) -> list[Fix]:
|
|
174
|
+
out: list[Fix] = []
|
|
175
|
+
for eng in engines if engines is not None else load_engines():
|
|
176
|
+
for f in eng.find(session, pricer) or []:
|
|
177
|
+
if f.source == "built-in" and getattr(eng, "name", "built-in") != "built-in":
|
|
178
|
+
f.source = eng.name
|
|
179
|
+
out.append(f)
|
|
180
|
+
out = [f for f in out if f.saving_usd > 0]
|
|
181
|
+
out.sort(key=lambda f: (-f.saving_tokens, -f.saving_usd)) # tokens cut first; money follows
|
|
182
|
+
return out[:MAX_FIXES]
|
sessioncost/locate.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Finding Claude Code, Codex, Antigravity and Cursor sessions on this machine.
|
|
2
|
+
|
|
3
|
+
Codex keeps every thread under $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl (CODEX_HOME defaults to
|
|
4
|
+
~/.codex); the project is the `cwd` in each file's first row. Helper threads (they carry a parent thread id) are
|
|
5
|
+
read with their main session, never listed on their own.
|
|
6
|
+
|
|
7
|
+
Antigravity keeps one SQLite file per conversation under ~/.gemini/antigravity-ide/conversations (or
|
|
8
|
+
$ANTIGRAVITY_HOME/conversations); the project is the workspace stored inside it.
|
|
9
|
+
|
|
10
|
+
Cursor keeps every chat in one SQLite file (see readers/cursor.py for where); a chat stands in for a session file
|
|
11
|
+
as a `CursorChat`. Helper chats are read with the chat that started them, never listed on their own.
|
|
12
|
+
|
|
13
|
+
Claude Code keeps one folder per project under ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects). The folder name
|
|
14
|
+
is the project path with every character other than a letter or digit replaced by '-', e.g. c:\\Users\\x\\my_proj
|
|
15
|
+
-> c--Users-x-my-proj. Matching is case-insensitive (Windows drive letters come in both cases).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def claude_home() -> Path:
|
|
27
|
+
base = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
28
|
+
return Path(base).expanduser() if base else Path.home() / ".claude"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def codex_home() -> Path:
|
|
32
|
+
base = os.environ.get("CODEX_HOME")
|
|
33
|
+
return Path(base).expanduser() if base else Path.home() / ".codex"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def session_id(path: Path) -> str:
|
|
37
|
+
"""The id a user types: a Claude Code file is named by it; a Codex file ends with it."""
|
|
38
|
+
m = re.search(r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$", path.stem)
|
|
39
|
+
return m.group(1) if m and path.stem.startswith("rollout-") else path.stem # (a CursorChat's stem is its id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def short_id(path: Path) -> str:
|
|
43
|
+
return session_id(path)[:8]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def project_key(path: str | Path) -> str:
|
|
47
|
+
return re.sub(r"[^A-Za-z0-9]", "-", str(Path(path).expanduser().resolve()))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def project_dir(project: str | Path | None = None, home: Path | None = None) -> Path | None:
|
|
51
|
+
root = (home or claude_home()) / "projects"
|
|
52
|
+
if not root.is_dir():
|
|
53
|
+
return None
|
|
54
|
+
want = project_key(project or os.getcwd()).lower()
|
|
55
|
+
for d in root.iterdir():
|
|
56
|
+
if d.is_dir() and d.name.lower() == want:
|
|
57
|
+
return d
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def last_stamp(path: Path, tail_bytes: int = 262144, types=("user", "assistant")) -> datetime | None:
|
|
62
|
+
"""The timestamp of the newest row that has one. Reading only the end of the file keeps this fast.
|
|
63
|
+
(Newest by file time can pick an old chat that was merely reopened; its last row stays old.)"""
|
|
64
|
+
size = path.stat().st_size
|
|
65
|
+
with path.open("rb") as f:
|
|
66
|
+
f.seek(max(0, size - tail_bytes))
|
|
67
|
+
lines = f.read().decode("utf-8", "replace").splitlines()
|
|
68
|
+
for line in reversed(lines):
|
|
69
|
+
if '"timestamp"' not in line:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
row = json.loads(line)
|
|
73
|
+
except ValueError:
|
|
74
|
+
continue
|
|
75
|
+
if types and row.get("type") not in types:
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
return datetime.fromisoformat(str(row["timestamp"]).replace("Z", "+00:00"))
|
|
79
|
+
except (KeyError, ValueError):
|
|
80
|
+
continue
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _same_dir(a: str | Path, b: str | Path) -> bool:
|
|
85
|
+
try:
|
|
86
|
+
return os.path.normcase(str(Path(a).expanduser().resolve())) == os.path.normcase(str(Path(b).expanduser().resolve()))
|
|
87
|
+
except OSError:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def codex_sessions(project: str | Path | None = None, home: Path | None = None) -> list[Path]:
|
|
92
|
+
"""Main Codex threads whose working folder is the project."""
|
|
93
|
+
root = (home or codex_home()) / "sessions"
|
|
94
|
+
if not root.is_dir():
|
|
95
|
+
return []
|
|
96
|
+
want = project or os.getcwd()
|
|
97
|
+
out = []
|
|
98
|
+
for p in root.glob("*/*/*/rollout-*.jsonl"):
|
|
99
|
+
try:
|
|
100
|
+
with p.open(encoding="utf-8", errors="replace") as f:
|
|
101
|
+
row = json.loads(f.readline())
|
|
102
|
+
except (OSError, ValueError):
|
|
103
|
+
continue
|
|
104
|
+
meta = row.get("payload") if isinstance(row, dict) and row.get("type") == "session_meta" else None
|
|
105
|
+
if isinstance(meta, dict) and not meta.get("parent_thread_id") and meta.get("cwd") and _same_dir(meta["cwd"], want):
|
|
106
|
+
out.append(p)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def antigravity_home() -> Path:
|
|
111
|
+
base = os.environ.get("ANTIGRAVITY_HOME")
|
|
112
|
+
return Path(base).expanduser() if base else Path.home() / ".gemini" / "antigravity-ide"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def antigravity_sessions(project: str | Path | None = None,
|
|
116
|
+
home: Path | None = None) -> list[tuple[datetime | None, Path]]:
|
|
117
|
+
"""Antigravity conversations whose workspace is the project, with the time of their last step."""
|
|
118
|
+
from .readers import _pb
|
|
119
|
+
from .readers.antigravity import _ts, connect, workspace
|
|
120
|
+
root = (home or antigravity_home()) / "conversations"
|
|
121
|
+
if not root.is_dir():
|
|
122
|
+
return []
|
|
123
|
+
want = project or os.getcwd()
|
|
124
|
+
out = []
|
|
125
|
+
for p in root.glob("*.db"):
|
|
126
|
+
try:
|
|
127
|
+
con = connect(p)
|
|
128
|
+
try:
|
|
129
|
+
ws = workspace(con)
|
|
130
|
+
if not ws or not _same_dir(ws, want):
|
|
131
|
+
continue
|
|
132
|
+
row = con.execute("select metadata from steps order by idx desc limit 1").fetchone()
|
|
133
|
+
finally:
|
|
134
|
+
con.close()
|
|
135
|
+
except Exception: # a locked or half-written file must not break the list
|
|
136
|
+
continue
|
|
137
|
+
out.append((_ts(row[0], 1) if row else None, p))
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cursor_sessions(project: str | Path | None = None, db: Path | None = None) -> list[tuple[datetime | None, object]]:
|
|
142
|
+
"""Cursor chats (not helper chats) whose workspace is the project, with their last update time."""
|
|
143
|
+
from .readers.cursor import CursorChat, chats, connect, helper_ids, state_db, workspace
|
|
144
|
+
db = db or state_db()
|
|
145
|
+
if not Path(db).is_file():
|
|
146
|
+
return []
|
|
147
|
+
want = project or os.getcwd()
|
|
148
|
+
try:
|
|
149
|
+
con = connect(db)
|
|
150
|
+
try:
|
|
151
|
+
found = chats(con)
|
|
152
|
+
finally:
|
|
153
|
+
con.close()
|
|
154
|
+
except Exception: # Cursor may hold the file; the other agents must still be listed
|
|
155
|
+
return []
|
|
156
|
+
helpers = helper_ids(found)
|
|
157
|
+
out = []
|
|
158
|
+
for d in found:
|
|
159
|
+
ws = workspace(d)
|
|
160
|
+
if d["composerId"] in helpers or not ws or not _same_dir(ws, want) or not (d.get("fullConversationHeadersOnly") or d.get("conversation")):
|
|
161
|
+
continue
|
|
162
|
+
ms = d.get("lastUpdatedAt") or d.get("createdAt")
|
|
163
|
+
when = datetime.fromtimestamp(ms / 1000).astimezone() if isinstance(ms, (int, float)) else None
|
|
164
|
+
out.append((when, CursorChat(Path(db), str(d["composerId"]))))
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def sessions(project: str | Path | None = None, home: Path | None = None, codex: Path | None = None,
|
|
169
|
+
antigravity: Path | None = None, cursor: Path | None = None) -> list[tuple[datetime, Path]]:
|
|
170
|
+
"""Main sessions of a project (Claude Code, Codex, Antigravity, Cursor), newest first."""
|
|
171
|
+
d = project_dir(project, home)
|
|
172
|
+
found = [(last_stamp(p), p) for p in d.glob("*.jsonl")] if d is not None else []
|
|
173
|
+
found += [(last_stamp(p, types=None), p) for p in codex_sessions(project, codex)]
|
|
174
|
+
found += antigravity_sessions(project, antigravity)
|
|
175
|
+
found += cursor_sessions(project, cursor)
|
|
176
|
+
out = [(t, p) for t, p in found if t is not None]
|
|
177
|
+
out.sort(key=lambda x: x[0], reverse=True)
|
|
178
|
+
return out
|
sessioncost/model.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The one in-memory shape every reader produces.
|
|
2
|
+
|
|
3
|
+
A reader for any coding agent (Claude Code today; Codex, Antigravity, Cursor later) turns that agent's own log
|
|
4
|
+
into a `Session`. Everything after the reader (cost, drill-down, fixes, the report) works on a `Session` only, so
|
|
5
|
+
a new agent needs a reader and nothing else.
|
|
6
|
+
|
|
7
|
+
Session -> Turns (each starts with something the user really typed) -> Calls (one model call each, counted once)
|
|
8
|
+
-> Actions (the tools a call asked for). Helpers (subagents) keep their own calls.
|
|
9
|
+
|
|
10
|
+
A number the log does not hold stays None; the report then says "not in this log" instead of guessing.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Usage:
|
|
20
|
+
"""Token counts of one model call, split by how each class is priced."""
|
|
21
|
+
fresh_input: int = 0 # new input, not cached
|
|
22
|
+
cache_write_5m: int = 0 # new input written to the 5-minute cache
|
|
23
|
+
cache_write_1h: int = 0 # new input written to the 1-hour cache
|
|
24
|
+
cache_read: int = 0 # input re-read from the cache
|
|
25
|
+
output: int = 0 # everything the model wrote, hidden thinking included
|
|
26
|
+
thinking: int | None = None # the hidden-thinking share of `output`; None = the log does not separate it
|
|
27
|
+
write_split_known: bool = True # False: the log does not say which cache writes were 5-minute or 1-hour
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def cache_write(self) -> int:
|
|
31
|
+
return self.cache_write_5m + self.cache_write_1h
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def new_input(self) -> int:
|
|
35
|
+
return self.fresh_input + self.cache_write
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def context(self) -> int:
|
|
39
|
+
"""Everything sent to the model in this call."""
|
|
40
|
+
return self.fresh_input + self.cache_write + self.cache_read
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Action:
|
|
45
|
+
"""One tool use a call asked for."""
|
|
46
|
+
id: str
|
|
47
|
+
tool: str # the agent's own tool name, e.g. "Read"
|
|
48
|
+
kind: str # read | search | edit | write | command | web | helper | question | skill | plan | other
|
|
49
|
+
label: str # plain words, e.g. "read `plan.md`"
|
|
50
|
+
target: str = "" # the file, command, query or url
|
|
51
|
+
args_chars: int = 0 # size of what the model wrote into the call
|
|
52
|
+
written_chars: int = 0 # text put into a file (write / edit)
|
|
53
|
+
result_chars: int = 0 # size of what came back
|
|
54
|
+
result_hash: str = "" # fingerprint of the result (to spot an unchanged re-read); the text is not kept
|
|
55
|
+
is_error: bool = False
|
|
56
|
+
ts: datetime | None = None
|
|
57
|
+
result_ts: datetime | None = None
|
|
58
|
+
answer_text: str = "" # question cards: the answers given
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Call:
|
|
63
|
+
n: int # 1-based within its thread (main or one helper)
|
|
64
|
+
msg_id: str
|
|
65
|
+
agent: str # "main" or the helper's id
|
|
66
|
+
model: str
|
|
67
|
+
usage: Usage
|
|
68
|
+
ts_first: datetime | None = None
|
|
69
|
+
ts_last: datetime | None = None
|
|
70
|
+
turn: int = 0 # the turn it belongs to
|
|
71
|
+
text_chars: int = 0 # visible text written to the user
|
|
72
|
+
first_text: str = "" # the first sentence it wrote (for plain-words labels)
|
|
73
|
+
thinking_chars: int = 0 # visible thinking text (usually empty: thinking is hidden)
|
|
74
|
+
actions: list[Action] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Event:
|
|
79
|
+
"""Something that entered the conversation that is not a model call: what the user typed, a command, a question
|
|
80
|
+
card answer, or text the agent's harness injected (labelled, never shown as typed by the user)."""
|
|
81
|
+
kind: str # typed | command | queued | answer | injected | instructions
|
|
82
|
+
label: str # plain words: "you typed", "editor selection (added by the IDE)", ...
|
|
83
|
+
text: str # cleaned text (short)
|
|
84
|
+
chars: int # full size of what entered
|
|
85
|
+
ts: datetime | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Turn:
|
|
90
|
+
n: int
|
|
91
|
+
kind: str # typed | command | queued | start (calls before any user message)
|
|
92
|
+
text: str # what the user asked, cleaned
|
|
93
|
+
ts: datetime | None = None
|
|
94
|
+
waited_s: float = 0.0 # time the agent waited for the user before this turn started
|
|
95
|
+
events: list[Event] = field(default_factory=list)
|
|
96
|
+
calls: list[Call] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Helper:
|
|
101
|
+
id: str
|
|
102
|
+
description: str
|
|
103
|
+
agent_type: str
|
|
104
|
+
calls: list[Call] = field(default_factory=list)
|
|
105
|
+
turn: int = 0 # the main turn it ran in
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class SetupPart:
|
|
110
|
+
what: str
|
|
111
|
+
chars: int
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Setup:
|
|
116
|
+
"""What the agent sends with every call before anyone says a word: system prompt, tool definitions, lists."""
|
|
117
|
+
known: bool = False
|
|
118
|
+
parts: list[SetupPart] = field(default_factory=list)
|
|
119
|
+
tools: list[SetupPart] = field(default_factory=list) # one entry per tool definition
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def chars(self) -> int:
|
|
123
|
+
return sum(p.chars for p in self.parts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Fix:
|
|
128
|
+
"""One fix, with a saving measured from this session's own calls."""
|
|
129
|
+
id: str
|
|
130
|
+
title: str
|
|
131
|
+
why: str # one or two plain sentences
|
|
132
|
+
saving_usd: float
|
|
133
|
+
saving_tokens: int
|
|
134
|
+
where: str = "" # "turn 4", "calls 3, 9, 12"
|
|
135
|
+
turns: list[int] = field(default_factory=list)
|
|
136
|
+
items: list[str] = field(default_factory=list)
|
|
137
|
+
source: str = "built-in" # which engine found it
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Session:
|
|
142
|
+
agent: str # "claude-code"
|
|
143
|
+
session_id: str
|
|
144
|
+
source: str
|
|
145
|
+
title: str = ""
|
|
146
|
+
cwd: str = ""
|
|
147
|
+
model: str = ""
|
|
148
|
+
agent_version: str = ""
|
|
149
|
+
started: datetime | None = None
|
|
150
|
+
ended: datetime | None = None
|
|
151
|
+
turns: list[Turn] = field(default_factory=list)
|
|
152
|
+
helpers: list[Helper] = field(default_factory=list)
|
|
153
|
+
setup: Setup = field(default_factory=Setup)
|
|
154
|
+
own_cost_usd: float | None = None # the agent's own running total, when its log carries one
|
|
155
|
+
billed_per_token: bool = True # False: the agent's plan never bills per token (money is API-equivalent)
|
|
156
|
+
tokens_exact: bool = True # False: the log has no token counts; they are estimated from text size
|
|
157
|
+
notes: list[str] = field(default_factory=list)
|
|
158
|
+
fixes: list[Fix] = field(default_factory=list) # filled by the fix engines (see fixes.py)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def calls(self) -> list[Call]:
|
|
162
|
+
"""Main-thread calls, in order."""
|
|
163
|
+
return [c for t in self.turns for c in t.calls]
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def helper_calls(self) -> list[Call]:
|
|
167
|
+
return [c for h in self.helpers for c in h.calls]
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def all_calls(self) -> list[Call]:
|
|
171
|
+
return self.calls + self.helper_calls
|
|
172
|
+
|
|
173
|
+
def actions(self, include_helpers: bool = True) -> list[Action]:
|
|
174
|
+
cs = self.all_calls if include_helpers else self.calls
|
|
175
|
+
return [a for c in cs for a in c.actions]
|
sessioncost/prices.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_read_me": "USD per million tokens. cache_write_5m / cache_write_1h = new input written to the 5-minute / 1-hour prompt cache; cache_read = input re-read from the cache. 'checked': true = the rate was checked against the source below on as_of; false = a guess, shown as an estimate. Override any entry in ~/.sessioncost/prices.json (merged) or with --prices <file>.",
|
|
3
|
+
"as_of": "2026-10-08",
|
|
4
|
+
"source": "Anthropic's first-party API price list (model table cached 2026-09-25; cache writes 1.25x input for 5 minutes, 2x for 1 hour; cache reads 0.1x input except where listed). claude-opus-5-5 and claude-haiku-4-5 also cross-checked against Claude Code's own logged cost to the cent. OpenAI (Codex) rows: list prices from third-party price trackers on 2026-10-08, NOT checked against OpenAI's own page (sources disagree; GPT-5.6 Sol has a temporary lower promo rate; long prompts over ~272K tokens may cost more), so they are shown as estimates. OpenAI does not charge extra to write the cache, so the cache-write rates equal input. Unlisted gpt-* / codex-* models use default-openai. Gemini (Antigravity) rows: introductory list prices from third-party trackers on 2026-10-08 (said to run to 2026-12-31, then $1.50 / $0.15 / $7.50), NOT checked against Google's own page; unlisted gemini-* models use default-gemini. Antigravity is not billed per token, so its money is always shown as API-equivalent.",
|
|
5
|
+
"models": {
|
|
6
|
+
"default": {"input": 5.0, "output": 25.0, "cache_write_5m": 6.25, "cache_write_1h": 10.0, "cache_read": 0.5, "checked": false},
|
|
7
|
+
"claude-fable-5-1": {"input": 10.0, "output": 50.0, "cache_write_5m": 12.5, "cache_write_1h": 20.0, "cache_read": 0.25, "checked": true},
|
|
8
|
+
"claude-mythos-5-1": {"input": 10.0, "output": 50.0, "cache_write_5m": 12.5, "cache_write_1h": 20.0, "cache_read": 0.25, "checked": true},
|
|
9
|
+
"claude-fable-5": {"input": 10.0, "output": 50.0, "cache_write_5m": 12.5, "cache_write_1h": 20.0, "cache_read": 1.0, "checked": true},
|
|
10
|
+
"claude-mythos-5": {"input": 10.0, "output": 50.0, "cache_write_5m": 12.5, "cache_write_1h": 20.0, "cache_read": 1.0, "checked": true},
|
|
11
|
+
"claude-opus-5-5": {"input": 4.0, "output": 20.0, "cache_write_5m": 5.0, "cache_write_1h": 8.0, "cache_read": 0.2, "checked": true},
|
|
12
|
+
"claude-opus-5": {"input": 5.0, "output": 25.0, "cache_write_5m": 6.25, "cache_write_1h": 10.0, "cache_read": 0.5, "checked": true},
|
|
13
|
+
"claude-opus-4-8": {"input": 5.0, "output": 25.0, "cache_write_5m": 6.25, "cache_write_1h": 10.0, "cache_read": 0.5, "checked": true},
|
|
14
|
+
"claude-opus-4-7": {"input": 5.0, "output": 25.0, "cache_write_5m": 6.25, "cache_write_1h": 10.0, "cache_read": 0.5, "checked": true},
|
|
15
|
+
"claude-opus-4-6": {"input": 5.0, "output": 25.0, "cache_write_5m": 6.25, "cache_write_1h": 10.0, "cache_read": 0.5, "checked": true},
|
|
16
|
+
"claude-sonnet-5-5": {"input": 2.0, "output": 10.0, "cache_write_5m": 2.5, "cache_write_1h": 4.0, "cache_read": 0.2, "checked": true},
|
|
17
|
+
"claude-sonnet-5": {"input": 2.0, "output": 10.0, "cache_write_5m": 2.5, "cache_write_1h": 4.0, "cache_read": 0.2, "checked": true},
|
|
18
|
+
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0, "cache_write_5m": 3.75, "cache_write_1h": 6.0, "cache_read": 0.3, "checked": true},
|
|
19
|
+
"claude-haiku-4-5": {"input": 1.0, "output": 5.0, "cache_write_5m": 1.25, "cache_write_1h": 2.0, "cache_read": 0.1, "checked": true},
|
|
20
|
+
"default-openai": {"input": 5.0, "output": 30.0, "cache_write_5m": 5.0, "cache_write_1h": 5.0, "cache_read": 0.5, "checked": false},
|
|
21
|
+
"gpt-5.5": {"input": 5.0, "output": 30.0, "cache_write_5m": 5.0, "cache_write_1h": 5.0, "cache_read": 0.5, "checked": false},
|
|
22
|
+
"gpt-5.6-sol": {"input": 5.0, "output": 30.0, "cache_write_5m": 5.0, "cache_write_1h": 5.0, "cache_read": 0.5, "checked": false},
|
|
23
|
+
"gpt-5.6-terra": {"input": 2.0, "output": 12.0, "cache_write_5m": 2.0, "cache_write_1h": 2.0, "cache_read": 0.2, "checked": false},
|
|
24
|
+
"gpt-5.6-luna": {"input": 0.2, "output": 1.2, "cache_write_5m": 0.2, "cache_write_1h": 0.2, "cache_read": 0.02, "checked": false},
|
|
25
|
+
"gpt-6-astra": {"input": 10.0, "output": 50.0, "cache_write_5m": 10.0, "cache_write_1h": 10.0, "cache_read": 1.0, "checked": false},
|
|
26
|
+
"gpt-6-sol": {"input": 2.0, "output": 10.0, "cache_write_5m": 2.0, "cache_write_1h": 2.0, "cache_read": 0.2, "checked": false},
|
|
27
|
+
"gpt-6-luna": {"input": 0.1, "output": 0.5, "cache_write_5m": 0.1, "cache_write_1h": 0.1, "cache_read": 0.01, "checked": false},
|
|
28
|
+
"gpt-5.3-codex": {"input": 1.75, "output": 14.0, "cache_write_5m": 1.75, "cache_write_1h": 1.75, "cache_read": 0.175, "checked": false},
|
|
29
|
+
"gpt-5.2": {"input": 1.75, "output": 14.0, "cache_write_5m": 1.75, "cache_write_1h": 1.75, "cache_read": 0.175, "checked": false},
|
|
30
|
+
"default-gemini": {"input": 0.75, "output": 3.75, "cache_write_5m": 0.75, "cache_write_1h": 0.75, "cache_read": 0.075, "checked": false},
|
|
31
|
+
"gemini-3.8-flash": {"input": 0.75, "output": 3.75, "cache_write_5m": 0.75, "cache_write_1h": 0.75, "cache_read": 0.075, "checked": false},
|
|
32
|
+
"gemini-3.8-flash-n": {"input": 0.75, "output": 3.75, "cache_write_5m": 0.75, "cache_write_1h": 0.75, "cache_read": 0.075, "checked": false},
|
|
33
|
+
"gemini-3.7-flash": {"input": 0.75, "output": 3.75, "cache_write_5m": 0.75, "cache_write_1h": 0.75, "cache_read": 0.075, "checked": false},
|
|
34
|
+
"gemini-3.6-flash": {"input": 0.75, "output": 3.75, "cache_write_5m": 0.75, "cache_write_1h": 0.75, "cache_read": 0.075, "checked": false}
|
|
35
|
+
}
|
|
36
|
+
}
|
sessioncost/prices.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Prices: built-in table, merged with the user's ~/.sessioncost/prices.json, then with --prices <file>."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .model import Usage
|
|
9
|
+
|
|
10
|
+
HERE = Path(__file__).parent
|
|
11
|
+
USER_FILE = Path.home() / ".sessioncost" / "prices.json"
|
|
12
|
+
CLASSES = ("input", "cache_write_5m", "cache_write_1h", "cache_read", "output")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _merge(base: dict, extra: dict) -> dict:
|
|
16
|
+
out = dict(base)
|
|
17
|
+
for k, v in extra.items():
|
|
18
|
+
if k == "models" and isinstance(v, dict):
|
|
19
|
+
models = {m: dict(r) for m, r in base.get("models", {}).items()}
|
|
20
|
+
for m, r in v.items():
|
|
21
|
+
models[m] = {**models.get(m, {}), **r}
|
|
22
|
+
out["models"] = models
|
|
23
|
+
else:
|
|
24
|
+
out[k] = v
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(extra_file: str | None = None, user_file: Path | None = USER_FILE) -> dict:
|
|
29
|
+
prices = json.loads((HERE / "prices.json").read_text(encoding="utf-8"))
|
|
30
|
+
for f in (user_file, Path(extra_file).expanduser() if extra_file else None):
|
|
31
|
+
if f and Path(f).is_file():
|
|
32
|
+
prices = _merge(prices, json.loads(Path(f).read_text(encoding="utf-8")))
|
|
33
|
+
return prices
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalise(model: str) -> str:
|
|
37
|
+
"""'claude-opus-5[1m]' -> 'claude-opus-5'; 'claude-haiku-4-5-20251001' -> 'claude-haiku-4-5'."""
|
|
38
|
+
m = re.sub(r"\[.*?\]", "", (model or "").strip().lower())
|
|
39
|
+
return re.sub(r"-\d{8}$", "", m)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rates(model: str, prices: dict) -> tuple[dict, bool, str]:
|
|
43
|
+
"""(rates per million, checked?, the price-table key used)."""
|
|
44
|
+
table = prices["models"]
|
|
45
|
+
for key in (model, normalise(model)):
|
|
46
|
+
if key in table:
|
|
47
|
+
r = table[key]
|
|
48
|
+
return r, bool(r.get("checked")), key
|
|
49
|
+
m = normalise(model)
|
|
50
|
+
key = "default-openai" if re.match(r"(gpt|codex|o\d)", m) else "default-gemini" if m.startswith("gemini") else ""
|
|
51
|
+
key = key if key in table else "default"
|
|
52
|
+
return table[key], False, key
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cost(u: Usage, r: dict) -> dict:
|
|
56
|
+
"""USD per token class for one call. Every token is counted in exactly one class."""
|
|
57
|
+
return {
|
|
58
|
+
"input": u.fresh_input * r["input"] / 1e6,
|
|
59
|
+
"cache_write_5m": u.cache_write_5m * r["cache_write_5m"] / 1e6,
|
|
60
|
+
"cache_write_1h": u.cache_write_1h * r["cache_write_1h"] / 1e6,
|
|
61
|
+
"cache_read": u.cache_read * r["cache_read"] / 1e6,
|
|
62
|
+
"output": u.output * r["output"] / 1e6,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def new_input_rate(u: Usage, r: dict) -> float:
|
|
67
|
+
"""Average USD per million for the new (not re-read) input of this call."""
|
|
68
|
+
n = u.new_input
|
|
69
|
+
if not n:
|
|
70
|
+
return r["cache_write_1h"] if u.cache_read else r["input"]
|
|
71
|
+
return (u.fresh_input * r["input"] + u.cache_write_5m * r["cache_write_5m"]
|
|
72
|
+
+ u.cache_write_1h * r["cache_write_1h"]) / n
|