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.
- codex_usage_tracker/__init__.py +7 -0
- codex_usage_tracker/__main__.py +6 -0
- codex_usage_tracker/allowance.py +759 -0
- codex_usage_tracker/api_payloads.py +90 -0
- codex_usage_tracker/cli.py +1326 -0
- codex_usage_tracker/context.py +410 -0
- codex_usage_tracker/costing.py +176 -0
- codex_usage_tracker/dashboard.py +389 -0
- codex_usage_tracker/diagnostics.py +624 -0
- codex_usage_tracker/formatting.py +225 -0
- codex_usage_tracker/json_contracts.py +350 -0
- codex_usage_tracker/mcp_server.py +371 -0
- codex_usage_tracker/models.py +92 -0
- codex_usage_tracker/parser.py +491 -0
- codex_usage_tracker/paths.py +18 -0
- codex_usage_tracker/plugin_data/__init__.py +1 -0
- codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
- codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
- codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
- codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
- codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
- codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
- codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
- codex_usage_tracker/plugin_installer.py +312 -0
- codex_usage_tracker/pricing.py +57 -0
- codex_usage_tracker/pricing_config.py +223 -0
- codex_usage_tracker/pricing_estimates.py +44 -0
- codex_usage_tracker/pricing_openai.py +253 -0
- codex_usage_tracker/projects.py +347 -0
- codex_usage_tracker/recommendations.py +270 -0
- codex_usage_tracker/reports.py +637 -0
- codex_usage_tracker/schema.py +71 -0
- codex_usage_tracker/server.py +400 -0
- codex_usage_tracker/store.py +666 -0
- codex_usage_tracker/support.py +147 -0
- codex_usage_tracker/threads.py +183 -0
- codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
- codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
- codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
- codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
- codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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)
|