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,71 @@
1
+ """SQLite schema metadata for aggregate usage events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class UsageColumn:
11
+ """One persisted usage_events column."""
12
+
13
+ name: str
14
+ declaration: str
15
+ alter_type: str
16
+ repairable: bool = False
17
+
18
+
19
+ USAGE_EVENT_COLUMNS = (
20
+ UsageColumn("record_id", "TEXT PRIMARY KEY", "TEXT"),
21
+ UsageColumn("session_id", "TEXT NOT NULL", "TEXT"),
22
+ UsageColumn("thread_name", "TEXT", "TEXT", repairable=True),
23
+ UsageColumn("session_updated_at", "TEXT", "TEXT", repairable=True),
24
+ UsageColumn("event_timestamp", "TEXT NOT NULL", "TEXT"),
25
+ UsageColumn("source_file", "TEXT NOT NULL", "TEXT"),
26
+ UsageColumn("line_number", "INTEGER NOT NULL", "INTEGER"),
27
+ UsageColumn("turn_id", "TEXT", "TEXT", repairable=True),
28
+ UsageColumn("turn_timestamp", "TEXT", "TEXT", repairable=True),
29
+ UsageColumn("cwd", "TEXT", "TEXT", repairable=True),
30
+ UsageColumn("model", "TEXT", "TEXT", repairable=True),
31
+ UsageColumn("effort", "TEXT", "TEXT", repairable=True),
32
+ UsageColumn("current_date", "TEXT", "TEXT", repairable=True),
33
+ UsageColumn("timezone", "TEXT", "TEXT", repairable=True),
34
+ UsageColumn("thread_source", "TEXT", "TEXT", repairable=True),
35
+ UsageColumn("subagent_type", "TEXT", "TEXT", repairable=True),
36
+ UsageColumn("agent_role", "TEXT", "TEXT", repairable=True),
37
+ UsageColumn("agent_nickname", "TEXT", "TEXT", repairable=True),
38
+ UsageColumn("parent_session_id", "TEXT", "TEXT", repairable=True),
39
+ UsageColumn("parent_thread_name", "TEXT", "TEXT", repairable=True),
40
+ UsageColumn("parent_session_updated_at", "TEXT", "TEXT", repairable=True),
41
+ UsageColumn("model_context_window", "INTEGER", "INTEGER", repairable=True),
42
+ UsageColumn("input_tokens", "INTEGER NOT NULL", "INTEGER"),
43
+ UsageColumn("cached_input_tokens", "INTEGER NOT NULL", "INTEGER"),
44
+ UsageColumn("output_tokens", "INTEGER NOT NULL", "INTEGER"),
45
+ UsageColumn("reasoning_output_tokens", "INTEGER NOT NULL", "INTEGER"),
46
+ UsageColumn("total_tokens", "INTEGER NOT NULL", "INTEGER"),
47
+ UsageColumn("cumulative_input_tokens", "INTEGER NOT NULL", "INTEGER"),
48
+ UsageColumn("cumulative_cached_input_tokens", "INTEGER NOT NULL", "INTEGER"),
49
+ UsageColumn("cumulative_output_tokens", "INTEGER NOT NULL", "INTEGER"),
50
+ UsageColumn("cumulative_reasoning_output_tokens", "INTEGER NOT NULL", "INTEGER"),
51
+ UsageColumn("cumulative_total_tokens", "INTEGER NOT NULL", "INTEGER"),
52
+ UsageColumn("uncached_input_tokens", "INTEGER NOT NULL", "INTEGER"),
53
+ UsageColumn("cache_ratio", "REAL NOT NULL", "REAL"),
54
+ UsageColumn("reasoning_output_ratio", "REAL NOT NULL", "REAL"),
55
+ UsageColumn("context_window_percent", "REAL NOT NULL", "REAL"),
56
+ )
57
+
58
+ USAGE_EVENT_COLUMN_NAMES = tuple(column.name for column in USAGE_EVENT_COLUMNS)
59
+ USAGE_EVENT_CREATE_COLUMNS_SQL = ",\n ".join(
60
+ f"{column.name} {column.declaration}" for column in USAGE_EVENT_COLUMNS
61
+ )
62
+ USAGE_EVENT_SCHEMA_CHECKSUM = hashlib.sha256(
63
+ "|".join(f"{column.name}:{column.declaration}" for column in USAGE_EVENT_COLUMNS).encode(
64
+ "utf-8"
65
+ )
66
+ ).hexdigest()
67
+ USAGE_EVENT_REPAIR_COLUMNS = {
68
+ column.name: column.alter_type
69
+ for column in USAGE_EVENT_COLUMNS
70
+ if column.repairable
71
+ }
@@ -0,0 +1,400 @@
1
+ """Local dashboard server with lazy context API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import json
7
+ import secrets
8
+ import sqlite3
9
+ import threading
10
+ import webbrowser
11
+ from datetime import datetime, timezone
12
+ from functools import partial
13
+ from http import HTTPStatus
14
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
15
+ from ipaddress import ip_address
16
+ from pathlib import Path
17
+ from urllib.parse import parse_qs, urlparse
18
+
19
+ from codex_usage_tracker.context import DEFAULT_CONTEXT_CHARS, load_call_context
20
+ from codex_usage_tracker.dashboard import dashboard_payload, generate_dashboard
21
+ from codex_usage_tracker.paths import (
22
+ DEFAULT_ALLOWANCE_PATH,
23
+ DEFAULT_CODEX_HOME,
24
+ DEFAULT_DASHBOARD_PATH,
25
+ DEFAULT_PRICING_PATH,
26
+ DEFAULT_PROJECTS_PATH,
27
+ DEFAULT_RATE_CARD_PATH,
28
+ DEFAULT_THRESHOLDS_PATH,
29
+ )
30
+ from codex_usage_tracker.store import refresh_usage_index
31
+
32
+
33
+ def serve_dashboard(
34
+ db_path: Path,
35
+ output_path: Path = DEFAULT_DASHBOARD_PATH,
36
+ pricing_path: Path = DEFAULT_PRICING_PATH,
37
+ allowance_path: Path = DEFAULT_ALLOWANCE_PATH,
38
+ rate_card_path: Path = DEFAULT_RATE_CARD_PATH,
39
+ limit: int = 5000,
40
+ since: str | None = None,
41
+ host: str = "127.0.0.1",
42
+ port: int = 8765,
43
+ context_chars: int = DEFAULT_CONTEXT_CHARS,
44
+ open_browser: bool = False,
45
+ codex_home: Path = DEFAULT_CODEX_HOME,
46
+ include_archived: bool = False,
47
+ context_api: str = "explicit",
48
+ thresholds_path: Path = DEFAULT_THRESHOLDS_PATH,
49
+ projects_path: Path = DEFAULT_PROJECTS_PATH,
50
+ privacy_mode: str = "normal",
51
+ ) -> None:
52
+ """Generate and serve the dashboard plus a localhost-only context endpoint."""
53
+
54
+ _validate_loopback_host(host)
55
+ _validate_context_api_mode(context_api)
56
+ api_token = secrets.token_urlsafe(32)
57
+ context_api_enabled = context_api != "disabled"
58
+ output = generate_dashboard(
59
+ db_path=db_path,
60
+ output_path=output_path,
61
+ limit=limit,
62
+ pricing_path=pricing_path,
63
+ allowance_path=allowance_path,
64
+ rate_card_path=rate_card_path,
65
+ since=since,
66
+ api_token=api_token,
67
+ context_api_enabled=context_api_enabled,
68
+ thresholds_path=thresholds_path,
69
+ projects_path=projects_path,
70
+ privacy_mode=privacy_mode,
71
+ include_archived=include_archived,
72
+ )
73
+ handler = partial(
74
+ _UsageDashboardHandler,
75
+ directory=str(output.parent),
76
+ db_path=db_path,
77
+ pricing_path=pricing_path,
78
+ allowance_path=allowance_path,
79
+ rate_card_path=rate_card_path,
80
+ thresholds_path=thresholds_path,
81
+ projects_path=projects_path,
82
+ privacy_mode=privacy_mode,
83
+ limit=limit,
84
+ since=since,
85
+ codex_home=codex_home,
86
+ include_archived=include_archived,
87
+ dashboard_name=output.name,
88
+ context_chars=context_chars,
89
+ api_token=api_token,
90
+ context_api_enabled=context_api_enabled,
91
+ refresh_lock=threading.Lock(),
92
+ )
93
+ server = ThreadingHTTPServer((host, port), handler)
94
+ url = f"http://{_url_host(host)}:{port}/{output.name}"
95
+ print(f"Serving Codex usage dashboard at {url}")
96
+ context_mode = "enabled for explicit row actions" if context_api_enabled else "disabled"
97
+ print("Aggregate rows refresh through /api/usage with a per-server token.")
98
+ print(f"Raw context API is {context_mode}; context is never embedded in the dashboard HTML.")
99
+ if open_browser:
100
+ webbrowser.open(url)
101
+ try:
102
+ server.serve_forever()
103
+ except KeyboardInterrupt:
104
+ print("\nStopping dashboard server.")
105
+ finally:
106
+ server.server_close()
107
+
108
+
109
+ class _UsageDashboardHandler(SimpleHTTPRequestHandler):
110
+ def __init__(
111
+ self,
112
+ *args: object,
113
+ db_path: Path,
114
+ pricing_path: Path,
115
+ allowance_path: Path,
116
+ thresholds_path: Path,
117
+ projects_path: Path,
118
+ limit: int,
119
+ since: str | None,
120
+ codex_home: Path,
121
+ include_archived: bool,
122
+ dashboard_name: str,
123
+ context_chars: int,
124
+ api_token: str,
125
+ context_api_enabled: bool,
126
+ refresh_lock: threading.Lock,
127
+ privacy_mode: str = "normal",
128
+ rate_card_path: Path = DEFAULT_RATE_CARD_PATH,
129
+ **kwargs: object,
130
+ ) -> None:
131
+ self._db_path = db_path
132
+ self._pricing_path = pricing_path
133
+ self._allowance_path = allowance_path
134
+ self._rate_card_path = rate_card_path
135
+ self._thresholds_path = thresholds_path
136
+ self._projects_path = projects_path
137
+ self._privacy_mode = privacy_mode
138
+ self._limit = limit
139
+ self._since = since
140
+ self._codex_home = codex_home
141
+ self._include_archived = include_archived
142
+ self._dashboard_name = dashboard_name
143
+ self._context_chars = context_chars
144
+ self._api_token = api_token
145
+ self._context_api_enabled = context_api_enabled
146
+ self._refresh_lock = refresh_lock
147
+ super().__init__(*args, **kwargs)
148
+
149
+ def do_GET(self) -> None: # noqa: N802 - stdlib hook name
150
+ parsed = urlparse(self.path)
151
+ if not self._request_origin_allowed():
152
+ self._send_json(HTTPStatus.FORBIDDEN, {"error": "Request host or origin is not allowed"})
153
+ return
154
+ if parsed.path == "/api/context":
155
+ self._handle_context(parsed.query)
156
+ return
157
+ if parsed.path == "/api/usage":
158
+ self._handle_usage(parsed.query)
159
+ return
160
+ if parsed.path == "/":
161
+ self.path = f"/{self._dashboard_name}"
162
+ super().do_GET()
163
+
164
+ def end_headers(self) -> None:
165
+ self.send_header("X-Content-Type-Options", "nosniff")
166
+ self.send_header("Referrer-Policy", "no-referrer")
167
+ self.send_header(
168
+ "Content-Security-Policy",
169
+ "default-src 'self'; script-src 'self'; "
170
+ "style-src 'self'; connect-src 'self'; "
171
+ "img-src 'self' data:; object-src 'none'; base-uri 'none'",
172
+ )
173
+ super().end_headers()
174
+
175
+ def log_message(self, format: str, *args: object) -> None:
176
+ if self.path.startswith("/api/usage"):
177
+ return
178
+ super().log_message(format, *args)
179
+
180
+ def _handle_context(self, query: str) -> None:
181
+ params = parse_qs(query)
182
+ if not self._context_api_enabled:
183
+ self._send_json(
184
+ HTTPStatus.FORBIDDEN,
185
+ {"error": "Context API is disabled for this dashboard server."},
186
+ )
187
+ return
188
+ if not self._has_valid_api_token(params):
189
+ self._send_json(HTTPStatus.FORBIDDEN, {"error": "Valid API token is required"})
190
+ return
191
+ record_id = _first(params.get("record_id"))
192
+ if not record_id:
193
+ self._send_json(
194
+ HTTPStatus.BAD_REQUEST,
195
+ {"error": "record_id is required"},
196
+ )
197
+ return
198
+ include_tool_output = _truthy(_first(params.get("include_tool_output")))
199
+ try:
200
+ payload = load_call_context(
201
+ record_id=record_id,
202
+ db_path=self._db_path,
203
+ max_chars=self._context_chars,
204
+ include_tool_output=include_tool_output,
205
+ )
206
+ except sqlite3.Error as exc:
207
+ self._send_json(
208
+ HTTPStatus.INTERNAL_SERVER_ERROR,
209
+ {"error": f"Database error while loading context: {exc}"},
210
+ )
211
+ return
212
+ except ValueError as exc:
213
+ self._send_json(HTTPStatus.NOT_FOUND, {"error": str(exc)})
214
+ return
215
+ except FileNotFoundError as exc:
216
+ self._send_json(HTTPStatus.NOT_FOUND, {"error": str(exc)})
217
+ return
218
+ except OSError as exc:
219
+ self._send_json(
220
+ HTTPStatus.INTERNAL_SERVER_ERROR,
221
+ {"error": f"Could not read source log: {exc}"},
222
+ )
223
+ return
224
+ self._send_json(HTTPStatus.OK, payload)
225
+
226
+ def _handle_usage(self, query: str) -> None:
227
+ params = parse_qs(query)
228
+ limit = _parse_limit(_first(params.get("limit")), self._limit)
229
+ offset = _parse_offset(_first(params.get("offset")))
230
+ include_archived = _parse_bool(_first(params.get("include_archived")), self._include_archived)
231
+ refresh_result = None
232
+ try:
233
+ if _truthy(_first(params.get("refresh"))):
234
+ if not self._has_valid_api_token(params):
235
+ self._send_json(
236
+ HTTPStatus.FORBIDDEN,
237
+ {"error": "Valid API token is required for refresh"},
238
+ )
239
+ return
240
+ with self._refresh_lock:
241
+ result = refresh_usage_index(
242
+ codex_home=self._codex_home,
243
+ db_path=self._db_path,
244
+ include_archived=include_archived,
245
+ )
246
+ refresh_result = {
247
+ "scanned_files": result.scanned_files,
248
+ "parsed_events": result.parsed_events,
249
+ "skipped_events": result.skipped_events,
250
+ "inserted_or_updated_events": result.inserted_or_updated_events,
251
+ "db_path": result.db_path,
252
+ "parser_diagnostics": result.parser_diagnostics,
253
+ "include_archived": include_archived,
254
+ }
255
+ payload = dashboard_payload(
256
+ db_path=self._db_path,
257
+ limit=limit,
258
+ offset=offset,
259
+ pricing_path=self._pricing_path,
260
+ allowance_path=self._allowance_path,
261
+ rate_card_path=self._rate_card_path,
262
+ thresholds_path=self._thresholds_path,
263
+ projects_path=self._projects_path,
264
+ privacy_mode=self._privacy_mode,
265
+ since=self._since,
266
+ api_token=self._api_token,
267
+ context_api_enabled=self._context_api_enabled,
268
+ include_archived=include_archived,
269
+ )
270
+ except sqlite3.Error as exc:
271
+ self._send_json(
272
+ HTTPStatus.INTERNAL_SERVER_ERROR,
273
+ {"error": f"Database error while reading usage data: {exc}"},
274
+ )
275
+ return
276
+ except OSError as exc:
277
+ self._send_json(
278
+ HTTPStatus.INTERNAL_SERVER_ERROR,
279
+ {"error": f"Could not read aggregate dashboard data: {exc}"},
280
+ )
281
+ return
282
+ payload["refreshed_at"] = _utc_now()
283
+ payload["refresh_result"] = refresh_result
284
+ self._send_json(HTTPStatus.OK, payload)
285
+
286
+ def _request_origin_allowed(self) -> bool:
287
+ if not _allowed_loopback_host(_host_header_name(self.headers.get("Host"))):
288
+ return False
289
+ origin = self.headers.get("Origin")
290
+ if not origin:
291
+ return True
292
+ parsed = urlparse(origin)
293
+ if parsed.scheme not in {"http", "https"}:
294
+ return False
295
+ if not _allowed_loopback_host(parsed.hostname):
296
+ return False
297
+ return parsed.port is None or parsed.port == self.server.server_port
298
+
299
+ def _has_valid_api_token(self, params: dict[str, list[str]]) -> bool:
300
+ provided = self.headers.get("X-Codex-Usage-Token") or _first(params.get("api_token")) or ""
301
+ return hmac.compare_digest(str(provided), self._api_token)
302
+
303
+ def _send_json(self, status: HTTPStatus, payload: dict[str, object]) -> None:
304
+ body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
305
+ self.send_response(status)
306
+ self.send_header("Content-Type", "application/json; charset=utf-8")
307
+ self.send_header("Cache-Control", "no-store")
308
+ self.send_header("Content-Length", str(len(body)))
309
+ self.end_headers()
310
+ self.wfile.write(body)
311
+
312
+
313
+ def _first(values: list[str] | None) -> str | None:
314
+ return values[0] if values else None
315
+
316
+
317
+ def _truthy(value: str | None) -> bool:
318
+ return str(value or "").lower() in {"1", "true", "yes", "on"}
319
+
320
+
321
+ def _parse_bool(value: str | None, default: bool) -> bool:
322
+ if value is None or value == "":
323
+ return default
324
+ normalized = value.lower()
325
+ if normalized in {"1", "true", "yes", "on"}:
326
+ return True
327
+ if normalized in {"0", "false", "no", "off"}:
328
+ return False
329
+ return default
330
+
331
+
332
+ def _parse_limit(value: str | None, default: int | None) -> int | None:
333
+ if value is None or value == "":
334
+ return default
335
+ if value.lower() == "all":
336
+ return None
337
+ try:
338
+ limit = int(value)
339
+ except ValueError:
340
+ return default
341
+ if limit <= 0:
342
+ return None
343
+ return limit
344
+
345
+
346
+ def _parse_offset(value: str | None) -> int:
347
+ if value is None or value == "":
348
+ return 0
349
+ try:
350
+ offset = int(value)
351
+ except ValueError:
352
+ return 0
353
+ return max(offset, 0)
354
+
355
+
356
+ def _utc_now() -> str:
357
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
358
+
359
+
360
+ def _validate_loopback_host(host: str) -> None:
361
+ if host == "localhost":
362
+ return
363
+ try:
364
+ address = ip_address(host)
365
+ except ValueError as exc:
366
+ raise ValueError(
367
+ "serve-dashboard --host must be localhost, 127.0.0.1, or ::1"
368
+ ) from exc
369
+ if not address.is_loopback:
370
+ raise ValueError("serve-dashboard refuses to expose raw context off localhost")
371
+
372
+
373
+ def _validate_context_api_mode(mode: str) -> None:
374
+ if mode not in {"explicit", "disabled"}:
375
+ raise ValueError("--context-api must be explicit or disabled")
376
+
377
+
378
+ def _allowed_loopback_host(host: str | None) -> bool:
379
+ if not host:
380
+ return False
381
+ if host == "localhost":
382
+ return True
383
+ try:
384
+ return ip_address(host).is_loopback
385
+ except ValueError:
386
+ return False
387
+
388
+
389
+ def _host_header_name(value: str | None) -> str | None:
390
+ if not value:
391
+ return None
392
+ host = value.strip()
393
+ if host.startswith("["):
394
+ end = host.find("]")
395
+ return host[1:end] if end > 0 else None
396
+ return host.split(":", 1)[0]
397
+
398
+
399
+ def _url_host(host: str) -> str:
400
+ return f"[{host}]" if ":" in host and not host.startswith("[") else host