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,491 @@
|
|
|
1
|
+
"""Parse Codex JSONL session logs into aggregate usage records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterable, MutableMapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from codex_usage_tracker.models import SessionInfo, UsageEvent
|
|
14
|
+
from codex_usage_tracker.paths import DEFAULT_CODEX_HOME
|
|
15
|
+
|
|
16
|
+
SESSION_ID_RE = re.compile(
|
|
17
|
+
r"rollout-[^-]+-[0-9T:-]+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
PARSER_ADAPTER_VERSION = "codex-jsonl-v1"
|
|
21
|
+
PARSER_DIAGNOSTIC_KEYS = (
|
|
22
|
+
"invalid_json",
|
|
23
|
+
"missing_payload",
|
|
24
|
+
"unknown_filename_format",
|
|
25
|
+
"unknown_event_shape",
|
|
26
|
+
"missing_info",
|
|
27
|
+
"missing_last_token_usage",
|
|
28
|
+
"missing_total_token_usage",
|
|
29
|
+
"missing_cumulative_total",
|
|
30
|
+
"duplicate_cumulative_total",
|
|
31
|
+
"invalid_integer",
|
|
32
|
+
"partial_field_count",
|
|
33
|
+
"invalid_model_context_window",
|
|
34
|
+
"skipped_events",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ParserAdapter:
|
|
40
|
+
"""Versioned parser adapter for one Codex log format family."""
|
|
41
|
+
|
|
42
|
+
version: str = PARSER_ADAPTER_VERSION
|
|
43
|
+
|
|
44
|
+
def parse_file(
|
|
45
|
+
self,
|
|
46
|
+
path: Path,
|
|
47
|
+
session_index: dict[str, SessionInfo] | None = None,
|
|
48
|
+
stats: MutableMapping[str, int] | None = None,
|
|
49
|
+
) -> list[UsageEvent]:
|
|
50
|
+
return _parse_codex_jsonl_v1(path, session_index=session_index, stats=stats)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULT_PARSER_ADAPTER = ParserAdapter()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_session_index(codex_home: Path = DEFAULT_CODEX_HOME) -> dict[str, SessionInfo]:
|
|
57
|
+
"""Load Codex thread names without reading transcript content."""
|
|
58
|
+
|
|
59
|
+
index_path = codex_home / "session_index.jsonl"
|
|
60
|
+
sessions: dict[str, SessionInfo] = {}
|
|
61
|
+
if not index_path.exists():
|
|
62
|
+
return sessions
|
|
63
|
+
|
|
64
|
+
with index_path.open("r", encoding="utf-8") as handle:
|
|
65
|
+
for line in handle:
|
|
66
|
+
try:
|
|
67
|
+
payload = json.loads(line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
session_id = payload.get("id")
|
|
71
|
+
if not isinstance(session_id, str):
|
|
72
|
+
continue
|
|
73
|
+
sessions[session_id] = SessionInfo(
|
|
74
|
+
session_id=session_id,
|
|
75
|
+
thread_name=_optional_str(payload.get("thread_name")),
|
|
76
|
+
updated_at=_optional_str(payload.get("updated_at")),
|
|
77
|
+
)
|
|
78
|
+
return sessions
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_session_logs(
|
|
82
|
+
codex_home: Path = DEFAULT_CODEX_HOME, include_archived: bool = False
|
|
83
|
+
) -> list[Path]:
|
|
84
|
+
"""Find local Codex JSONL logs."""
|
|
85
|
+
|
|
86
|
+
paths = list((codex_home / "sessions").glob("**/*.jsonl"))
|
|
87
|
+
if include_archived:
|
|
88
|
+
paths.extend((codex_home / "archived_sessions").glob("*.jsonl"))
|
|
89
|
+
return sorted(path for path in paths if path.is_file())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_usage_events(
|
|
93
|
+
paths: Iterable[Path],
|
|
94
|
+
session_index: dict[str, SessionInfo] | None = None,
|
|
95
|
+
stats: MutableMapping[str, int] | None = None,
|
|
96
|
+
) -> list[UsageEvent]:
|
|
97
|
+
"""Parse all provided logs into aggregate usage events."""
|
|
98
|
+
|
|
99
|
+
index = session_index or {}
|
|
100
|
+
events: list[UsageEvent] = []
|
|
101
|
+
for path in paths:
|
|
102
|
+
events.extend(parse_usage_events_from_file(path, index, stats=stats))
|
|
103
|
+
return events
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_usage_events_from_file(
|
|
107
|
+
path: Path,
|
|
108
|
+
session_index: dict[str, SessionInfo] | None = None,
|
|
109
|
+
stats: MutableMapping[str, int] | None = None,
|
|
110
|
+
) -> list[UsageEvent]:
|
|
111
|
+
"""Parse one Codex JSONL log without storing raw message content."""
|
|
112
|
+
|
|
113
|
+
return DEFAULT_PARSER_ADAPTER.parse_file(path, session_index=session_index, stats=stats)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def inspect_log(
|
|
117
|
+
path: Path,
|
|
118
|
+
session_index: dict[str, SessionInfo] | None = None,
|
|
119
|
+
) -> dict[str, object]:
|
|
120
|
+
"""Return aggregate-only parser observations for one log without DB writes."""
|
|
121
|
+
|
|
122
|
+
stats = empty_parser_diagnostics()
|
|
123
|
+
events = parse_usage_events_from_file(path, session_index=session_index, stats=stats)
|
|
124
|
+
session_ids = sorted({event.session_id for event in events})
|
|
125
|
+
models = sorted({event.model for event in events if event.model})
|
|
126
|
+
efforts = sorted({event.effort for event in events if event.effort})
|
|
127
|
+
first_event = events[0] if events else None
|
|
128
|
+
last_event = events[-1] if events else None
|
|
129
|
+
return {
|
|
130
|
+
"path": str(path),
|
|
131
|
+
"adapter": DEFAULT_PARSER_ADAPTER.version,
|
|
132
|
+
"file_session_id": _session_id_from_path(path),
|
|
133
|
+
"event_count": len(events),
|
|
134
|
+
"session_ids": session_ids,
|
|
135
|
+
"models": models,
|
|
136
|
+
"efforts": efforts,
|
|
137
|
+
"first_event_timestamp": first_event.event_timestamp if first_event else None,
|
|
138
|
+
"last_event_timestamp": last_event.event_timestamp if last_event else None,
|
|
139
|
+
"diagnostics": compact_parser_diagnostics(stats),
|
|
140
|
+
"events": [
|
|
141
|
+
{
|
|
142
|
+
"record_id": event.record_id,
|
|
143
|
+
"line_number": event.line_number,
|
|
144
|
+
"event_timestamp": event.event_timestamp,
|
|
145
|
+
"session_id": event.session_id,
|
|
146
|
+
"turn_id": event.turn_id,
|
|
147
|
+
"model": event.model,
|
|
148
|
+
"effort": event.effort,
|
|
149
|
+
"input_tokens": event.input_tokens,
|
|
150
|
+
"cached_input_tokens": event.cached_input_tokens,
|
|
151
|
+
"uncached_input_tokens": event.uncached_input_tokens,
|
|
152
|
+
"output_tokens": event.output_tokens,
|
|
153
|
+
"reasoning_output_tokens": event.reasoning_output_tokens,
|
|
154
|
+
"total_tokens": event.total_tokens,
|
|
155
|
+
"cumulative_total_tokens": event.cumulative_total_tokens,
|
|
156
|
+
}
|
|
157
|
+
for event in events
|
|
158
|
+
],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def empty_parser_diagnostics() -> dict[str, int]:
|
|
163
|
+
"""Return all parser diagnostic counters initialized to zero."""
|
|
164
|
+
|
|
165
|
+
return {key: 0 for key in PARSER_DIAGNOSTIC_KEYS}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def compact_parser_diagnostics(stats: MutableMapping[str, int]) -> dict[str, int]:
|
|
169
|
+
"""Return non-zero parser diagnostics in stable key order."""
|
|
170
|
+
|
|
171
|
+
return {key: int(stats.get(key, 0)) for key in PARSER_DIAGNOSTIC_KEYS if stats.get(key, 0)}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_codex_jsonl_v1(
|
|
175
|
+
path: Path,
|
|
176
|
+
session_index: dict[str, SessionInfo] | None = None,
|
|
177
|
+
stats: MutableMapping[str, int] | None = None,
|
|
178
|
+
) -> list[UsageEvent]:
|
|
179
|
+
"""Parse one Codex JSONL v1 log without storing raw message content."""
|
|
180
|
+
|
|
181
|
+
index = session_index or {}
|
|
182
|
+
file_session_id = _session_id_from_path(path)
|
|
183
|
+
if not file_session_id:
|
|
184
|
+
_increment_stat(stats, "unknown_filename_format")
|
|
185
|
+
session_id = file_session_id
|
|
186
|
+
session_info = index.get(session_id) if session_id else None
|
|
187
|
+
current_turn: dict[str, Any] = {}
|
|
188
|
+
session_meta: dict[str, str | None] = {}
|
|
189
|
+
last_cumulative_total = -1
|
|
190
|
+
events: list[UsageEvent] = []
|
|
191
|
+
|
|
192
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
193
|
+
for line_number, line in enumerate(handle, 1):
|
|
194
|
+
try:
|
|
195
|
+
envelope = json.loads(line)
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
_increment_stat(stats, "invalid_json")
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
payload = envelope.get("payload")
|
|
201
|
+
if not isinstance(payload, dict):
|
|
202
|
+
_increment_stat(stats, "missing_payload")
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
entry_type = envelope.get("type")
|
|
206
|
+
timestamp = _optional_str(envelope.get("timestamp")) or ""
|
|
207
|
+
|
|
208
|
+
if entry_type == "session_meta":
|
|
209
|
+
if not session_id:
|
|
210
|
+
session_id = _optional_str(payload.get("id"))
|
|
211
|
+
session_info = index.get(session_id or "")
|
|
212
|
+
session_meta = _session_metadata(payload, index)
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
if entry_type == "turn_context":
|
|
216
|
+
current_turn = {
|
|
217
|
+
"turn_id": _optional_str(payload.get("turn_id")),
|
|
218
|
+
"turn_timestamp": timestamp,
|
|
219
|
+
"cwd": _optional_str(payload.get("cwd")),
|
|
220
|
+
"model": _optional_str(payload.get("model")),
|
|
221
|
+
"effort": _optional_str(payload.get("effort")),
|
|
222
|
+
"current_date": _optional_str(payload.get("current_date")),
|
|
223
|
+
"timezone": _optional_str(payload.get("timezone")),
|
|
224
|
+
}
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
if entry_type != "event_msg" or payload.get("type") != "token_count":
|
|
228
|
+
if entry_type == "event_msg":
|
|
229
|
+
_increment_stat(stats, "unknown_event_shape")
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
info = payload.get("info")
|
|
233
|
+
if not isinstance(info, dict):
|
|
234
|
+
_increment_stat(stats, "missing_info")
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
total_usage = info.get("total_token_usage")
|
|
238
|
+
last_usage = info.get("last_token_usage")
|
|
239
|
+
if not isinstance(total_usage, dict):
|
|
240
|
+
_increment_stat(stats, "missing_total_token_usage")
|
|
241
|
+
_increment_stat(stats, "skipped_events")
|
|
242
|
+
continue
|
|
243
|
+
if not isinstance(last_usage, dict):
|
|
244
|
+
_increment_stat(stats, "missing_last_token_usage")
|
|
245
|
+
_increment_stat(stats, "skipped_events")
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
cumulative_total = _required_usage_int(
|
|
250
|
+
total_usage,
|
|
251
|
+
"total_tokens",
|
|
252
|
+
stats=stats,
|
|
253
|
+
missing_key="missing_cumulative_total",
|
|
254
|
+
)
|
|
255
|
+
except ValueError:
|
|
256
|
+
_increment_stat(stats, "skipped_events")
|
|
257
|
+
continue
|
|
258
|
+
if cumulative_total <= last_cumulative_total:
|
|
259
|
+
_increment_stat(stats, "duplicate_cumulative_total")
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
effective_session_id = session_id or "unknown"
|
|
263
|
+
session_info = session_info or index.get(effective_session_id)
|
|
264
|
+
try:
|
|
265
|
+
event = _build_event(
|
|
266
|
+
path=path,
|
|
267
|
+
line_number=line_number,
|
|
268
|
+
event_timestamp=timestamp,
|
|
269
|
+
session_id=effective_session_id,
|
|
270
|
+
session_info=session_info,
|
|
271
|
+
session_meta=session_meta,
|
|
272
|
+
current_turn=current_turn,
|
|
273
|
+
model_context_window=_nullable_int(
|
|
274
|
+
info.get("model_context_window"),
|
|
275
|
+
stats=stats,
|
|
276
|
+
invalid_key="invalid_model_context_window",
|
|
277
|
+
),
|
|
278
|
+
last_usage=last_usage,
|
|
279
|
+
total_usage=total_usage,
|
|
280
|
+
stats=stats,
|
|
281
|
+
)
|
|
282
|
+
except ValueError:
|
|
283
|
+
_increment_stat(stats, "skipped_events")
|
|
284
|
+
continue
|
|
285
|
+
last_cumulative_total = cumulative_total
|
|
286
|
+
events.append(event)
|
|
287
|
+
|
|
288
|
+
return events
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _build_event(
|
|
292
|
+
path: Path,
|
|
293
|
+
line_number: int,
|
|
294
|
+
event_timestamp: str,
|
|
295
|
+
session_id: str,
|
|
296
|
+
session_info: SessionInfo | None,
|
|
297
|
+
session_meta: dict[str, str | None],
|
|
298
|
+
current_turn: dict[str, Any],
|
|
299
|
+
model_context_window: int | None,
|
|
300
|
+
last_usage: dict[str, Any],
|
|
301
|
+
total_usage: dict[str, Any],
|
|
302
|
+
stats: MutableMapping[str, int] | None = None,
|
|
303
|
+
) -> UsageEvent:
|
|
304
|
+
input_tokens = _required_usage_int(last_usage, "input_tokens", stats=stats)
|
|
305
|
+
cached_input_tokens = _required_usage_int(last_usage, "cached_input_tokens", stats=stats)
|
|
306
|
+
output_tokens = _required_usage_int(last_usage, "output_tokens", stats=stats)
|
|
307
|
+
reasoning_output_tokens = _required_usage_int(
|
|
308
|
+
last_usage, "reasoning_output_tokens", stats=stats
|
|
309
|
+
)
|
|
310
|
+
total_tokens = _required_usage_int(last_usage, "total_tokens", stats=stats)
|
|
311
|
+
cumulative_total_tokens = _required_usage_int(
|
|
312
|
+
total_usage,
|
|
313
|
+
"total_tokens",
|
|
314
|
+
stats=stats,
|
|
315
|
+
missing_key="missing_cumulative_total",
|
|
316
|
+
)
|
|
317
|
+
record_id = _record_id(
|
|
318
|
+
session_id=session_id,
|
|
319
|
+
turn_id=_optional_str(current_turn.get("turn_id")),
|
|
320
|
+
event_timestamp=event_timestamp,
|
|
321
|
+
cumulative_total_tokens=cumulative_total_tokens,
|
|
322
|
+
total_tokens=total_tokens,
|
|
323
|
+
)
|
|
324
|
+
return UsageEvent(
|
|
325
|
+
record_id=record_id,
|
|
326
|
+
session_id=session_id,
|
|
327
|
+
thread_name=session_info.thread_name if session_info else None,
|
|
328
|
+
session_updated_at=session_info.updated_at if session_info else None,
|
|
329
|
+
event_timestamp=event_timestamp,
|
|
330
|
+
source_file=str(path),
|
|
331
|
+
line_number=line_number,
|
|
332
|
+
turn_id=_optional_str(current_turn.get("turn_id")),
|
|
333
|
+
turn_timestamp=_optional_str(current_turn.get("turn_timestamp")),
|
|
334
|
+
cwd=_optional_str(current_turn.get("cwd")),
|
|
335
|
+
model=_optional_str(current_turn.get("model")),
|
|
336
|
+
effort=_optional_str(current_turn.get("effort")),
|
|
337
|
+
current_date=_optional_str(current_turn.get("current_date")),
|
|
338
|
+
timezone=_optional_str(current_turn.get("timezone")),
|
|
339
|
+
thread_source=session_meta.get("thread_source"),
|
|
340
|
+
subagent_type=session_meta.get("subagent_type"),
|
|
341
|
+
agent_role=session_meta.get("agent_role"),
|
|
342
|
+
agent_nickname=session_meta.get("agent_nickname"),
|
|
343
|
+
parent_session_id=session_meta.get("parent_session_id"),
|
|
344
|
+
parent_thread_name=session_meta.get("parent_thread_name"),
|
|
345
|
+
parent_session_updated_at=session_meta.get("parent_session_updated_at"),
|
|
346
|
+
model_context_window=model_context_window,
|
|
347
|
+
input_tokens=input_tokens,
|
|
348
|
+
cached_input_tokens=cached_input_tokens,
|
|
349
|
+
output_tokens=output_tokens,
|
|
350
|
+
reasoning_output_tokens=reasoning_output_tokens,
|
|
351
|
+
total_tokens=total_tokens,
|
|
352
|
+
cumulative_input_tokens=_required_usage_int(total_usage, "input_tokens", stats=stats),
|
|
353
|
+
cumulative_cached_input_tokens=_required_usage_int(
|
|
354
|
+
total_usage, "cached_input_tokens", stats=stats
|
|
355
|
+
),
|
|
356
|
+
cumulative_output_tokens=_required_usage_int(total_usage, "output_tokens", stats=stats),
|
|
357
|
+
cumulative_reasoning_output_tokens=_required_usage_int(
|
|
358
|
+
total_usage, "reasoning_output_tokens", stats=stats
|
|
359
|
+
),
|
|
360
|
+
cumulative_total_tokens=cumulative_total_tokens,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _session_metadata(
|
|
365
|
+
payload: dict[str, Any],
|
|
366
|
+
session_index: dict[str, SessionInfo],
|
|
367
|
+
) -> dict[str, str | None]:
|
|
368
|
+
source = payload.get("source")
|
|
369
|
+
metadata: dict[str, str | None] = {
|
|
370
|
+
"thread_source": _optional_str(payload.get("thread_source")),
|
|
371
|
+
"subagent_type": None,
|
|
372
|
+
"agent_role": None,
|
|
373
|
+
"agent_nickname": None,
|
|
374
|
+
"parent_session_id": None,
|
|
375
|
+
"parent_thread_name": None,
|
|
376
|
+
"parent_session_updated_at": None,
|
|
377
|
+
}
|
|
378
|
+
if not isinstance(source, dict):
|
|
379
|
+
return metadata
|
|
380
|
+
|
|
381
|
+
subagent = source.get("subagent")
|
|
382
|
+
if not isinstance(subagent, dict):
|
|
383
|
+
return metadata
|
|
384
|
+
|
|
385
|
+
other = _optional_str(subagent.get("other"))
|
|
386
|
+
if other:
|
|
387
|
+
metadata["subagent_type"] = other
|
|
388
|
+
return metadata
|
|
389
|
+
|
|
390
|
+
thread_spawn = subagent.get("thread_spawn")
|
|
391
|
+
if isinstance(thread_spawn, dict):
|
|
392
|
+
metadata["subagent_type"] = "thread_spawn"
|
|
393
|
+
metadata["agent_role"] = _optional_str(thread_spawn.get("agent_role"))
|
|
394
|
+
metadata["agent_nickname"] = _optional_str(thread_spawn.get("agent_nickname"))
|
|
395
|
+
parent_session_id = _optional_str(thread_spawn.get("parent_thread_id"))
|
|
396
|
+
metadata["parent_session_id"] = parent_session_id
|
|
397
|
+
if parent_session_id:
|
|
398
|
+
parent_info = session_index.get(parent_session_id)
|
|
399
|
+
if parent_info:
|
|
400
|
+
metadata["parent_thread_name"] = parent_info.thread_name
|
|
401
|
+
metadata["parent_session_updated_at"] = parent_info.updated_at
|
|
402
|
+
return metadata
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _record_id(
|
|
406
|
+
session_id: str,
|
|
407
|
+
turn_id: str | None,
|
|
408
|
+
event_timestamp: str,
|
|
409
|
+
cumulative_total_tokens: int,
|
|
410
|
+
total_tokens: int,
|
|
411
|
+
) -> str:
|
|
412
|
+
raw = "|".join(
|
|
413
|
+
[
|
|
414
|
+
session_id,
|
|
415
|
+
turn_id or "",
|
|
416
|
+
event_timestamp,
|
|
417
|
+
str(cumulative_total_tokens),
|
|
418
|
+
str(total_tokens),
|
|
419
|
+
]
|
|
420
|
+
)
|
|
421
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _session_id_from_path(path: Path) -> str | None:
|
|
425
|
+
match = SESSION_ID_RE.search(path.name)
|
|
426
|
+
if not match:
|
|
427
|
+
return None
|
|
428
|
+
return match.group(1)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _optional_str(value: object) -> str | None:
|
|
432
|
+
if isinstance(value, str):
|
|
433
|
+
return value
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _nullable_int(
|
|
438
|
+
value: object,
|
|
439
|
+
*,
|
|
440
|
+
stats: MutableMapping[str, int] | None = None,
|
|
441
|
+
invalid_key: str = "partial_field_count",
|
|
442
|
+
) -> int | None:
|
|
443
|
+
if value is None:
|
|
444
|
+
return None
|
|
445
|
+
try:
|
|
446
|
+
return _strict_int(value)
|
|
447
|
+
except ValueError:
|
|
448
|
+
_increment_stat(stats, invalid_key)
|
|
449
|
+
if invalid_key != "partial_field_count":
|
|
450
|
+
_increment_stat(stats, "partial_field_count")
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _strict_int(value: object) -> int:
|
|
455
|
+
if isinstance(value, bool):
|
|
456
|
+
raise ValueError(f"invalid integer value: {value!r}")
|
|
457
|
+
if isinstance(value, int):
|
|
458
|
+
return value
|
|
459
|
+
if isinstance(value, float):
|
|
460
|
+
raise ValueError(f"invalid integer value: {value!r}")
|
|
461
|
+
if isinstance(value, str) and value.strip():
|
|
462
|
+
try:
|
|
463
|
+
return int(value)
|
|
464
|
+
except ValueError as exc:
|
|
465
|
+
raise ValueError(f"invalid integer value: {value!r}") from exc
|
|
466
|
+
raise ValueError(f"invalid integer value: {value!r}")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _required_usage_int(
|
|
470
|
+
values: dict[str, Any],
|
|
471
|
+
key: str,
|
|
472
|
+
*,
|
|
473
|
+
stats: MutableMapping[str, int] | None = None,
|
|
474
|
+
missing_key: str = "partial_field_count",
|
|
475
|
+
) -> int:
|
|
476
|
+
if key not in values or values.get(key) is None:
|
|
477
|
+
_increment_stat(stats, missing_key)
|
|
478
|
+
if missing_key != "partial_field_count":
|
|
479
|
+
_increment_stat(stats, "partial_field_count")
|
|
480
|
+
raise ValueError(f"missing required integer field: {key}")
|
|
481
|
+
try:
|
|
482
|
+
return _strict_int(values.get(key))
|
|
483
|
+
except ValueError:
|
|
484
|
+
_increment_stat(stats, "invalid_integer")
|
|
485
|
+
_increment_stat(stats, "partial_field_count")
|
|
486
|
+
raise
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _increment_stat(stats: MutableMapping[str, int] | None, key: str) -> None:
|
|
490
|
+
if stats is not None:
|
|
491
|
+
stats[key] = stats.get(key, 0) + 1
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared filesystem defaults for local Codex usage tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
APP_DIR = Path.home() / ".codex-usage-tracker"
|
|
8
|
+
DEFAULT_DB_PATH = APP_DIR / "usage.sqlite3"
|
|
9
|
+
DEFAULT_DASHBOARD_PATH = APP_DIR / "dashboard.html"
|
|
10
|
+
DEFAULT_SUPPORT_BUNDLE_PATH = APP_DIR / "support-bundle.json"
|
|
11
|
+
DEFAULT_PRICING_PATH = APP_DIR / "pricing.json"
|
|
12
|
+
DEFAULT_ALLOWANCE_PATH = APP_DIR / "allowance.json"
|
|
13
|
+
DEFAULT_RATE_CARD_PATH = APP_DIR / "rate-card.json"
|
|
14
|
+
DEFAULT_THRESHOLDS_PATH = APP_DIR / "thresholds.json"
|
|
15
|
+
DEFAULT_PROJECTS_PATH = APP_DIR / "projects.json"
|
|
16
|
+
DEFAULT_CODEX_HOME = Path.home() / ".codex"
|
|
17
|
+
DEFAULT_PLUGIN_LINK = Path.home() / "plugins" / "codex-usage-tracker"
|
|
18
|
+
DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package data used to generate the local Codex plugin wrapper."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Codex Usage Tracker">
|
|
2
|
+
<rect width="64" height="64" rx="12" fill="#2563EB"/>
|
|
3
|
+
<path d="M14 44h36" stroke="#DBEAFE" stroke-width="4" stroke-linecap="round"/>
|
|
4
|
+
<path d="M18 38V24M30 38V16M42 38V28" stroke="#FFFFFF" stroke-width="6" stroke-linecap="round"/>
|
|
5
|
+
<circle cx="18" cy="24" r="4" fill="#BFDBFE"/>
|
|
6
|
+
<circle cx="30" cy="16" r="4" fill="#BFDBFE"/>
|
|
7
|
+
<circle cx="42" cy="28" r="4" fill="#BFDBFE"/>
|
|
8
|
+
</svg>
|