tokmeter 0.1.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.
- tokmeter/__init__.py +885 -0
- tokmeter/dashboard.html +1996 -0
- tokmeter-0.1.0.dist-info/METADATA +170 -0
- tokmeter-0.1.0.dist-info/RECORD +7 -0
- tokmeter-0.1.0.dist-info/WHEEL +4 -0
- tokmeter-0.1.0.dist-info/entry_points.txt +2 -0
- tokmeter-0.1.0.dist-info/licenses/LICENSE +21 -0
tokmeter/__init__.py
ADDED
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = ["polars>=1.0"]
|
|
5
|
+
# ///
|
|
6
|
+
"""tokmeter — local dashboard for Claude Code & Codex CLI token cost.
|
|
7
|
+
|
|
8
|
+
Reads ~/.claude/projects/**/*.jsonl and ~/.codex/sessions/**/*.jsonl,
|
|
9
|
+
aggregates with polars, serves a single-file HTML on localhost.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
uvx tokmeter # one-shot run from PyPI
|
|
13
|
+
uv run tokmeter.py # single-file mode (PEP 723)
|
|
14
|
+
python3 tokmeter.py # if polars already installed
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import bisect
|
|
18
|
+
import glob
|
|
19
|
+
import http.server
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import socketserver
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import webbrowser
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from datetime import datetime, timedelta, timezone
|
|
30
|
+
from functools import lru_cache
|
|
31
|
+
from urllib.parse import parse_qs, urlparse
|
|
32
|
+
|
|
33
|
+
import sqlite3
|
|
34
|
+
import polars as pl
|
|
35
|
+
|
|
36
|
+
ROOT = os.path.expanduser("~/.claude/projects")
|
|
37
|
+
CODEX_SESSIONS = os.path.expanduser("~/.codex/sessions")
|
|
38
|
+
CODEX_LOGS_DB = os.path.expanduser("~/.codex/logs_2.sqlite")
|
|
39
|
+
HOME = os.path.expanduser("~")
|
|
40
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
41
|
+
DASHBOARD_HTML = os.path.join(SCRIPT_DIR, "dashboard.html")
|
|
42
|
+
DEFAULT_PORT = 8765
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_html():
|
|
46
|
+
with open(DASHBOARD_HTML, encoding="utf-8") as f:
|
|
47
|
+
return f.read()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@lru_cache(maxsize=8192)
|
|
51
|
+
def _find_dir_match(base: str, candidate: str):
|
|
52
|
+
"""Find a subdirectory of `base` matching `candidate`, treating '-' and '_'
|
|
53
|
+
as equivalent. Claude Code's project encoding replaces both `/` and `_` with `-`,
|
|
54
|
+
so when reversing we accept either literal-dash or underscore-canonical forms."""
|
|
55
|
+
if not os.path.isdir(base):
|
|
56
|
+
return None
|
|
57
|
+
full_direct = os.path.join(base, candidate)
|
|
58
|
+
if os.path.isdir(full_direct):
|
|
59
|
+
return candidate
|
|
60
|
+
target = candidate.replace("-", "_")
|
|
61
|
+
try:
|
|
62
|
+
entries = os.listdir(base)
|
|
63
|
+
except OSError:
|
|
64
|
+
return None
|
|
65
|
+
for entry in entries:
|
|
66
|
+
if entry.replace("-", "_") == target and os.path.isdir(os.path.join(base, entry)):
|
|
67
|
+
return entry
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@lru_cache(maxsize=2048)
|
|
72
|
+
def decode_project_name(encoded: str) -> str:
|
|
73
|
+
"""`-Users-nek0-Code-Claw-proxy` → `~/Code/Claw_proxy` by greedy filesystem match.
|
|
74
|
+
|
|
75
|
+
Claude Code encodes project paths as `<path>` with both `/` and `_` collapsed to `-`.
|
|
76
|
+
Decoding is ambiguous in general, so at each position we take the longest segment
|
|
77
|
+
that resolves to an existing directory (treating `-`/`_` as equivalent), falling
|
|
78
|
+
back to single-part literal when nothing matches."""
|
|
79
|
+
if not encoded or not encoded.startswith("-"):
|
|
80
|
+
return encoded
|
|
81
|
+
parts = encoded[1:].split("-")
|
|
82
|
+
if not parts:
|
|
83
|
+
return encoded
|
|
84
|
+
path = "/"
|
|
85
|
+
i = 0
|
|
86
|
+
n = len(parts)
|
|
87
|
+
MAX_JOIN = 16
|
|
88
|
+
while i < n:
|
|
89
|
+
matched_len = 0
|
|
90
|
+
matched_dir = None
|
|
91
|
+
for j in range(min(MAX_JOIN, n - i), 0, -1):
|
|
92
|
+
candidate = "-".join(parts[i:i + j])
|
|
93
|
+
actual = _find_dir_match(path, candidate)
|
|
94
|
+
if actual is not None:
|
|
95
|
+
matched_len = j
|
|
96
|
+
matched_dir = actual
|
|
97
|
+
break
|
|
98
|
+
if matched_len == 0:
|
|
99
|
+
matched_len = 1
|
|
100
|
+
matched_dir = parts[i]
|
|
101
|
+
path = os.path.join(path, matched_dir)
|
|
102
|
+
i += matched_len
|
|
103
|
+
if path == HOME:
|
|
104
|
+
return "~"
|
|
105
|
+
if path.startswith(HOME + "/"):
|
|
106
|
+
return "~/" + path[len(HOME) + 1:]
|
|
107
|
+
return path
|
|
108
|
+
|
|
109
|
+
PRICE = {
|
|
110
|
+
"claude-opus-4-7": {"in":5.00,"out":25.00,"cw5m":6.25,"cw1h":10.00,"cr":0.50},
|
|
111
|
+
"claude-opus-4-6": {"in":5.00,"out":25.00,"cw5m":6.25,"cw1h":10.00,"cr":0.50},
|
|
112
|
+
"claude-opus-4-5": {"in":5.00,"out":25.00,"cw5m":6.25,"cw1h":10.00,"cr":0.50},
|
|
113
|
+
"claude-opus-4-1": {"in":15.00,"out":75.00,"cw5m":18.75,"cw1h":30.00,"cr":1.50},
|
|
114
|
+
"claude-opus-4": {"in":15.00,"out":75.00,"cw5m":18.75,"cw1h":30.00,"cr":1.50},
|
|
115
|
+
"claude-sonnet-4-6": {"in":3.00,"out":15.00,"cw5m":3.75,"cw1h":6.00,"cr":0.30},
|
|
116
|
+
"claude-sonnet-4-5": {"in":3.00,"out":15.00,"cw5m":3.75,"cw1h":6.00,"cr":0.30},
|
|
117
|
+
"claude-sonnet-4": {"in":3.00,"out":15.00,"cw5m":3.75,"cw1h":6.00,"cr":0.30},
|
|
118
|
+
"claude-haiku-4-5": {"in":1.00,"out":5.00,"cw5m":1.25,"cw1h":2.00,"cr":0.10},
|
|
119
|
+
"claude-haiku-3-5": {"in":0.80,"out":4.00,"cw5m":1.00,"cw1h":1.60,"cr":0.08},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def normalize_model(m: str) -> str:
|
|
123
|
+
m = (m or "unknown").lower()
|
|
124
|
+
for suf in ("[1m]","-20251001","-20250929","-20250522"):
|
|
125
|
+
if m.endswith(suf):
|
|
126
|
+
m = m[:-len(suf)]
|
|
127
|
+
return m
|
|
128
|
+
|
|
129
|
+
def price_for(model):
|
|
130
|
+
if model in PRICE: return PRICE[model]
|
|
131
|
+
for k, v in PRICE.items():
|
|
132
|
+
if model.startswith(k): return v
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def cost_of(model, in_t, out_t, cw5, cw1, cr):
|
|
136
|
+
p = price_for(model)
|
|
137
|
+
if not p: return 0.0
|
|
138
|
+
return (in_t*p["in"] + out_t*p["out"] + cw5*p["cw5m"] + cw1*p["cw1h"] + cr*p["cr"]) / 1_000_000
|
|
139
|
+
|
|
140
|
+
# ---------- in-memory cache, refreshed by file mtime/size ----------
|
|
141
|
+
_lock = threading.Lock()
|
|
142
|
+
# path -> { mtime, size, records: [ (msg_id, ts_ms, model, project, is_sub, in, out, cw5m, cw1h, cr) ] }
|
|
143
|
+
_files = {}
|
|
144
|
+
_last_full_refresh = 0.0
|
|
145
|
+
# Sorted/dedup cache rebuilt only when files change — avoids re-sorting 50k+ rows per request.
|
|
146
|
+
_sorted_cache = None # list of records, ascending by ts
|
|
147
|
+
_sorted_ts_cache = None # parallel list of ts (ms) for bisect
|
|
148
|
+
|
|
149
|
+
def _cc_price(model: str):
|
|
150
|
+
"""Look up Claude Code price by prefix. Returns dict or None."""
|
|
151
|
+
m = model.lower()
|
|
152
|
+
for p in PRICE_LIST:
|
|
153
|
+
if m.startswith(p["prefix"]):
|
|
154
|
+
return p
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
# Record schema (22 columns) shared by Claude Code and Codex parsers:
|
|
158
|
+
RECORD_SCHEMA = [
|
|
159
|
+
("msg_id", pl.Utf8), ("ts", pl.Int64),
|
|
160
|
+
("model", pl.Utf8), ("project", pl.Utf8),
|
|
161
|
+
("is_sub", pl.Int8), ("source", pl.Utf8), # 'cc' | 'codex'
|
|
162
|
+
("in_t", pl.Int64), ("cached_t", pl.Int64),
|
|
163
|
+
("out_t", pl.Int64), ("reason_t", pl.Int64),
|
|
164
|
+
("cw5m", pl.Int64), ("cw1h", pl.Int64), ("cr", pl.Int64),
|
|
165
|
+
("tier", pl.Utf8), ("long_ctx", pl.Int8),
|
|
166
|
+
# Pre-computed per-row cost components:
|
|
167
|
+
("cost_in", pl.Float64),
|
|
168
|
+
("cost_cached", pl.Float64),
|
|
169
|
+
("cost_out_text", pl.Float64),
|
|
170
|
+
("cost_out_reason", pl.Float64),
|
|
171
|
+
("cost_cw5m", pl.Float64),
|
|
172
|
+
("cost_cw1h", pl.Float64),
|
|
173
|
+
("cost_cr", pl.Float64),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
def parse_file(path):
|
|
177
|
+
"""Claude Code parser. Emits records in RECORD_SCHEMA shape."""
|
|
178
|
+
rel = os.path.relpath(path, ROOT)
|
|
179
|
+
raw_project = rel.split(os.sep)[0]
|
|
180
|
+
project = decode_project_name(raw_project)
|
|
181
|
+
is_sub = (os.sep + "subagents" + os.sep) in path
|
|
182
|
+
out = []
|
|
183
|
+
try:
|
|
184
|
+
with open(path, errors="replace") as f:
|
|
185
|
+
for line in f:
|
|
186
|
+
if '"usage"' not in line:
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
rec = json.loads(line)
|
|
190
|
+
except Exception:
|
|
191
|
+
continue
|
|
192
|
+
if rec.get("type") != "assistant":
|
|
193
|
+
continue
|
|
194
|
+
ts_s = rec.get("timestamp")
|
|
195
|
+
if not ts_s:
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
ts = datetime.fromisoformat(ts_s.replace("Z", "+00:00"))
|
|
199
|
+
except Exception:
|
|
200
|
+
continue
|
|
201
|
+
msg = rec.get("message") or {}
|
|
202
|
+
u = msg.get("usage") or {}
|
|
203
|
+
if not u:
|
|
204
|
+
continue
|
|
205
|
+
mid = msg.get("id") or ""
|
|
206
|
+
model = normalize_model(msg.get("model"))
|
|
207
|
+
if model == "<synthetic>" or not model or model == "unknown":
|
|
208
|
+
continue
|
|
209
|
+
in_t = int(u.get("input_tokens") or 0)
|
|
210
|
+
out_t = int(u.get("output_tokens") or 0)
|
|
211
|
+
cr_t = int(u.get("cache_read_input_tokens") or 0)
|
|
212
|
+
cw_total = int(u.get("cache_creation_input_tokens") or 0)
|
|
213
|
+
cw_b = u.get("cache_creation") or {}
|
|
214
|
+
cw5 = int(cw_b.get("ephemeral_5m_input_tokens") or 0)
|
|
215
|
+
cw1 = int(cw_b.get("ephemeral_1h_input_tokens") or 0)
|
|
216
|
+
if cw5 + cw1 == 0 and cw_total > 0:
|
|
217
|
+
cw5 = cw_total
|
|
218
|
+
p = _cc_price(model)
|
|
219
|
+
if p:
|
|
220
|
+
c_in = in_t * p["in"] / 1_000_000
|
|
221
|
+
c_out = out_t * p["out"] / 1_000_000
|
|
222
|
+
c_cw5 = cw5 * p["cw5m"] / 1_000_000
|
|
223
|
+
c_cw1 = cw1 * p["cw1h"] / 1_000_000
|
|
224
|
+
c_cr = cr_t * p["cr"] / 1_000_000
|
|
225
|
+
else:
|
|
226
|
+
c_in = c_out = c_cw5 = c_cw1 = c_cr = 0.0
|
|
227
|
+
out.append((
|
|
228
|
+
mid, int(ts.timestamp() * 1000),
|
|
229
|
+
model, project,
|
|
230
|
+
1 if is_sub else 0, "cc",
|
|
231
|
+
in_t, 0, out_t, 0,
|
|
232
|
+
cw5, cw1, cr_t,
|
|
233
|
+
"standard", 0,
|
|
234
|
+
c_in, 0.0, c_out, 0.0,
|
|
235
|
+
c_cw5, c_cw1, c_cr,
|
|
236
|
+
))
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_codex_rollout(path):
|
|
243
|
+
"""Codex rollout (~/.codex/sessions/...) parser. One record per token_count
|
|
244
|
+
event. Tier is looked up per-event from the sqlite-backed tier map.
|
|
245
|
+
Subagent sessions are flagged via session_meta.payload.agent_role / agent_nickname."""
|
|
246
|
+
out = []
|
|
247
|
+
session_id = None
|
|
248
|
+
project = None
|
|
249
|
+
current_model = None
|
|
250
|
+
is_sub_session = False
|
|
251
|
+
try:
|
|
252
|
+
with open(path, errors="replace") as f:
|
|
253
|
+
for line in f:
|
|
254
|
+
if '"timestamp"' not in line:
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
rec = json.loads(line)
|
|
258
|
+
except Exception:
|
|
259
|
+
continue
|
|
260
|
+
t = rec.get("type")
|
|
261
|
+
payload = rec.get("payload") if isinstance(rec.get("payload"), dict) else {}
|
|
262
|
+
if t == "session_meta":
|
|
263
|
+
session_id = payload.get("id")
|
|
264
|
+
cwd = payload.get("cwd") or ""
|
|
265
|
+
if cwd == HOME:
|
|
266
|
+
project = "~"
|
|
267
|
+
elif cwd.startswith(HOME + "/"):
|
|
268
|
+
project = "~/" + cwd[len(HOME) + 1:]
|
|
269
|
+
else:
|
|
270
|
+
project = cwd or "(unknown)"
|
|
271
|
+
# Subagent sessions carry an explicit agent role/nickname.
|
|
272
|
+
is_sub_session = bool(payload.get("agent_role") or payload.get("agent_nickname"))
|
|
273
|
+
elif t == "turn_context":
|
|
274
|
+
m = payload.get("model")
|
|
275
|
+
if m:
|
|
276
|
+
current_model = m.lower()
|
|
277
|
+
elif t == "event_msg" and payload.get("type") == "token_count":
|
|
278
|
+
info = payload.get("info")
|
|
279
|
+
if not info:
|
|
280
|
+
continue
|
|
281
|
+
last = info.get("last_token_usage") or {}
|
|
282
|
+
in_t = int(last.get("input_tokens") or 0)
|
|
283
|
+
cached_t = int(last.get("cached_input_tokens") or 0)
|
|
284
|
+
out_t = int(last.get("output_tokens") or 0)
|
|
285
|
+
reason_t = int(last.get("reasoning_output_tokens") or 0)
|
|
286
|
+
# Skip context-size snapshot events (all zero usage fields)
|
|
287
|
+
if in_t == 0 and out_t == 0 and cached_t == 0:
|
|
288
|
+
continue
|
|
289
|
+
ts_s = rec.get("timestamp")
|
|
290
|
+
if not ts_s:
|
|
291
|
+
continue
|
|
292
|
+
try:
|
|
293
|
+
ts = datetime.fromisoformat(ts_s.replace("Z", "+00:00"))
|
|
294
|
+
except Exception:
|
|
295
|
+
continue
|
|
296
|
+
ts_ms = int(ts.timestamp() * 1000)
|
|
297
|
+
tier = lookup_tier_for(session_id, ts_ms) if session_id else "standard"
|
|
298
|
+
p = lookup_oai_price(current_model or "")
|
|
299
|
+
long_ctx = 1 if (p and ("in_long" in p) and in_t > LONG_CTX_THRESHOLD) else 0
|
|
300
|
+
cc = codex_cost_components(current_model or "", in_t, cached_t, out_t, reason_t, tier)
|
|
301
|
+
out.append((
|
|
302
|
+
"", # msg_id unused for codex
|
|
303
|
+
ts_ms,
|
|
304
|
+
current_model or "unknown",
|
|
305
|
+
project or "(unknown)",
|
|
306
|
+
1 if is_sub_session else 0, "codex",
|
|
307
|
+
in_t, cached_t, out_t, reason_t,
|
|
308
|
+
0, 0, 0,
|
|
309
|
+
tier, long_ctx,
|
|
310
|
+
cc["cost_in"], cc["cost_cached"], cc["cost_out_text"], cc["cost_out_reason"],
|
|
311
|
+
0.0, 0.0, 0.0,
|
|
312
|
+
))
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
def _scan_dir(root_glob: str, parser, container: dict, force: bool):
|
|
318
|
+
"""Generic mtime+size based incremental rescanner. Returns (n_scanned, n_changed, n_removed)."""
|
|
319
|
+
paths = glob.glob(root_glob, recursive=True)
|
|
320
|
+
changed = []
|
|
321
|
+
seen = set()
|
|
322
|
+
for path in paths:
|
|
323
|
+
seen.add(path)
|
|
324
|
+
try:
|
|
325
|
+
st = os.stat(path)
|
|
326
|
+
except OSError:
|
|
327
|
+
continue
|
|
328
|
+
prev = container.get(path)
|
|
329
|
+
if force or prev is None or prev["mtime"] != st.st_mtime or prev["size"] != st.st_size:
|
|
330
|
+
changed.append((path, st.st_mtime, st.st_size))
|
|
331
|
+
removed = [p for p in container if p not in seen]
|
|
332
|
+
with _lock:
|
|
333
|
+
for p in removed:
|
|
334
|
+
del container[p]
|
|
335
|
+
for path, mt, sz in changed:
|
|
336
|
+
recs = parser(path)
|
|
337
|
+
container[path] = {"mtime": mt, "size": sz, "records": recs}
|
|
338
|
+
return len(paths), len(changed), len(removed)
|
|
339
|
+
|
|
340
|
+
# Codex rollouts get their own bucket (parallel to _files for Claude Code).
|
|
341
|
+
_codex_files = {}
|
|
342
|
+
|
|
343
|
+
def refresh(force=False):
|
|
344
|
+
"""Re-parse only files whose mtime/size changed since last scan, for both
|
|
345
|
+
Claude Code (~/.claude/projects) and Codex (~/.codex/sessions)."""
|
|
346
|
+
global _last_full_refresh
|
|
347
|
+
cc_n, cc_changed, cc_removed = _scan_dir(
|
|
348
|
+
os.path.join(ROOT, "**", "*.jsonl"), parse_file, _files, force)
|
|
349
|
+
|
|
350
|
+
# When Codex sqlite tier-map changes, must invalidate cached tier lookups
|
|
351
|
+
# AND re-parse all codex rollouts (their per-row tier was baked at parse time).
|
|
352
|
+
tier_invalidated = False
|
|
353
|
+
if os.path.exists(CODEX_LOGS_DB):
|
|
354
|
+
try:
|
|
355
|
+
db_mtime = os.stat(CODEX_LOGS_DB).st_mtime
|
|
356
|
+
except OSError:
|
|
357
|
+
db_mtime = None
|
|
358
|
+
if db_mtime is not None and getattr(refresh, "_last_db_mtime", None) != db_mtime:
|
|
359
|
+
invalidate_codex_tier_map()
|
|
360
|
+
refresh._last_db_mtime = db_mtime
|
|
361
|
+
tier_invalidated = True
|
|
362
|
+
|
|
363
|
+
cx_n, cx_changed, cx_removed = _scan_dir(
|
|
364
|
+
os.path.join(CODEX_SESSIONS, "**", "*.jsonl"),
|
|
365
|
+
parse_codex_rollout, _codex_files,
|
|
366
|
+
force or tier_invalidated)
|
|
367
|
+
|
|
368
|
+
with _lock:
|
|
369
|
+
_last_full_refresh = time.time()
|
|
370
|
+
if cc_changed or cc_removed or cx_changed or cx_removed or tier_invalidated:
|
|
371
|
+
invalidate_df()
|
|
372
|
+
return {
|
|
373
|
+
"scanned": cc_n + cx_n,
|
|
374
|
+
"changed": cc_changed + cx_changed,
|
|
375
|
+
"removed": cc_removed + cx_removed,
|
|
376
|
+
"cc": {"scanned": cc_n, "changed": cc_changed, "removed": cc_removed},
|
|
377
|
+
"codex": {"scanned": cx_n, "changed": cx_changed, "removed": cx_removed},
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
def all_records():
|
|
381
|
+
"""Materialize deduped record list (sorted ascending by ts)."""
|
|
382
|
+
seen = set()
|
|
383
|
+
out = []
|
|
384
|
+
with _lock:
|
|
385
|
+
for entry in _files.values():
|
|
386
|
+
for r in entry["records"]:
|
|
387
|
+
mid = r[0]
|
|
388
|
+
if mid:
|
|
389
|
+
if mid in seen:
|
|
390
|
+
continue
|
|
391
|
+
seen.add(mid)
|
|
392
|
+
out.append(r)
|
|
393
|
+
out.sort(key=lambda r: r[1])
|
|
394
|
+
return out
|
|
395
|
+
|
|
396
|
+
# ---------- aggregation for /api/aggregate (polars-vectorized) ----------
|
|
397
|
+
def tz_offset_ms(tz):
|
|
398
|
+
if tz == "UTC": return 0
|
|
399
|
+
if tz == "ET": return -4 * 3600 * 1000 # EDT
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
# Bucket sizes (s).
|
|
403
|
+
SECONDS_IN_BUCKET = {
|
|
404
|
+
"1m": 60, "5m": 300, "15m": 900, "30m": 1800,
|
|
405
|
+
"1h": 3600, "hour": 3600,
|
|
406
|
+
"6h": 21600,
|
|
407
|
+
"1d": 86400, "day": 86400,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
# Pricing: list of prefix → multipliers. Order matters — longer prefixes first
|
|
411
|
+
# so `claude-opus-4-7` matches before `claude-opus-4`.
|
|
412
|
+
PRICE_LIST = [
|
|
413
|
+
{"prefix": "claude-opus-4-7", "in": 5.00, "out": 25.00, "cw5m": 6.25, "cw1h": 10.00, "cr": 0.50},
|
|
414
|
+
{"prefix": "claude-opus-4-6", "in": 5.00, "out": 25.00, "cw5m": 6.25, "cw1h": 10.00, "cr": 0.50},
|
|
415
|
+
{"prefix": "claude-opus-4-5", "in": 5.00, "out": 25.00, "cw5m": 6.25, "cw1h": 10.00, "cr": 0.50},
|
|
416
|
+
{"prefix": "claude-opus-4-1", "in": 15.00, "out": 75.00, "cw5m": 18.75, "cw1h": 30.00, "cr": 1.50},
|
|
417
|
+
{"prefix": "claude-opus-4", "in": 15.00, "out": 75.00, "cw5m": 18.75, "cw1h": 30.00, "cr": 1.50},
|
|
418
|
+
{"prefix": "claude-sonnet-4-6", "in": 3.00, "out": 15.00, "cw5m": 3.75, "cw1h": 6.00, "cr": 0.30},
|
|
419
|
+
{"prefix": "claude-sonnet-4-5", "in": 3.00, "out": 15.00, "cw5m": 3.75, "cw1h": 6.00, "cr": 0.30},
|
|
420
|
+
{"prefix": "claude-sonnet-4", "in": 3.00, "out": 15.00, "cw5m": 3.75, "cw1h": 6.00, "cr": 0.30},
|
|
421
|
+
{"prefix": "claude-haiku-4-5", "in": 1.00, "out": 5.00, "cw5m": 1.25, "cw1h": 2.00, "cr": 0.10},
|
|
422
|
+
{"prefix": "claude-haiku-3-5", "in": 0.80, "out": 4.00, "cw5m": 1.00, "cw1h": 1.60, "cr": 0.08},
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
# ─── OpenAI / Codex pricing ──────────────────────────────────────────────────
|
|
426
|
+
# Per 1M tokens, $USD. Values:
|
|
427
|
+
# in : standard input rate (new tokens, not cached)
|
|
428
|
+
# cached : cached input rate (~10% of input)
|
|
429
|
+
# out : output rate (covers both text + reasoning tokens)
|
|
430
|
+
# in_long : >272K input rate (only for models with the long-context tier)
|
|
431
|
+
# cached_long, out_long: same idea
|
|
432
|
+
# priority : multiplier for priority service tier (Codex "Fast mode")
|
|
433
|
+
# Order: longest prefix first so str.starts_with picks the most specific.
|
|
434
|
+
PRICE_LIST_OAI = [
|
|
435
|
+
{"prefix":"gpt-5.5-pro", "in":30.00,"cached":0.0, "out":180.00, "priority":2.5},
|
|
436
|
+
{"prefix":"gpt-5.5", "in": 5.00,"cached":0.50,"out": 30.00,
|
|
437
|
+
"in_long":10.00,"cached_long":1.00,"out_long":45.00, "priority":2.5},
|
|
438
|
+
{"prefix":"gpt-5.4-pro", "in":30.00,"cached":0.0, "out":180.00,
|
|
439
|
+
"in_long":60.00,"cached_long":0.0,"out_long":270.00, "priority":2.0},
|
|
440
|
+
{"prefix":"gpt-5.4-mini", "in":0.75,"cached":0.075,"out":4.50, "priority":2.0},
|
|
441
|
+
{"prefix":"gpt-5.4-nano", "in":0.20,"cached":0.02, "out":1.25, "priority":2.0},
|
|
442
|
+
{"prefix":"gpt-5.4", "in":2.50,"cached":0.25, "out":15.00,
|
|
443
|
+
"in_long":5.00,"cached_long":0.50,"out_long":22.50, "priority":2.0},
|
|
444
|
+
{"prefix":"gpt-5.3-codex", "in":1.75,"cached":0.175,"out":14.00, "priority":2.0},
|
|
445
|
+
{"prefix":"gpt-5.2-codex", "in":1.75,"cached":0.175,"out":14.00, "priority":2.0},
|
|
446
|
+
{"prefix":"gpt-5.2-pro", "in":21.00,"cached":0.0, "out":168.00, "priority":2.0},
|
|
447
|
+
{"prefix":"gpt-5.2", "in":1.75,"cached":0.175,"out":14.00, "priority":2.0},
|
|
448
|
+
{"prefix":"gpt-5.1-codex-max","in":1.25,"cached":0.125,"out":10.00, "priority":2.0},
|
|
449
|
+
{"prefix":"gpt-5.1-codex", "in":1.25,"cached":0.125,"out":10.00, "priority":2.0},
|
|
450
|
+
{"prefix":"gpt-5.1", "in":1.25,"cached":0.125,"out":10.00, "priority":2.0},
|
|
451
|
+
{"prefix":"gpt-5-codex", "in":1.25,"cached":0.125,"out":10.00, "priority":2.0},
|
|
452
|
+
{"prefix":"gpt-5-pro", "in":15.00,"cached":0.0, "out":120.00, "priority":2.0},
|
|
453
|
+
{"prefix":"gpt-5-mini", "in":0.25,"cached":0.025,"out":2.00, "priority":1.8},
|
|
454
|
+
{"prefix":"gpt-5-nano", "in":0.05,"cached":0.005,"out":0.40, "priority":1.0},
|
|
455
|
+
{"prefix":"gpt-5", "in":1.25,"cached":0.125,"out":10.00, "priority":2.0},
|
|
456
|
+
]
|
|
457
|
+
LONG_CTX_THRESHOLD = 272_000 # tokens
|
|
458
|
+
|
|
459
|
+
def lookup_oai_price(model: str):
|
|
460
|
+
if not model: return None
|
|
461
|
+
m = model.lower()
|
|
462
|
+
for p in PRICE_LIST_OAI:
|
|
463
|
+
if m.startswith(p["prefix"]):
|
|
464
|
+
return p
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
def codex_cost_components(model: str, in_t: int, cached_t: int, out_t: int,
|
|
468
|
+
reason_t: int, tier: str) -> dict:
|
|
469
|
+
"""Compute per-component costs for one Codex API call.
|
|
470
|
+
Returns: {cost_in, cost_cached, cost_out_text, cost_out_reason}
|
|
471
|
+
in_t includes cached_t (Codex format: input_tokens = total prompt size).
|
|
472
|
+
"""
|
|
473
|
+
p = lookup_oai_price(model)
|
|
474
|
+
if not p:
|
|
475
|
+
return {"cost_in":0.0, "cost_cached":0.0, "cost_out_text":0.0, "cost_out_reason":0.0}
|
|
476
|
+
long_ctx = (in_t > LONG_CTX_THRESHOLD) and ("in_long" in p)
|
|
477
|
+
if long_ctx:
|
|
478
|
+
in_rate, cached_rate, out_rate = p["in_long"], p["cached_long"], p["out_long"]
|
|
479
|
+
else:
|
|
480
|
+
in_rate, cached_rate, out_rate = p["in"], p["cached"], p["out"]
|
|
481
|
+
# priority multiplier
|
|
482
|
+
if tier == "priority":
|
|
483
|
+
mult = p.get("priority", 1.0)
|
|
484
|
+
in_rate *= mult; cached_rate *= mult; out_rate *= mult
|
|
485
|
+
new_input_t = max(0, in_t - cached_t)
|
|
486
|
+
text_out_t = max(0, out_t - reason_t)
|
|
487
|
+
return {
|
|
488
|
+
"cost_in": new_input_t * in_rate / 1_000_000,
|
|
489
|
+
"cost_cached": cached_t * cached_rate / 1_000_000,
|
|
490
|
+
"cost_out_text": text_out_t * out_rate / 1_000_000,
|
|
491
|
+
"cost_out_reason": reason_t * out_rate / 1_000_000,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
# ─── Codex tier map (from logs_2.sqlite) ─────────────────────────────────────
|
|
495
|
+
# Each Codex API call sends a service_tier ("priority" / "default" / "auto").
|
|
496
|
+
# We treat priority → "priority", others → "standard".
|
|
497
|
+
# Tier can flip mid-session (user toggles fast mode between turns), so we build
|
|
498
|
+
# a sorted list per thread_id and binary-search by ts when assigning each
|
|
499
|
+
# token_count event its own tier.
|
|
500
|
+
_TIER_RE = re.compile(r'"service_tier"\s*:\s*"([^"]+)"')
|
|
501
|
+
_codex_tier_map = None # {thread_id: [(ts_seconds, tier), ...]}
|
|
502
|
+
|
|
503
|
+
def invalidate_codex_tier_map():
|
|
504
|
+
global _codex_tier_map
|
|
505
|
+
_codex_tier_map = None
|
|
506
|
+
|
|
507
|
+
def get_codex_tier_map():
|
|
508
|
+
global _codex_tier_map
|
|
509
|
+
if _codex_tier_map is not None:
|
|
510
|
+
return _codex_tier_map
|
|
511
|
+
by_thread = defaultdict(list)
|
|
512
|
+
if os.path.exists(CODEX_LOGS_DB):
|
|
513
|
+
try:
|
|
514
|
+
conn = sqlite3.connect(f"file:{CODEX_LOGS_DB}?mode=ro&immutable=1", uri=True)
|
|
515
|
+
cur = conn.cursor()
|
|
516
|
+
cur.execute("""
|
|
517
|
+
SELECT thread_id, ts, feedback_log_body
|
|
518
|
+
FROM logs
|
|
519
|
+
WHERE thread_id IS NOT NULL
|
|
520
|
+
AND feedback_log_body LIKE '%service_tier%'
|
|
521
|
+
""")
|
|
522
|
+
for tid, ts, body in cur:
|
|
523
|
+
m = _TIER_RE.search(body)
|
|
524
|
+
if m:
|
|
525
|
+
raw = m.group(1)
|
|
526
|
+
tier = "priority" if raw == "priority" else "standard"
|
|
527
|
+
by_thread[tid].append((ts, tier))
|
|
528
|
+
conn.close()
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
for tid in by_thread:
|
|
532
|
+
by_thread[tid].sort()
|
|
533
|
+
_codex_tier_map = dict(by_thread)
|
|
534
|
+
return _codex_tier_map
|
|
535
|
+
|
|
536
|
+
def lookup_tier_for(thread_id: str, ts_ms: int) -> str:
|
|
537
|
+
"""Tier active for this thread at this ts. Default 'standard' if unknown."""
|
|
538
|
+
m = get_codex_tier_map()
|
|
539
|
+
arr = m.get(thread_id)
|
|
540
|
+
if not arr:
|
|
541
|
+
return "standard"
|
|
542
|
+
ts_s = ts_ms // 1000
|
|
543
|
+
# bisect for last entry with ts <= ts_s
|
|
544
|
+
lo, hi = 0, len(arr)
|
|
545
|
+
while lo < hi:
|
|
546
|
+
mid = (lo + hi) >> 1
|
|
547
|
+
if arr[mid][0] <= ts_s: lo = mid + 1
|
|
548
|
+
else: hi = mid
|
|
549
|
+
if lo == 0: return arr[0][1] # before first known marker; assume same
|
|
550
|
+
return arr[lo - 1][1]
|
|
551
|
+
|
|
552
|
+
# Cached deduped+sorted DataFrame (with cost column). Invalidated on refresh.
|
|
553
|
+
_global_df = None
|
|
554
|
+
|
|
555
|
+
def invalidate_df():
|
|
556
|
+
global _global_df
|
|
557
|
+
_global_df = None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def get_df() -> pl.DataFrame:
|
|
561
|
+
"""Materialize the global DataFrame on first call after a refresh.
|
|
562
|
+
Merges Claude Code (~/.claude/projects) and Codex (~/.codex/sessions) rows.
|
|
563
|
+
Per-row cost components are pre-computed at parse time."""
|
|
564
|
+
global _global_df
|
|
565
|
+
if _global_df is not None:
|
|
566
|
+
return _global_df
|
|
567
|
+
rows = []
|
|
568
|
+
seen = set()
|
|
569
|
+
with _lock:
|
|
570
|
+
# Claude Code: dedup by msg_id (forks copy parent transcripts).
|
|
571
|
+
for entry in _files.values():
|
|
572
|
+
for r in entry["records"]:
|
|
573
|
+
mid = r[0]
|
|
574
|
+
if mid:
|
|
575
|
+
if mid in seen: continue
|
|
576
|
+
seen.add(mid)
|
|
577
|
+
rows.append(r)
|
|
578
|
+
# Codex: each rollout is a unique session, no cross-file duplication.
|
|
579
|
+
for entry in _codex_files.values():
|
|
580
|
+
for r in entry["records"]:
|
|
581
|
+
rows.append(r)
|
|
582
|
+
if not rows:
|
|
583
|
+
_global_df = pl.DataFrame(schema=dict(RECORD_SCHEMA)).with_columns(pl.lit(0.0).alias("cost"))
|
|
584
|
+
return _global_df
|
|
585
|
+
df = pl.DataFrame(rows, schema=RECORD_SCHEMA, orient="row").sort("ts")
|
|
586
|
+
df = df.with_columns(
|
|
587
|
+
(pl.col("cost_in") + pl.col("cost_cached")
|
|
588
|
+
+ pl.col("cost_out_text") + pl.col("cost_out_reason")
|
|
589
|
+
+ pl.col("cost_cw5m") + pl.col("cost_cw1h") + pl.col("cost_cr")).alias("cost")
|
|
590
|
+
)
|
|
591
|
+
_global_df = df
|
|
592
|
+
return df
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _format_bucket_key(bucket_utc_ms: int, off: int, g: str) -> str:
|
|
596
|
+
local_secs = (bucket_utc_ms + off) // 1000
|
|
597
|
+
tm = time.gmtime(local_secs)
|
|
598
|
+
if g in ("1d", "day"):
|
|
599
|
+
return time.strftime("%Y-%m-%d", tm)
|
|
600
|
+
return time.strftime("%Y-%m-%d %H:%M", tm)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _empty_bucket(key):
|
|
604
|
+
return {"key": key, "calls":0, "in":0, "out":0, "cw5m":0, "cw1h":0, "cr":0,
|
|
605
|
+
"cost":0.0, "mainCost":0.0, "subCost":0.0}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def aggregate(start_ms, end_ms, granularity, tz, fill_gaps=True, source="cc"):
|
|
609
|
+
df = get_df()
|
|
610
|
+
g = (granularity or "1h").lower()
|
|
611
|
+
bucket_secs = SECONDS_IN_BUCKET.get(g, 3600)
|
|
612
|
+
bucket_ms = bucket_secs * 1000
|
|
613
|
+
off = tz_offset_ms(tz)
|
|
614
|
+
floor_offset = -off # add to ts to align floor with local-TZ bucket boundaries
|
|
615
|
+
|
|
616
|
+
# Source filter: 'cc', 'codex', or 'all'
|
|
617
|
+
if df.height and source in ("cc", "codex"):
|
|
618
|
+
df = df.filter(pl.col("source") == source)
|
|
619
|
+
|
|
620
|
+
# Slice window via predicate (polars uses SIMD on ints — instant)
|
|
621
|
+
sub = df.filter((pl.col("ts") >= start_ms) & (pl.col("ts") < end_ms)) if df.height else df
|
|
622
|
+
|
|
623
|
+
# Totals
|
|
624
|
+
if sub.height > 0:
|
|
625
|
+
main_part = sub.filter(pl.col("is_sub") == 0)
|
|
626
|
+
sub_part = sub.filter(pl.col("is_sub") == 1)
|
|
627
|
+
totals = {
|
|
628
|
+
"calls": sub.height,
|
|
629
|
+
"in": int(sub["in_t"].sum() or 0),
|
|
630
|
+
"cached": int(sub["cached_t"].sum() or 0),
|
|
631
|
+
"out": int(sub["out_t"].sum() or 0),
|
|
632
|
+
"reason": int(sub["reason_t"].sum() or 0),
|
|
633
|
+
"cw5m": int(sub["cw5m"].sum() or 0),
|
|
634
|
+
"cw1h": int(sub["cw1h"].sum() or 0),
|
|
635
|
+
"cr": int(sub["cr"].sum() or 0),
|
|
636
|
+
"cost": float(sub["cost"].sum() or 0.0),
|
|
637
|
+
"main": {"calls": main_part.height, "cost": float(main_part["cost"].sum() or 0.0)},
|
|
638
|
+
"sub": {"calls": sub_part.height, "cost": float(sub_part["cost"].sum() or 0.0)},
|
|
639
|
+
}
|
|
640
|
+
else:
|
|
641
|
+
totals = {"calls":0,"in":0,"cached":0,"out":0,"reason":0,"cw5m":0,"cw1h":0,"cr":0,"cost":0.0,
|
|
642
|
+
"main":{"calls":0,"cost":0.0}, "sub":{"calls":0,"cost":0.0}}
|
|
643
|
+
|
|
644
|
+
# By project
|
|
645
|
+
by_proj_list = []
|
|
646
|
+
if sub.height > 0:
|
|
647
|
+
gp = (sub.group_by("project")
|
|
648
|
+
.agg([pl.len().alias("calls"),
|
|
649
|
+
pl.col("cost").sum().alias("cost"),
|
|
650
|
+
pl.col("out_t").sum().alias("out"),
|
|
651
|
+
pl.col("cr").sum().alias("cr")])
|
|
652
|
+
.sort("cost", descending=True))
|
|
653
|
+
by_proj_list = [{"name": r["project"], "calls": r["calls"],
|
|
654
|
+
"cost": float(r["cost"] or 0.0),
|
|
655
|
+
"out": int(r["out"] or 0), "cr": int(r["cr"] or 0)}
|
|
656
|
+
for r in gp.iter_rows(named=True)]
|
|
657
|
+
|
|
658
|
+
# By model
|
|
659
|
+
by_model_list = []
|
|
660
|
+
if sub.height > 0:
|
|
661
|
+
gm = (sub.group_by("model")
|
|
662
|
+
.agg([pl.len().alias("calls"), pl.col("cost").sum().alias("cost")])
|
|
663
|
+
.sort("cost", descending=True))
|
|
664
|
+
by_model_list = [{"name": r["model"], "calls": r["calls"],
|
|
665
|
+
"cost": float(r["cost"] or 0.0)}
|
|
666
|
+
for r in gm.iter_rows(named=True)]
|
|
667
|
+
|
|
668
|
+
# Cost split — list of {label, key, val, color} so frontend just renders.
|
|
669
|
+
# Slice composition differs per source: Claude Code has 5 cache tiers,
|
|
670
|
+
# Codex has 4 (input/cached/output_text/output_reason).
|
|
671
|
+
cost_split = []
|
|
672
|
+
if sub.height > 0:
|
|
673
|
+
if source == "codex":
|
|
674
|
+
cost_split = [
|
|
675
|
+
{"key":"cached", "label":"Cached input", "val": float(sub["cost_cached"].sum() or 0.0), "color":"#0A0A0A"},
|
|
676
|
+
{"key":"output", "label":"Output (text)", "val": float(sub["cost_out_text"].sum() or 0.0), "color":"#FFD81F"},
|
|
677
|
+
{"key":"reasoning", "label":"Output (reasoning)","val":float(sub["cost_out_reason"].sum() or 0.0), "color":"#FF2BA0"},
|
|
678
|
+
{"key":"input", "label":"Input", "val": float(sub["cost_in"].sum() or 0.0), "color":"#FF6A1A"},
|
|
679
|
+
]
|
|
680
|
+
else:
|
|
681
|
+
cost_split = [
|
|
682
|
+
{"key":"cr", "label":"Cache read", "val": float(sub["cost_cr"].sum() or 0.0), "color":"#0A0A0A"},
|
|
683
|
+
{"key":"cw1h", "label":"Cache write 1h", "val": float(sub["cost_cw1h"].sum() or 0.0), "color":"#FF6A1A"},
|
|
684
|
+
{"key":"out", "label":"Output", "val": float(sub["cost_out_text"].sum() or 0.0), "color":"#FFD81F"},
|
|
685
|
+
{"key":"cw5m", "label":"Cache write 5m", "val": float(sub["cost_cw5m"].sum() or 0.0), "color":"#FF2BA0"},
|
|
686
|
+
{"key":"in", "label":"Input", "val": float(sub["cost_in"].sum() or 0.0), "color":"#7C3AED"},
|
|
687
|
+
]
|
|
688
|
+
cost_split = [c for c in cost_split if c["val"] > 0]
|
|
689
|
+
|
|
690
|
+
# Buckets — int floor by bucket size, vectorized groupby
|
|
691
|
+
bucket_rows = {}
|
|
692
|
+
if sub.height > 0:
|
|
693
|
+
sub2 = sub.with_columns(
|
|
694
|
+
(((pl.col("ts") - floor_offset) // bucket_ms) * bucket_ms + floor_offset).alias("bucket_utc")
|
|
695
|
+
)
|
|
696
|
+
gb = (sub2.group_by("bucket_utc")
|
|
697
|
+
.agg([pl.len().alias("calls"),
|
|
698
|
+
pl.col("in_t").sum().alias("in"),
|
|
699
|
+
pl.col("cached_t").sum().alias("cached"),
|
|
700
|
+
pl.col("out_t").sum().alias("out"),
|
|
701
|
+
pl.col("reason_t").sum().alias("reason"),
|
|
702
|
+
pl.col("cw5m").sum().alias("cw5m"),
|
|
703
|
+
pl.col("cw1h").sum().alias("cw1h"),
|
|
704
|
+
pl.col("cr").sum().alias("cr"),
|
|
705
|
+
pl.col("cost").sum().alias("cost"),
|
|
706
|
+
(pl.col("cost") * (pl.col("is_sub") == 0).cast(pl.Float64)).sum().alias("mainCost"),
|
|
707
|
+
(pl.col("cost") * (pl.col("is_sub") == 1).cast(pl.Float64)).sum().alias("subCost"),
|
|
708
|
+
# Per-component cost sums for the BY-TOKEN chart view
|
|
709
|
+
pl.col("cost_in").sum().alias("c_in"),
|
|
710
|
+
pl.col("cost_cached").sum().alias("c_cached"),
|
|
711
|
+
pl.col("cost_out_text").sum().alias("c_out"),
|
|
712
|
+
pl.col("cost_out_reason").sum().alias("c_reason"),
|
|
713
|
+
pl.col("cost_cw5m").sum().alias("c_cw5m"),
|
|
714
|
+
pl.col("cost_cw1h").sum().alias("c_cw1h"),
|
|
715
|
+
pl.col("cost_cr").sum().alias("c_cr"),
|
|
716
|
+
]))
|
|
717
|
+
for r in gb.iter_rows(named=True):
|
|
718
|
+
bucket_rows[r["bucket_utc"]] = r
|
|
719
|
+
|
|
720
|
+
# Compose final bucket arrays (columnar — ~4× smaller wire size than per-row dicts).
|
|
721
|
+
if fill_gaps:
|
|
722
|
+
first = ((start_ms - floor_offset) // bucket_ms) * bucket_ms + floor_offset
|
|
723
|
+
bucket_keys = list(range(first, end_ms, bucket_ms))
|
|
724
|
+
else:
|
|
725
|
+
bucket_keys = sorted(bucket_rows.keys())
|
|
726
|
+
|
|
727
|
+
n = len(bucket_keys)
|
|
728
|
+
ts_arr = bucket_keys
|
|
729
|
+
calls_arr = [0] * n
|
|
730
|
+
in_arr = [0] * n
|
|
731
|
+
cached_arr = [0] * n
|
|
732
|
+
out_arr = [0] * n
|
|
733
|
+
reason_arr = [0] * n
|
|
734
|
+
cw5m_arr = [0] * n
|
|
735
|
+
cw1h_arr = [0] * n
|
|
736
|
+
cr_arr = [0] * n
|
|
737
|
+
cost_arr = [0.0] * n
|
|
738
|
+
main_arr = [0.0] * n
|
|
739
|
+
sub_arr = [0.0] * n
|
|
740
|
+
cIn_arr = [0.0] * n
|
|
741
|
+
cCached_arr = [0.0] * n
|
|
742
|
+
cOut_arr = [0.0] * n
|
|
743
|
+
cReason_arr = [0.0] * n
|
|
744
|
+
cCw5m_arr = [0.0] * n
|
|
745
|
+
cCw1h_arr = [0.0] * n
|
|
746
|
+
cCr_arr = [0.0] * n
|
|
747
|
+
for i, bk in enumerate(bucket_keys):
|
|
748
|
+
r = bucket_rows.get(bk)
|
|
749
|
+
if r is None:
|
|
750
|
+
continue
|
|
751
|
+
calls_arr[i] = r["calls"]
|
|
752
|
+
in_arr[i] = int(r["in"] or 0)
|
|
753
|
+
cached_arr[i] = int(r["cached"] or 0)
|
|
754
|
+
out_arr[i] = int(r["out"] or 0)
|
|
755
|
+
reason_arr[i] = int(r["reason"] or 0)
|
|
756
|
+
cw5m_arr[i] = int(r["cw5m"] or 0)
|
|
757
|
+
cw1h_arr[i] = int(r["cw1h"] or 0)
|
|
758
|
+
cr_arr[i] = int(r["cr"] or 0)
|
|
759
|
+
cost_arr[i] = float(r["cost"] or 0.0)
|
|
760
|
+
main_arr[i] = float(r["mainCost"] or 0.0)
|
|
761
|
+
sub_arr[i] = float(r["subCost"] or 0.0)
|
|
762
|
+
cIn_arr[i] = float(r["c_in"] or 0.0)
|
|
763
|
+
cCached_arr[i] = float(r["c_cached"] or 0.0)
|
|
764
|
+
cOut_arr[i] = float(r["c_out"] or 0.0)
|
|
765
|
+
cReason_arr[i] = float(r["c_reason"] or 0.0)
|
|
766
|
+
cCw5m_arr[i] = float(r["c_cw5m"] or 0.0)
|
|
767
|
+
cCw1h_arr[i] = float(r["c_cw1h"] or 0.0)
|
|
768
|
+
cCr_arr[i] = float(r["c_cr"] or 0.0)
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
"totals": totals,
|
|
772
|
+
"byProject": by_proj_list,
|
|
773
|
+
"byModel": by_model_list,
|
|
774
|
+
"buckets": {
|
|
775
|
+
"step": bucket_ms,
|
|
776
|
+
"ts": ts_arr, # bucket start, UTC ms
|
|
777
|
+
"calls": calls_arr,
|
|
778
|
+
"in": in_arr,
|
|
779
|
+
"cached": cached_arr, # codex: cached_input_tokens
|
|
780
|
+
"out": out_arr,
|
|
781
|
+
"reason": reason_arr, # codex: reasoning_output_tokens
|
|
782
|
+
"cw5m": cw5m_arr, # cc only
|
|
783
|
+
"cw1h": cw1h_arr, # cc only
|
|
784
|
+
"cr": cr_arr, # cc only (cache_read)
|
|
785
|
+
"cost": cost_arr,
|
|
786
|
+
"mainCost": main_arr,
|
|
787
|
+
"subCost": sub_arr,
|
|
788
|
+
# Per-component cost arrays (for the BY-TOKEN chart view)
|
|
789
|
+
"cIn": cIn_arr,
|
|
790
|
+
"cCached": cCached_arr,
|
|
791
|
+
"cOut": cOut_arr,
|
|
792
|
+
"cReason": cReason_arr,
|
|
793
|
+
"cCw5m": cCw5m_arr,
|
|
794
|
+
"cCw1h": cCw1h_arr,
|
|
795
|
+
"cCr": cCr_arr,
|
|
796
|
+
},
|
|
797
|
+
"source": source,
|
|
798
|
+
"granularity": g,
|
|
799
|
+
"costSplit": cost_split,
|
|
800
|
+
"firstRecordMs": int(df["ts"][0]) if df.height > 0 else None,
|
|
801
|
+
"totalRecords": df.height,
|
|
802
|
+
"files": len(_files) + len(_codex_files),
|
|
803
|
+
"lastRefresh": _last_full_refresh,
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
# ---------- HTTP server ----------
|
|
807
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
808
|
+
def log_message(self, *args): pass # silence default logging
|
|
809
|
+
|
|
810
|
+
def _json(self, obj, status=200):
|
|
811
|
+
body = json.dumps(obj, default=str).encode()
|
|
812
|
+
self.send_response(status)
|
|
813
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
814
|
+
self.send_header("Content-Length", str(len(body)))
|
|
815
|
+
self.end_headers()
|
|
816
|
+
self.wfile.write(body)
|
|
817
|
+
|
|
818
|
+
def do_GET(self):
|
|
819
|
+
u = urlparse(self.path)
|
|
820
|
+
if u.path in ("/", "/index.html"):
|
|
821
|
+
try:
|
|
822
|
+
body = get_html().encode()
|
|
823
|
+
except FileNotFoundError:
|
|
824
|
+
self.send_error(500, f"dashboard.html not found at {DASHBOARD_HTML}")
|
|
825
|
+
return
|
|
826
|
+
self.send_response(200)
|
|
827
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
828
|
+
self.send_header("Cache-Control", "no-cache")
|
|
829
|
+
self.send_header("Content-Length", str(len(body)))
|
|
830
|
+
self.end_headers()
|
|
831
|
+
self.wfile.write(body)
|
|
832
|
+
return
|
|
833
|
+
if u.path == "/api/refresh":
|
|
834
|
+
stats = refresh()
|
|
835
|
+
return self._json(stats)
|
|
836
|
+
if u.path == "/api/aggregate":
|
|
837
|
+
q = parse_qs(u.query)
|
|
838
|
+
try:
|
|
839
|
+
start = int(q["from"][0]); end = int(q["to"][0])
|
|
840
|
+
except Exception:
|
|
841
|
+
return self._json({"error": "from/to (ms) required"}, 400)
|
|
842
|
+
tz = q.get("tz", ["ET"])[0]
|
|
843
|
+
granularity = q.get("granularity", ["hour"])[0]
|
|
844
|
+
gaps = q.get("gaps", ["fill"])[0] # "fill" | "skip"
|
|
845
|
+
fill_gaps = gaps != "skip"
|
|
846
|
+
source = q.get("source", ["cc"])[0] # "cc" | "codex" | "all"
|
|
847
|
+
if source not in ("cc", "codex", "all"):
|
|
848
|
+
source = "cc"
|
|
849
|
+
# cheap auto-refresh on stale cache
|
|
850
|
+
if time.time() - _last_full_refresh > 30:
|
|
851
|
+
refresh()
|
|
852
|
+
return self._json(aggregate(start, end, granularity, tz, fill_gaps, source))
|
|
853
|
+
self.send_error(404)
|
|
854
|
+
|
|
855
|
+
class ReusableTCPServer(socketserver.ThreadingTCPServer):
|
|
856
|
+
allow_reuse_address = True
|
|
857
|
+
daemon_threads = True
|
|
858
|
+
|
|
859
|
+
def main():
|
|
860
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
861
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("TOKMETER_PORT", os.environ.get("CLAUDE_USAGE_PORT", DEFAULT_PORT))))
|
|
862
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
863
|
+
ap.add_argument("--no-open", action="store_true", help="Don't auto-open browser")
|
|
864
|
+
args = ap.parse_args()
|
|
865
|
+
|
|
866
|
+
print(f"Scanning {ROOT} ...")
|
|
867
|
+
t0 = time.time()
|
|
868
|
+
stats = refresh(force=True)
|
|
869
|
+
print(f" {stats['scanned']} files scanned in {time.time()-t0:.1f}s")
|
|
870
|
+
|
|
871
|
+
url = f"http://{args.host}:{args.port}/"
|
|
872
|
+
print(f"\n tokmeter")
|
|
873
|
+
print(f" → {url}\n")
|
|
874
|
+
|
|
875
|
+
if not args.no_open:
|
|
876
|
+
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
|
877
|
+
|
|
878
|
+
with ReusableTCPServer((args.host, args.port), Handler) as srv:
|
|
879
|
+
try:
|
|
880
|
+
srv.serve_forever()
|
|
881
|
+
except KeyboardInterrupt:
|
|
882
|
+
print("\nbye.")
|
|
883
|
+
|
|
884
|
+
if __name__ == "__main__":
|
|
885
|
+
main()
|