codex-usage-tracking 0.3.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.
Files changed (50) hide show
  1. codex_usage_tracker/__init__.py +7 -0
  2. codex_usage_tracker/__main__.py +6 -0
  3. codex_usage_tracker/allowance.py +759 -0
  4. codex_usage_tracker/api_payloads.py +90 -0
  5. codex_usage_tracker/cli.py +1326 -0
  6. codex_usage_tracker/context.py +410 -0
  7. codex_usage_tracker/costing.py +176 -0
  8. codex_usage_tracker/dashboard.py +389 -0
  9. codex_usage_tracker/diagnostics.py +624 -0
  10. codex_usage_tracker/formatting.py +225 -0
  11. codex_usage_tracker/json_contracts.py +350 -0
  12. codex_usage_tracker/mcp_server.py +371 -0
  13. codex_usage_tracker/models.py +92 -0
  14. codex_usage_tracker/parser.py +491 -0
  15. codex_usage_tracker/paths.py +18 -0
  16. codex_usage_tracker/plugin_data/__init__.py +1 -0
  17. codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
  18. codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
  19. codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
  20. codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
  21. codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
  22. codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
  23. codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
  24. codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
  25. codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
  26. codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
  27. codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
  28. codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
  29. codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
  30. codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
  31. codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
  32. codex_usage_tracker/plugin_installer.py +312 -0
  33. codex_usage_tracker/pricing.py +57 -0
  34. codex_usage_tracker/pricing_config.py +223 -0
  35. codex_usage_tracker/pricing_estimates.py +44 -0
  36. codex_usage_tracker/pricing_openai.py +253 -0
  37. codex_usage_tracker/projects.py +347 -0
  38. codex_usage_tracker/recommendations.py +270 -0
  39. codex_usage_tracker/reports.py +637 -0
  40. codex_usage_tracker/schema.py +71 -0
  41. codex_usage_tracker/server.py +400 -0
  42. codex_usage_tracker/store.py +666 -0
  43. codex_usage_tracker/support.py +147 -0
  44. codex_usage_tracker/threads.py +183 -0
  45. codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
  46. codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
  47. codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
  48. codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
  49. codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
  50. codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,666 @@
1
+ """SQLite persistence and aggregate queries for Codex usage data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import sqlite3
7
+ from collections.abc import Iterable, Iterator
8
+ from contextlib import contextmanager, suppress
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from codex_usage_tracker.models import RefreshResult, UsageEvent
14
+ from codex_usage_tracker.parser import (
15
+ PARSER_DIAGNOSTIC_KEYS,
16
+ compact_parser_diagnostics,
17
+ find_session_logs,
18
+ load_session_index,
19
+ parse_usage_events,
20
+ )
21
+ from codex_usage_tracker.paths import DEFAULT_CODEX_HOME, DEFAULT_DB_PATH
22
+ from codex_usage_tracker.projects import apply_project_privacy_to_rows, validate_privacy_mode
23
+ from codex_usage_tracker.schema import (
24
+ USAGE_EVENT_COLUMN_NAMES,
25
+ USAGE_EVENT_CREATE_COLUMNS_SQL,
26
+ USAGE_EVENT_REPAIR_COLUMNS,
27
+ USAGE_EVENT_SCHEMA_CHECKSUM,
28
+ )
29
+
30
+ EVENT_COLUMNS = list(USAGE_EVENT_COLUMN_NAMES)
31
+
32
+ SCHEMA_VERSION = 2
33
+ MIGRATION_NAMES = {
34
+ 1: "create usage_events aggregate fact table",
35
+ 2: "track schema migration checksum metadata",
36
+ }
37
+ _ARCHIVED_SOURCE_PATTERNS = (
38
+ "%/archived_sessions/%",
39
+ "archived_sessions/%",
40
+ "%\\archived_sessions\\%",
41
+ "archived_sessions\\%",
42
+ )
43
+
44
+
45
+ def refresh_usage_index(
46
+ codex_home: Path = DEFAULT_CODEX_HOME,
47
+ db_path: Path = DEFAULT_DB_PATH,
48
+ include_archived: bool = False,
49
+ ) -> RefreshResult:
50
+ """Scan Codex logs and upsert aggregate usage events."""
51
+
52
+ logs = find_session_logs(codex_home=codex_home, include_archived=include_archived)
53
+ session_index = load_session_index(codex_home)
54
+ stats: dict[str, int] = {}
55
+ events = parse_usage_events(logs, session_index=session_index, stats=stats)
56
+ inserted = upsert_usage_events(events, db_path=db_path)
57
+ skipped_events = stats.get("skipped_events", 0)
58
+ diagnostics = compact_parser_diagnostics(stats)
59
+ record_refresh_metadata(
60
+ db_path=db_path,
61
+ scanned_files=len(logs),
62
+ parsed_events=len(events),
63
+ skipped_events=skipped_events,
64
+ inserted_or_updated_events=inserted,
65
+ parser_diagnostics=diagnostics,
66
+ )
67
+ return RefreshResult(
68
+ scanned_files=len(logs),
69
+ parsed_events=len(events),
70
+ inserted_or_updated_events=inserted,
71
+ db_path=str(db_path),
72
+ skipped_events=skipped_events,
73
+ parser_diagnostics=diagnostics,
74
+ )
75
+
76
+
77
+ def rebuild_usage_index(
78
+ codex_home: Path = DEFAULT_CODEX_HOME,
79
+ db_path: Path = DEFAULT_DB_PATH,
80
+ include_archived: bool = False,
81
+ ) -> RefreshResult:
82
+ """Clear aggregate rows and rescan local Codex logs."""
83
+
84
+ with connect(db_path) as conn:
85
+ init_db(conn)
86
+ conn.execute("DELETE FROM usage_events")
87
+ conn.execute("DELETE FROM refresh_meta")
88
+ return refresh_usage_index(
89
+ codex_home=codex_home,
90
+ db_path=db_path,
91
+ include_archived=include_archived,
92
+ )
93
+
94
+
95
+ def reset_usage_database(db_path: Path = DEFAULT_DB_PATH) -> dict[str, Any]:
96
+ """Clear tracker-owned aggregate rows and refresh metadata."""
97
+
98
+ with connect(db_path) as conn:
99
+ init_db(conn)
100
+ row = conn.execute("SELECT COUNT(*) AS count FROM usage_events").fetchone()
101
+ deleted_rows = int(row["count"] if row is not None else 0)
102
+ conn.execute("DELETE FROM usage_events")
103
+ conn.execute("DELETE FROM refresh_meta")
104
+ return {"db_path": str(db_path), "deleted_usage_events": deleted_rows}
105
+
106
+
107
+ @contextmanager
108
+ def connect(db_path: Path = DEFAULT_DB_PATH) -> Iterator[sqlite3.Connection]:
109
+ db_path.parent.mkdir(parents=True, exist_ok=True)
110
+ conn = sqlite3.connect(db_path, timeout=5.0)
111
+ conn.row_factory = sqlite3.Row
112
+ conn.execute("PRAGMA busy_timeout = 5000")
113
+ with suppress(sqlite3.DatabaseError):
114
+ conn.execute("PRAGMA journal_mode = WAL")
115
+ try:
116
+ yield conn
117
+ conn.commit()
118
+ except BaseException:
119
+ conn.rollback()
120
+ raise
121
+ finally:
122
+ conn.close()
123
+
124
+
125
+ def init_db(conn: sqlite3.Connection) -> None:
126
+ user_version = int(conn.execute("PRAGMA user_version").fetchone()[0])
127
+ _ensure_migrations_table(conn)
128
+ if user_version < 1:
129
+ _migrate_v1(conn)
130
+ _record_migration(conn, 1)
131
+ else:
132
+ _migrate_v1(conn)
133
+ _record_migration_if_missing(conn, 1)
134
+ if user_version < 2:
135
+ _migrate_v2(conn)
136
+ _record_migration(conn, 2)
137
+ else:
138
+ _migrate_v2(conn)
139
+ _record_migration_if_missing(conn, 2)
140
+ conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
141
+
142
+
143
+ def _ensure_migrations_table(conn: sqlite3.Connection) -> None:
144
+ conn.execute(
145
+ """
146
+ CREATE TABLE IF NOT EXISTS schema_migrations (
147
+ version INTEGER PRIMARY KEY,
148
+ name TEXT NOT NULL,
149
+ checksum TEXT NOT NULL,
150
+ applied_at TEXT NOT NULL
151
+ )
152
+ """
153
+ )
154
+
155
+
156
+ def _migrate_v1(conn: sqlite3.Connection) -> None:
157
+ conn.executescript(
158
+ f"""
159
+ CREATE TABLE IF NOT EXISTS usage_events (
160
+ {USAGE_EVENT_CREATE_COLUMNS_SQL}
161
+ );
162
+
163
+ CREATE TABLE IF NOT EXISTS refresh_meta (
164
+ key TEXT PRIMARY KEY,
165
+ value TEXT NOT NULL
166
+ );
167
+ """
168
+ )
169
+ _ensure_columns(conn, USAGE_EVENT_REPAIR_COLUMNS)
170
+ conn.executescript(
171
+ """
172
+ CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_events(session_id);
173
+ CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage_events(event_timestamp);
174
+ CREATE INDEX IF NOT EXISTS idx_usage_model_effort ON usage_events(model, effort);
175
+ CREATE INDEX IF NOT EXISTS idx_usage_thread ON usage_events(thread_name);
176
+ CREATE INDEX IF NOT EXISTS idx_usage_parent_thread ON usage_events(parent_thread_name);
177
+ CREATE INDEX IF NOT EXISTS idx_usage_parent_session ON usage_events(parent_session_id);
178
+ CREATE INDEX IF NOT EXISTS idx_usage_total_tokens ON usage_events(total_tokens);
179
+ """
180
+ )
181
+
182
+
183
+ def _migrate_v2(conn: sqlite3.Connection) -> None:
184
+ _ensure_migrations_table(conn)
185
+
186
+
187
+ def _record_migration(conn: sqlite3.Connection, version: int) -> None:
188
+ conn.execute(
189
+ """
190
+ INSERT INTO schema_migrations (version, name, checksum, applied_at)
191
+ VALUES (?, ?, ?, ?)
192
+ ON CONFLICT(version) DO UPDATE SET
193
+ name = excluded.name,
194
+ checksum = excluded.checksum
195
+ """,
196
+ (
197
+ version,
198
+ MIGRATION_NAMES[version],
199
+ USAGE_EVENT_SCHEMA_CHECKSUM,
200
+ datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
201
+ ),
202
+ )
203
+
204
+
205
+ def _record_migration_if_missing(conn: sqlite3.Connection, version: int) -> None:
206
+ exists = conn.execute(
207
+ "SELECT 1 FROM schema_migrations WHERE version = ?",
208
+ (version,),
209
+ ).fetchone()
210
+ if exists is None:
211
+ _record_migration(conn, version)
212
+
213
+
214
+ def record_refresh_metadata(
215
+ db_path: Path = DEFAULT_DB_PATH,
216
+ *,
217
+ scanned_files: int,
218
+ parsed_events: int,
219
+ skipped_events: int,
220
+ inserted_or_updated_events: int,
221
+ parser_diagnostics: dict[str, int] | None = None,
222
+ ) -> None:
223
+ """Record the latest refresh counters in refresh_meta."""
224
+
225
+ values = {
226
+ "latest_refresh_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
227
+ "scanned_files": str(scanned_files),
228
+ "parsed_events": str(parsed_events),
229
+ "skipped_events": str(skipped_events),
230
+ "inserted_or_updated_events": str(inserted_or_updated_events),
231
+ "parser_adapter": "codex-jsonl-v1",
232
+ "schema_version": str(SCHEMA_VERSION),
233
+ "usage_events_schema_checksum": USAGE_EVENT_SCHEMA_CHECKSUM,
234
+ }
235
+ diagnostics = parser_diagnostics or {}
236
+ for key in PARSER_DIAGNOSTIC_KEYS:
237
+ values[f"parser_{key}"] = str(int(diagnostics.get(key, 0)))
238
+ with connect(db_path) as conn:
239
+ init_db(conn)
240
+ conn.executemany(
241
+ """
242
+ INSERT INTO refresh_meta (key, value)
243
+ VALUES (?, ?)
244
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
245
+ """,
246
+ values.items(),
247
+ )
248
+
249
+
250
+ def refresh_metadata(db_path: Path = DEFAULT_DB_PATH) -> dict[str, str]:
251
+ """Return latest refresh metadata and parser diagnostics."""
252
+
253
+ if not db_path.exists():
254
+ return {}
255
+ with connect(db_path) as conn:
256
+ init_db(conn)
257
+ rows = conn.execute("SELECT key, value FROM refresh_meta").fetchall()
258
+ return {str(row["key"]): str(row["value"]) for row in rows}
259
+
260
+
261
+ def schema_state(db_path: Path = DEFAULT_DB_PATH) -> dict[str, Any]:
262
+ """Return database migration and usage_events checksum state."""
263
+
264
+ if not db_path.exists():
265
+ return {
266
+ "exists": False,
267
+ "schema_version": None,
268
+ "expected_schema_version": SCHEMA_VERSION,
269
+ "expected_checksum": USAGE_EVENT_SCHEMA_CHECKSUM,
270
+ "migrations": [],
271
+ "checksum_matches": False,
272
+ }
273
+ with connect(db_path) as conn:
274
+ init_db(conn)
275
+ version = int(conn.execute("PRAGMA user_version").fetchone()[0])
276
+ rows = conn.execute(
277
+ """
278
+ SELECT version, name, checksum, applied_at
279
+ FROM schema_migrations
280
+ ORDER BY version
281
+ """
282
+ ).fetchall()
283
+ migrations = [_row_to_dict(row) for row in rows]
284
+ latest_checksum = migrations[-1]["checksum"] if migrations else None
285
+ return {
286
+ "exists": True,
287
+ "schema_version": version,
288
+ "expected_schema_version": SCHEMA_VERSION,
289
+ "expected_checksum": USAGE_EVENT_SCHEMA_CHECKSUM,
290
+ "latest_checksum": latest_checksum,
291
+ "checksum_matches": latest_checksum == USAGE_EVENT_SCHEMA_CHECKSUM,
292
+ "migrations": migrations,
293
+ }
294
+
295
+
296
+ def _ensure_columns(conn: sqlite3.Connection, columns: dict[str, str]) -> None:
297
+ existing = {
298
+ str(row["name"])
299
+ for row in conn.execute("PRAGMA table_info(usage_events)").fetchall()
300
+ }
301
+ for column, column_type in columns.items():
302
+ if column not in existing:
303
+ try:
304
+ conn.execute(f"ALTER TABLE usage_events ADD COLUMN {column} {column_type}")
305
+ except sqlite3.OperationalError as exc:
306
+ if "duplicate column name" not in str(exc).lower():
307
+ raise
308
+
309
+
310
+ def upsert_usage_events(
311
+ events: Iterable[UsageEvent], db_path: Path = DEFAULT_DB_PATH
312
+ ) -> int:
313
+ rows = [event.to_row() for event in events]
314
+ with connect(db_path) as conn:
315
+ init_db(conn)
316
+ if not rows:
317
+ return 0
318
+ placeholders = ", ".join("?" for _ in EVENT_COLUMNS)
319
+ update_clause = ", ".join(
320
+ f"{column}=excluded.{column}"
321
+ for column in EVENT_COLUMNS
322
+ if column != "record_id"
323
+ )
324
+ sql = (
325
+ f"INSERT INTO usage_events ({', '.join(EVENT_COLUMNS)}) "
326
+ f"VALUES ({placeholders}) "
327
+ f"ON CONFLICT(record_id) DO UPDATE SET {update_clause}"
328
+ )
329
+ conn.executemany(sql, [[row[column] for column in EVENT_COLUMNS] for row in rows])
330
+ return len(rows)
331
+
332
+
333
+ def query_summary(
334
+ db_path: Path = DEFAULT_DB_PATH,
335
+ group_by: str = "thread",
336
+ limit: int = 20,
337
+ since: str | None = None,
338
+ ) -> list[dict[str, Any]]:
339
+ group_expr = _group_expression(group_by)
340
+ where_clause, raw_params = _since_where_clause(since)
341
+ params: list[Any] = list(raw_params)
342
+ sql = f"""
343
+ SELECT
344
+ {group_expr} AS group_key,
345
+ COUNT(*) AS model_calls,
346
+ COUNT(DISTINCT session_id) AS sessions,
347
+ COUNT(DISTINCT turn_id) AS turns,
348
+ SUM(input_tokens) AS input_tokens,
349
+ SUM(cached_input_tokens) AS cached_input_tokens,
350
+ SUM(uncached_input_tokens) AS uncached_input_tokens,
351
+ SUM(output_tokens) AS output_tokens,
352
+ SUM(reasoning_output_tokens) AS reasoning_output_tokens,
353
+ SUM(total_tokens) AS total_tokens,
354
+ AVG(cache_ratio) AS avg_cache_ratio,
355
+ AVG(reasoning_output_ratio) AS avg_reasoning_output_ratio,
356
+ AVG(context_window_percent) AS avg_context_window_percent,
357
+ MAX(event_timestamp) AS latest_event
358
+ FROM usage_events
359
+ {where_clause}
360
+ GROUP BY group_key
361
+ ORDER BY total_tokens DESC
362
+ LIMIT ?
363
+ """
364
+ params.append(limit)
365
+ with connect(db_path) as conn:
366
+ init_db(conn)
367
+ return [_row_to_dict(row) for row in conn.execute(sql, params)]
368
+
369
+
370
+ def query_session_usage(
371
+ db_path: Path = DEFAULT_DB_PATH,
372
+ session_id: str | None = None,
373
+ limit: int = 200,
374
+ ) -> list[dict[str, Any]]:
375
+ with connect(db_path) as conn:
376
+ init_db(conn)
377
+ if session_id is None:
378
+ row = conn.execute(
379
+ """
380
+ SELECT session_id
381
+ FROM usage_events
382
+ GROUP BY session_id
383
+ ORDER BY MAX(event_timestamp) DESC
384
+ LIMIT 1
385
+ """
386
+ ).fetchone()
387
+ if row is None:
388
+ return []
389
+ session_id = str(row["session_id"])
390
+ rows = conn.execute(
391
+ """
392
+ SELECT *
393
+ FROM usage_events
394
+ WHERE session_id = ?
395
+ ORDER BY event_timestamp, cumulative_total_tokens
396
+ LIMIT ?
397
+ """,
398
+ (session_id, limit),
399
+ )
400
+ return [_row_to_dict(row) for row in rows]
401
+
402
+
403
+ def query_usage_record(
404
+ db_path: Path = DEFAULT_DB_PATH,
405
+ record_id: str | None = None,
406
+ ) -> dict[str, Any] | None:
407
+ """Return one aggregate usage row by stable record id."""
408
+
409
+ if not record_id:
410
+ return None
411
+ with connect(db_path) as conn:
412
+ init_db(conn)
413
+ row = conn.execute(
414
+ """
415
+ SELECT *
416
+ FROM usage_events
417
+ WHERE record_id = ?
418
+ LIMIT 1
419
+ """,
420
+ (record_id,),
421
+ ).fetchone()
422
+ return _row_to_dict(row) if row is not None else None
423
+
424
+
425
+ def query_dashboard_events(
426
+ db_path: Path = DEFAULT_DB_PATH,
427
+ limit: int | None = 5000,
428
+ offset: int = 0,
429
+ since: str | None = None,
430
+ until: str | None = None,
431
+ model: str | None = None,
432
+ effort: str | None = None,
433
+ thread: str | None = None,
434
+ min_tokens: int | None = None,
435
+ include_archived: bool = True,
436
+ ) -> list[dict[str, Any]]:
437
+ where_clause, params = _usage_where_clause(
438
+ since=since,
439
+ until=until,
440
+ model=model,
441
+ effort=effort,
442
+ thread=thread,
443
+ min_tokens=min_tokens,
444
+ table_alias="usage_events",
445
+ include_archived=include_archived,
446
+ )
447
+ parent_where_clause, parent_params = _usage_where_clause(include_archived=include_archived)
448
+ parent_thread_filter = (
449
+ f"{parent_where_clause} AND thread_name IS NOT NULL"
450
+ if parent_where_clause
451
+ else "WHERE thread_name IS NOT NULL"
452
+ )
453
+ normalized_limit = _normalize_limit(limit)
454
+ normalized_offset = _normalize_offset(offset)
455
+ limit_clause = ""
456
+ query_params = [*parent_params, *params]
457
+ if normalized_limit is not None:
458
+ limit_clause = "LIMIT ?"
459
+ query_params.append(normalized_limit)
460
+ if normalized_offset:
461
+ limit_clause += " OFFSET ?"
462
+ query_params.append(normalized_offset)
463
+ elif normalized_offset:
464
+ limit_clause = "LIMIT -1 OFFSET ?"
465
+ query_params.append(normalized_offset)
466
+ with connect(db_path) as conn:
467
+ init_db(conn)
468
+ rows = conn.execute(
469
+ f"""
470
+ SELECT
471
+ usage_events.*,
472
+ coalesce(
473
+ usage_events.parent_thread_name,
474
+ parent_threads.thread_name
475
+ ) AS resolved_parent_thread_name,
476
+ coalesce(
477
+ usage_events.parent_session_updated_at,
478
+ parent_threads.session_updated_at
479
+ ) AS resolved_parent_session_updated_at
480
+ FROM usage_events
481
+ LEFT JOIN (
482
+ SELECT
483
+ session_id,
484
+ max(thread_name) AS thread_name,
485
+ max(session_updated_at) AS session_updated_at
486
+ FROM usage_events
487
+ {parent_thread_filter}
488
+ GROUP BY session_id
489
+ ) AS parent_threads
490
+ ON usage_events.parent_session_id = parent_threads.session_id
491
+ {where_clause}
492
+ ORDER BY usage_events.event_timestamp DESC, usage_events.cumulative_total_tokens DESC
493
+ {limit_clause}
494
+ """,
495
+ query_params,
496
+ )
497
+ return [_row_to_dict(row) for row in rows]
498
+
499
+
500
+ def query_dashboard_event_count(
501
+ db_path: Path = DEFAULT_DB_PATH,
502
+ since: str | None = None,
503
+ until: str | None = None,
504
+ model: str | None = None,
505
+ effort: str | None = None,
506
+ thread: str | None = None,
507
+ min_tokens: int | None = None,
508
+ include_archived: bool = True,
509
+ ) -> int:
510
+ """Return total aggregate usage rows available for the dashboard window."""
511
+
512
+ where_clause, params = _usage_where_clause(
513
+ since=since,
514
+ until=until,
515
+ model=model,
516
+ effort=effort,
517
+ thread=thread,
518
+ min_tokens=min_tokens,
519
+ include_archived=include_archived,
520
+ )
521
+ with connect(db_path) as conn:
522
+ init_db(conn)
523
+ row = conn.execute(
524
+ f"""
525
+ SELECT COUNT(*) AS row_count
526
+ FROM usage_events
527
+ {where_clause}
528
+ """,
529
+ params,
530
+ ).fetchone()
531
+ return int(row["row_count"] if row is not None else 0)
532
+
533
+
534
+ def query_most_expensive_calls(
535
+ db_path: Path = DEFAULT_DB_PATH, limit: int = 20, since: str | None = None
536
+ ) -> list[dict[str, Any]]:
537
+ """Return the largest aggregate model calls by last-call token count."""
538
+
539
+ where_clause, params = _since_where_clause(since)
540
+ with connect(db_path) as conn:
541
+ init_db(conn)
542
+ rows = conn.execute(
543
+ f"""
544
+ SELECT *
545
+ FROM usage_events
546
+ {where_clause}
547
+ ORDER BY total_tokens DESC, event_timestamp DESC
548
+ LIMIT ?
549
+ """,
550
+ (*params, limit),
551
+ )
552
+ return [_row_to_dict(row) for row in rows]
553
+
554
+
555
+ def export_usage_csv(
556
+ output_path: Path,
557
+ db_path: Path = DEFAULT_DB_PATH,
558
+ limit: int | None = None,
559
+ privacy_mode: str = "normal",
560
+ ) -> int:
561
+ output_path.parent.mkdir(parents=True, exist_ok=True)
562
+ privacy_mode = validate_privacy_mode(privacy_mode)
563
+ sql = "SELECT * FROM usage_events ORDER BY event_timestamp, cumulative_total_tokens"
564
+ params: tuple[int, ...] = ()
565
+ normalized_limit = _normalize_limit(limit)
566
+ if normalized_limit is not None:
567
+ sql += " LIMIT ?"
568
+ params = (normalized_limit,)
569
+ with connect(db_path) as conn:
570
+ init_db(conn)
571
+ rows = [_row_to_dict(row) for row in conn.execute(sql, params)]
572
+ rows = apply_project_privacy_to_rows(rows, privacy_mode=privacy_mode)
573
+
574
+ with output_path.open("w", newline="", encoding="utf-8") as handle:
575
+ writer = csv.DictWriter(handle, fieldnames=EVENT_COLUMNS)
576
+ writer.writeheader()
577
+ for row in rows:
578
+ writer.writerow({column: row.get(column) for column in EVENT_COLUMNS})
579
+ return len(rows)
580
+
581
+
582
+ def _group_expression(group_by: str) -> str:
583
+ mapping = {
584
+ "date": "substr(event_timestamp, 1, 10)",
585
+ "model": "coalesce(model, 'Unknown model')",
586
+ "effort": "coalesce(effort, 'Unknown effort')",
587
+ "cwd": "coalesce(cwd, 'Unknown cwd')",
588
+ "thread": "coalesce(thread_name, parent_thread_name, session_id)",
589
+ "session": "session_id",
590
+ "thread_source": "coalesce(thread_source, 'user')",
591
+ "subagent_type": "coalesce(subagent_type, 'not subagent')",
592
+ "agent_role": "coalesce(agent_role, 'not agent role')",
593
+ "parent_session": "coalesce(parent_session_id, 'no parent session')",
594
+ "parent_thread": "coalesce(parent_thread_name, 'no parent thread')",
595
+ }
596
+ try:
597
+ return mapping[group_by]
598
+ except KeyError as exc:
599
+ allowed = ", ".join(sorted(mapping))
600
+ raise ValueError(f"group_by must be one of: {allowed}") from exc
601
+
602
+
603
+ def _since_where_clause(since: str | None) -> tuple[str, list[Any]]:
604
+ return _usage_where_clause(since=since)
605
+
606
+
607
+ def _usage_where_clause(
608
+ *,
609
+ since: str | None = None,
610
+ until: str | None = None,
611
+ model: str | None = None,
612
+ effort: str | None = None,
613
+ thread: str | None = None,
614
+ min_tokens: int | None = None,
615
+ table_alias: str | None = None,
616
+ include_archived: bool = True,
617
+ ) -> tuple[str, list[Any]]:
618
+ prefix = f"{table_alias}." if table_alias else ""
619
+ clauses: list[str] = []
620
+ params: list[Any] = []
621
+ if since:
622
+ clauses.append(f"{prefix}event_timestamp >= ?")
623
+ params.append(since)
624
+ if until:
625
+ clauses.append(f"{prefix}event_timestamp <= ?")
626
+ params.append(until)
627
+ if model:
628
+ clauses.append(f"{prefix}model = ?")
629
+ params.append(model)
630
+ if effort:
631
+ clauses.append(f"{prefix}effort = ?")
632
+ params.append(effort)
633
+ if thread:
634
+ clauses.append(
635
+ "("
636
+ f"{prefix}thread_name = ? OR "
637
+ f"{prefix}parent_thread_name = ? OR "
638
+ f"{prefix}session_id = ?"
639
+ ")"
640
+ )
641
+ params.extend([thread, thread, thread])
642
+ if min_tokens is not None:
643
+ clauses.append(f"{prefix}total_tokens >= ?")
644
+ params.append(min_tokens)
645
+ if not include_archived:
646
+ clauses.extend([f"{prefix}source_file NOT LIKE ?"] * len(_ARCHIVED_SOURCE_PATTERNS))
647
+ params.extend(_ARCHIVED_SOURCE_PATTERNS)
648
+ if not clauses:
649
+ return "", []
650
+ return "WHERE " + " AND ".join(clauses), params
651
+
652
+
653
+ def _normalize_limit(limit: int | None) -> int | None:
654
+ if limit is None or limit <= 0:
655
+ return None
656
+ return int(limit)
657
+
658
+
659
+ def _normalize_offset(offset: int | None) -> int:
660
+ if offset is None or offset <= 0:
661
+ return 0
662
+ return int(offset)
663
+
664
+
665
+ def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
666
+ return dict(row)