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