artificial-intelligence-usage 0.0.1__tar.gz

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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: artificial-intelligence-usage
3
+ Version: 0.0.1
4
+ Summary: Analyze recent Codex sessions and report usage information.
5
+ Author: Kaizten Analytics
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Artificial Intelligence Usage
13
+
14
+ `artificial-intelligence-usage` analyzes local Codex session rollouts and prints a summary of the
15
+ latest session followed by daily session, turn, token, and rate-limit usage.
16
+
17
+ ## Behavior
18
+
19
+ - Reads rollout files below `~/.codex/sessions`.
20
+ - Shows status details from the most recently modified session.
21
+ - Reports sessions from the last 14 calendar days by default, including today.
22
+ - Counts user turns and cumulative tokens for each session.
23
+ - Shows the remaining five-hour and weekly allowance recorded in each day's
24
+ last session.
25
+ - Ignores malformed JSON lines and incomplete status events.
26
+
27
+ The tool only reads local session files and does not modify them.
28
+
29
+ ## Requirements
30
+
31
+ - Python 3.10 or newer
32
+
33
+ ## Building
34
+
35
+ ### Testing
36
+
37
+ Run the unit tests from the project root:
38
+
39
+ ```bash
40
+ python3 -m unittest discover -s tests -v
41
+ ```
42
+
43
+ ### Package artifact
44
+
45
+ Build and validate the package artifacts:
46
+
47
+ ```bash
48
+ ./build-package.sh
49
+ ```
50
+
51
+ This creates the wheel and source distribution under `dist/` and checks the
52
+ package metadata with Twine.
53
+
54
+ ### Publish artifact
55
+
56
+ Publish the generated artifacts to PyPI:
57
+
58
+ ```bash
59
+ ./publish-pypi.sh
60
+ ```
61
+
62
+ The publish script checks that Twine is available, creates a local `.venv` for
63
+ Twine when needed, verifies that `dist/` contains generated artifacts, and then
64
+ uploads them. If PyPI rejects a version that was already published, increment
65
+ the version in `pyproject.toml`, rebuild, and publish again.
66
+
67
+ ### Install artifact
68
+
69
+ Install the package in a virtual environment:
70
+
71
+ ```bash
72
+ python3 -m venv .venv
73
+ source .venv/bin/activate
74
+ python3 -m pip install artificial-intelligence-usage
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ After installation, run:
80
+
81
+ ```bash
82
+ artificial-intelligence-usage
83
+ ```
84
+
85
+ Choose a different reporting window with `--days`:
86
+
87
+ ```bash
88
+ artificial-intelligence-usage --days 30
89
+ ```
90
+
91
+ From a source checkout, `./artificial-intelligence-usage.py` is the canonical launcher and
92
+ `./script-sessions.py` remains available for compatibility.
93
+
94
+ ### Help
95
+
96
+ ```text
97
+ usage: artificial-intelligence-usage [-h] [--days DAYS]
98
+
99
+ Analyze recent Codex sessions.
100
+
101
+ options:
102
+ -h, --help show this help message and exit
103
+ --days DAYS number of calendar days to analyze, including today (default: 14)
104
+ ```
@@ -0,0 +1,93 @@
1
+ # Artificial Intelligence Usage
2
+
3
+ `artificial-intelligence-usage` analyzes local Codex session rollouts and prints a summary of the
4
+ latest session followed by daily session, turn, token, and rate-limit usage.
5
+
6
+ ## Behavior
7
+
8
+ - Reads rollout files below `~/.codex/sessions`.
9
+ - Shows status details from the most recently modified session.
10
+ - Reports sessions from the last 14 calendar days by default, including today.
11
+ - Counts user turns and cumulative tokens for each session.
12
+ - Shows the remaining five-hour and weekly allowance recorded in each day's
13
+ last session.
14
+ - Ignores malformed JSON lines and incomplete status events.
15
+
16
+ The tool only reads local session files and does not modify them.
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.10 or newer
21
+
22
+ ## Building
23
+
24
+ ### Testing
25
+
26
+ Run the unit tests from the project root:
27
+
28
+ ```bash
29
+ python3 -m unittest discover -s tests -v
30
+ ```
31
+
32
+ ### Package artifact
33
+
34
+ Build and validate the package artifacts:
35
+
36
+ ```bash
37
+ ./build-package.sh
38
+ ```
39
+
40
+ This creates the wheel and source distribution under `dist/` and checks the
41
+ package metadata with Twine.
42
+
43
+ ### Publish artifact
44
+
45
+ Publish the generated artifacts to PyPI:
46
+
47
+ ```bash
48
+ ./publish-pypi.sh
49
+ ```
50
+
51
+ The publish script checks that Twine is available, creates a local `.venv` for
52
+ Twine when needed, verifies that `dist/` contains generated artifacts, and then
53
+ uploads them. If PyPI rejects a version that was already published, increment
54
+ the version in `pyproject.toml`, rebuild, and publish again.
55
+
56
+ ### Install artifact
57
+
58
+ Install the package in a virtual environment:
59
+
60
+ ```bash
61
+ python3 -m venv .venv
62
+ source .venv/bin/activate
63
+ python3 -m pip install artificial-intelligence-usage
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ After installation, run:
69
+
70
+ ```bash
71
+ artificial-intelligence-usage
72
+ ```
73
+
74
+ Choose a different reporting window with `--days`:
75
+
76
+ ```bash
77
+ artificial-intelligence-usage --days 30
78
+ ```
79
+
80
+ From a source checkout, `./artificial-intelligence-usage.py` is the canonical launcher and
81
+ `./script-sessions.py` remains available for compatibility.
82
+
83
+ ### Help
84
+
85
+ ```text
86
+ usage: artificial-intelligence-usage [-h] [--days DAYS]
87
+
88
+ Analyze recent Codex sessions.
89
+
90
+ options:
91
+ -h, --help show this help message and exit
92
+ --days DAYS number of calendar days to analyze, including today (default: 14)
93
+ ```
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "artificial-intelligence-usage"
7
+ version = "0.0.1"
8
+ description = "Analyze recent Codex sessions and report usage information."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [{ name = "Kaizten Analytics" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ ]
17
+
18
+ [project.scripts]
19
+ artificial-intelligence-usage = "artificial_intelligence_usage:main"
20
+
21
+ [tool.setuptools]
22
+ package-dir = { "" = "src" }
23
+ py-modules = ["artificial_intelligence_usage"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: artificial-intelligence-usage
3
+ Version: 0.0.1
4
+ Summary: Analyze recent Codex sessions and report usage information.
5
+ Author: Kaizten Analytics
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Artificial Intelligence Usage
13
+
14
+ `artificial-intelligence-usage` analyzes local Codex session rollouts and prints a summary of the
15
+ latest session followed by daily session, turn, token, and rate-limit usage.
16
+
17
+ ## Behavior
18
+
19
+ - Reads rollout files below `~/.codex/sessions`.
20
+ - Shows status details from the most recently modified session.
21
+ - Reports sessions from the last 14 calendar days by default, including today.
22
+ - Counts user turns and cumulative tokens for each session.
23
+ - Shows the remaining five-hour and weekly allowance recorded in each day's
24
+ last session.
25
+ - Ignores malformed JSON lines and incomplete status events.
26
+
27
+ The tool only reads local session files and does not modify them.
28
+
29
+ ## Requirements
30
+
31
+ - Python 3.10 or newer
32
+
33
+ ## Building
34
+
35
+ ### Testing
36
+
37
+ Run the unit tests from the project root:
38
+
39
+ ```bash
40
+ python3 -m unittest discover -s tests -v
41
+ ```
42
+
43
+ ### Package artifact
44
+
45
+ Build and validate the package artifacts:
46
+
47
+ ```bash
48
+ ./build-package.sh
49
+ ```
50
+
51
+ This creates the wheel and source distribution under `dist/` and checks the
52
+ package metadata with Twine.
53
+
54
+ ### Publish artifact
55
+
56
+ Publish the generated artifacts to PyPI:
57
+
58
+ ```bash
59
+ ./publish-pypi.sh
60
+ ```
61
+
62
+ The publish script checks that Twine is available, creates a local `.venv` for
63
+ Twine when needed, verifies that `dist/` contains generated artifacts, and then
64
+ uploads them. If PyPI rejects a version that was already published, increment
65
+ the version in `pyproject.toml`, rebuild, and publish again.
66
+
67
+ ### Install artifact
68
+
69
+ Install the package in a virtual environment:
70
+
71
+ ```bash
72
+ python3 -m venv .venv
73
+ source .venv/bin/activate
74
+ python3 -m pip install artificial-intelligence-usage
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ After installation, run:
80
+
81
+ ```bash
82
+ artificial-intelligence-usage
83
+ ```
84
+
85
+ Choose a different reporting window with `--days`:
86
+
87
+ ```bash
88
+ artificial-intelligence-usage --days 30
89
+ ```
90
+
91
+ From a source checkout, `./artificial-intelligence-usage.py` is the canonical launcher and
92
+ `./script-sessions.py` remains available for compatibility.
93
+
94
+ ### Help
95
+
96
+ ```text
97
+ usage: artificial-intelligence-usage [-h] [--days DAYS]
98
+
99
+ Analyze recent Codex sessions.
100
+
101
+ options:
102
+ -h, --help show this help message and exit
103
+ --days DAYS number of calendar days to analyze, including today (default: 14)
104
+ ```
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/artificial_intelligence_usage.py
4
+ src/artificial_intelligence_usage.egg-info/PKG-INFO
5
+ src/artificial_intelligence_usage.egg-info/SOURCES.txt
6
+ src/artificial_intelligence_usage.egg-info/dependency_links.txt
7
+ src/artificial_intelligence_usage.egg-info/entry_points.txt
8
+ src/artificial_intelligence_usage.egg-info/top_level.txt
9
+ tests/test_artificial_intelligence_usage.py
10
+ tests/test_publish_pypi.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ artificial-intelligence-usage = artificial_intelligence_usage:main
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ """Analyze recent Codex sessions and report usage information."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from collections import Counter, defaultdict
9
+ from datetime import date, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ DEFAULT_DAYS = 14
15
+
16
+
17
+ def positive_int(value: str) -> int:
18
+ """Return value as a positive integer for command-line arguments."""
19
+ try:
20
+ number = int(value)
21
+ except ValueError as error:
22
+ raise argparse.ArgumentTypeError("must be an integer") from error
23
+
24
+ if number < 1:
25
+ raise argparse.ArgumentTypeError("must be at least 1")
26
+
27
+ return number
28
+
29
+
30
+ def format_timestamp(value: str | int | float | None) -> str:
31
+ """Format an ISO or Unix timestamp in the local timezone."""
32
+ try:
33
+ if isinstance(value, str):
34
+ timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
35
+ elif isinstance(value, (int, float)):
36
+ timestamp = datetime.fromtimestamp(value).astimezone()
37
+ else:
38
+ return "unknown"
39
+
40
+ return timestamp.astimezone().strftime("%Y-%m-%d %H:%M %Z")
41
+ except (OverflowError, OSError, ValueError):
42
+ return "unknown"
43
+
44
+
45
+ def format_window(minutes: Any) -> str:
46
+ """Return a compact label for a rate-limit window."""
47
+ if not isinstance(minutes, (int, float)):
48
+ return "Rate limit"
49
+ if minutes % (7 * 24 * 60) == 0:
50
+ return f"{minutes / (7 * 24 * 60):g}w limit"
51
+ if minutes % (24 * 60) == 0:
52
+ return f"{minutes / (24 * 60):g}d limit"
53
+ if minutes % 60 == 0:
54
+ return f"{minutes / 60:g}h limit"
55
+ return f"{minutes:g}m limit"
56
+
57
+
58
+ def read_status(session_file: Path | None) -> dict[str, Any]:
59
+ """Read the latest status-related values from a Codex rollout."""
60
+ status: dict[str, Any] = {
61
+ "session": {},
62
+ "context": {},
63
+ "token_count": {},
64
+ "updated_at": None,
65
+ }
66
+
67
+ if session_file is None:
68
+ return status
69
+
70
+ try:
71
+ with session_file.open("r", encoding="utf-8") as file_handle:
72
+ for line in file_handle:
73
+ try:
74
+ event = json.loads(line)
75
+ except (json.JSONDecodeError, UnicodeDecodeError):
76
+ continue
77
+
78
+ status["updated_at"] = event.get("timestamp") or status["updated_at"]
79
+ payload = event.get("payload") or {}
80
+
81
+ if event.get("type") == "session_meta":
82
+ status["session"] = payload
83
+ elif event.get("type") == "turn_context":
84
+ status["context"] = payload
85
+ elif (
86
+ event.get("type") == "event_msg"
87
+ and payload.get("type") == "token_count"
88
+ ):
89
+ status["token_count"] = payload
90
+ except (OSError, UnicodeError):
91
+ pass
92
+
93
+ return status
94
+
95
+
96
+ def print_rate_limit(limit: Any, fallback_label: str) -> None:
97
+ """Print one rate-limit window from a token-count event."""
98
+ if not isinstance(limit, dict):
99
+ print(f"{fallback_label + ':':<16} unknown")
100
+ return
101
+
102
+ label = format_window(limit.get("window_minutes"))
103
+ used_percent = limit.get("used_percent")
104
+ remaining = (
105
+ max(0.0, 100.0 - float(used_percent))
106
+ if isinstance(used_percent, (int, float))
107
+ else None
108
+ )
109
+ availability = f"{remaining:g}% remaining" if remaining is not None else "unknown"
110
+ reset = format_timestamp(limit.get("resets_at"))
111
+ print(f"{label + ':':<16} {availability} (resets {reset})")
112
+
113
+
114
+ def format_remaining(limit: Any) -> str:
115
+ """Return the remaining allowance for one rate-limit window."""
116
+ if not isinstance(limit, dict):
117
+ return "unknown"
118
+
119
+ used_percent = limit.get("used_percent")
120
+ if not isinstance(used_percent, (int, float)):
121
+ return "unknown"
122
+
123
+ remaining = min(100.0, max(0.0, 100.0 - float(used_percent)))
124
+ return f"{remaining:g}% remaining"
125
+
126
+
127
+ def session_rate_limits(session_file: Path) -> tuple[str, str]:
128
+ """Return the 5-hour and weekly limits recorded by one session."""
129
+ token_count = read_status(session_file)["token_count"]
130
+ rate_limits = token_count.get("rate_limits") or {}
131
+ limits = [rate_limits.get("primary"), rate_limits.get("secondary")]
132
+
133
+ five_hour = next(
134
+ (
135
+ limit
136
+ for limit in limits
137
+ if isinstance(limit, dict) and limit.get("window_minutes") == 5 * 60
138
+ ),
139
+ rate_limits.get("primary"),
140
+ )
141
+ weekly = next(
142
+ (
143
+ limit
144
+ for limit in limits
145
+ if isinstance(limit, dict)
146
+ and limit.get("window_minutes") == 7 * 24 * 60
147
+ ),
148
+ rate_limits.get("secondary"),
149
+ )
150
+
151
+ return format_remaining(five_hour), format_remaining(weekly)
152
+
153
+
154
+ def print_status(session_file: Path | None) -> None:
155
+ """Print a /status-like summary for the latest Codex session."""
156
+ status = read_status(session_file)
157
+ session = status["session"]
158
+ context = status["context"]
159
+ token_count = status["token_count"]
160
+ info = token_count.get("info") or {}
161
+ total_usage = info.get("total_token_usage") or {}
162
+ last_usage = info.get("last_token_usage") or {}
163
+ rate_limits = token_count.get("rate_limits") or {}
164
+
165
+ model = context.get("model") or "unknown"
166
+ effort = context.get("effort")
167
+ if effort:
168
+ model = f"{model} ({effort})"
169
+
170
+ sandbox = (context.get("sandbox_policy") or {}).get("type") or "unknown"
171
+ approval_policy = context.get("approval_policy") or {}
172
+ approval = next(iter(approval_policy), "unknown")
173
+
174
+ current_tokens = last_usage.get("total_tokens")
175
+ context_window = info.get("model_context_window")
176
+ if isinstance(current_tokens, (int, float)) and isinstance(
177
+ context_window, (int, float)
178
+ ):
179
+ context_percent = min(100.0, current_tokens / context_window * 100)
180
+ context_value = (
181
+ f"{int(current_tokens):,} / {int(context_window):,} tokens "
182
+ f"({context_percent:.1f}% used)"
183
+ )
184
+ else:
185
+ context_value = "unknown"
186
+
187
+ total_tokens = total_usage.get("total_tokens")
188
+ total_value = (
189
+ f"{int(total_tokens):,}"
190
+ if isinstance(total_tokens, (int, float))
191
+ else "unknown"
192
+ )
193
+
194
+ print("Codex status (latest session)")
195
+ print("-" * 57)
196
+ print(f"{'Updated:':<16} {format_timestamp(status['updated_at'])}")
197
+ print(
198
+ f"{'Session:':<16} "
199
+ f"{session.get('session_id') or session.get('id') or 'unknown'}"
200
+ )
201
+ print(f"{'Codex CLI:':<16} {session.get('cli_version') or 'unknown'}")
202
+ print(f"{'Model:':<16} {model}")
203
+ print(
204
+ f"{'Workspace:':<16} "
205
+ f"{context.get('cwd') or session.get('cwd') or 'unknown'}"
206
+ )
207
+ print(f"{'Permissions:':<16} {sandbox} (approval: {approval})")
208
+ print(f"{'Context:':<16} {context_value}")
209
+ print(f"{'Session tokens:':<16} {total_value}")
210
+ print(f"{'Plan:':<16} {rate_limits.get('plan_type') or 'unknown'}")
211
+ print_rate_limit(rate_limits.get("primary"), "Primary limit")
212
+ print_rate_limit(rate_limits.get("secondary"), "Secondary limit")
213
+ print()
214
+
215
+
216
+ def analyze_session(session_file: Path) -> tuple[int, int]:
217
+ """Return ``(user_turns, total_tokens)`` for one Codex session."""
218
+ turns = 0
219
+ total_tokens = 0
220
+
221
+ with session_file.open("r", encoding="utf-8") as file_handle:
222
+ for line in file_handle:
223
+ try:
224
+ event = json.loads(line)
225
+ except (json.JSONDecodeError, UnicodeDecodeError):
226
+ continue
227
+
228
+ payload = event.get("payload") or {}
229
+
230
+ if (
231
+ event.get("type") == "response_item"
232
+ and payload.get("type") == "message"
233
+ and payload.get("role") == "user"
234
+ ):
235
+ turns += 1
236
+
237
+ if (
238
+ event.get("type") == "event_msg"
239
+ and payload.get("type") == "token_count"
240
+ ):
241
+ info = payload.get("info") or {}
242
+ usage = info.get("total_token_usage") or {}
243
+ value = usage.get("total_tokens")
244
+
245
+ if isinstance(value, (int, float)):
246
+ total_tokens = max(total_tokens, int(value))
247
+
248
+ return turns, total_tokens
249
+
250
+
251
+ def build_parser() -> argparse.ArgumentParser:
252
+ """Build the command-line argument parser."""
253
+ parser = argparse.ArgumentParser(description="Analyze recent Codex sessions.")
254
+ parser.add_argument(
255
+ "--days",
256
+ type=positive_int,
257
+ default=DEFAULT_DAYS,
258
+ help=f"number of calendar days to analyze, including today (default: {DEFAULT_DAYS})",
259
+ )
260
+ return parser
261
+
262
+
263
+ def run_report(sessions_dir: Path, days: int, today: date | None = None) -> int:
264
+ """Analyze sessions and print the status and daily usage report."""
265
+ current_day = today or date.today()
266
+ first_day = current_day - timedelta(days=days - 1)
267
+ session_counts: Counter[str] = Counter()
268
+ turn_counts: defaultdict[str, list[int]] = defaultdict(list)
269
+ token_counts: Counter[str] = Counter()
270
+ last_session_by_date: dict[str, Path] = {}
271
+
272
+ all_session_files = list(sessions_dir.glob("*/*/*/rollout-*.jsonl"))
273
+ latest_session = (
274
+ max(all_session_files, key=lambda path: path.stat().st_mtime)
275
+ if all_session_files
276
+ else None
277
+ )
278
+ print_status(latest_session)
279
+
280
+ for session_file in all_session_files:
281
+ relative = session_file.relative_to(sessions_dir)
282
+ year, month, day = relative.parts[:3]
283
+ date_label = f"{year}-{month}-{day}"
284
+
285
+ try:
286
+ session_date = date.fromisoformat(date_label)
287
+ except ValueError:
288
+ continue
289
+
290
+ if not first_day <= session_date <= current_day:
291
+ continue
292
+
293
+ turns, tokens = analyze_session(session_file)
294
+ session_counts[date_label] += 1
295
+ turn_counts[date_label].append(turns)
296
+ token_counts[date_label] += tokens
297
+
298
+ previous_last = last_session_by_date.get(date_label)
299
+ if previous_last is None or session_file.name > previous_last.name:
300
+ last_session_by_date[date_label] = session_file
301
+
302
+ if not session_counts:
303
+ print(f"No Codex sessions found in the last {days} days in {sessions_dir}")
304
+ return 0
305
+
306
+ print(
307
+ f"{'Date':<12} "
308
+ f"{'Sessions':>8} "
309
+ f"{'Turns':>8} "
310
+ f"{'Avg turns':>10} "
311
+ f"{'Tokens':>15} "
312
+ f"{'5h last session':>17} "
313
+ f"{'Weekly last session':>20}"
314
+ )
315
+ print("-" * 96)
316
+
317
+ for date_label in sorted(session_counts):
318
+ sessions = session_counts[date_label]
319
+ total_turns = sum(turn_counts[date_label])
320
+ avg_turns = total_turns / sessions
321
+ tokens = token_counts[date_label]
322
+ five_hour, weekly = session_rate_limits(last_session_by_date[date_label])
323
+
324
+ print(
325
+ f"{date_label:<12} "
326
+ f"{sessions:>8} "
327
+ f"{total_turns:>8} "
328
+ f"{avg_turns:>10.1f} "
329
+ f"{tokens:>15,} "
330
+ f"{five_hour:>17} "
331
+ f"{weekly:>20}"
332
+ )
333
+
334
+ total_sessions = sum(session_counts.values())
335
+ total_turns = sum(sum(turns) for turns in turn_counts.values())
336
+ total_tokens = sum(token_counts.values())
337
+ overall_average = total_turns / total_sessions
338
+
339
+ print("-" * 96)
340
+ print(
341
+ f"{'Total':<12} "
342
+ f"{total_sessions:>8} "
343
+ f"{total_turns:>8} "
344
+ f"{overall_average:>10.1f} "
345
+ f"{total_tokens:>15,} "
346
+ f"{'-':>17} "
347
+ f"{'-':>20}"
348
+ )
349
+ return 0
350
+
351
+
352
+ def main(argv: list[str] | None = None) -> int:
353
+ """Run the artificial-intelligence-usage command."""
354
+ args = build_parser().parse_args(argv)
355
+ return run_report(Path.home() / ".codex" / "sessions", args.days)
356
+
357
+
358
+ if __name__ == "__main__":
359
+ raise SystemExit(main())
@@ -0,0 +1,197 @@
1
+ import contextlib
2
+ import io
3
+ import json
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+ from datetime import date
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+
12
+ SRC = Path(__file__).resolve().parents[1] / "src"
13
+ sys.path.insert(0, str(SRC))
14
+
15
+ import artificial_intelligence_usage
16
+
17
+
18
+ class ArtificialIntelligenceUsageTests(unittest.TestCase):
19
+ def write_session(
20
+ self,
21
+ sessions_dir: Path,
22
+ day: date,
23
+ name: str,
24
+ events: list[dict],
25
+ ) -> Path:
26
+ target = sessions_dir / f"{day:%Y}" / f"{day:%m}" / f"{day:%d}"
27
+ target.mkdir(parents=True, exist_ok=True)
28
+ session_file = target / f"rollout-{name}.jsonl"
29
+ session_file.write_text(
30
+ "\n".join(json.dumps(event) for event in events) + "\n",
31
+ encoding="utf-8",
32
+ )
33
+ return session_file
34
+
35
+ def token_event(
36
+ self,
37
+ tokens: int,
38
+ five_hour_used: int = 25,
39
+ weekly_used: int = 40,
40
+ ) -> dict:
41
+ return {
42
+ "timestamp": "2026-10-04T10:00:00Z",
43
+ "type": "event_msg",
44
+ "payload": {
45
+ "type": "token_count",
46
+ "info": {
47
+ "total_token_usage": {"total_tokens": tokens},
48
+ "last_token_usage": {"total_tokens": 100},
49
+ "model_context_window": 1000,
50
+ },
51
+ "rate_limits": {
52
+ "plan_type": "team",
53
+ "primary": {
54
+ "window_minutes": 300,
55
+ "used_percent": five_hour_used,
56
+ "resets_at": 0,
57
+ },
58
+ "secondary": {
59
+ "window_minutes": 10080,
60
+ "used_percent": weekly_used,
61
+ "resets_at": 0,
62
+ },
63
+ },
64
+ },
65
+ }
66
+
67
+ def test_positive_int_accepts_positive_values(self):
68
+ self.assertEqual(artificial_intelligence_usage.positive_int("7"), 7)
69
+
70
+ def test_positive_int_rejects_zero_and_non_integer_values(self):
71
+ with self.assertRaisesRegex(Exception, "at least 1"):
72
+ artificial_intelligence_usage.positive_int("0")
73
+ with self.assertRaisesRegex(Exception, "must be an integer"):
74
+ artificial_intelligence_usage.positive_int("nope")
75
+
76
+ def test_analyze_session_ignores_malformed_lines_and_uses_maximum_tokens(self):
77
+ with tempfile.TemporaryDirectory() as directory:
78
+ session_file = Path(directory) / "rollout-test.jsonl"
79
+ session_file.write_text(
80
+ "not-json\n"
81
+ + json.dumps(
82
+ {
83
+ "type": "response_item",
84
+ "payload": {"type": "message", "role": "user"},
85
+ }
86
+ )
87
+ + "\n"
88
+ + json.dumps(self.token_event(120))
89
+ + "\n"
90
+ + json.dumps(self.token_event(95))
91
+ + "\n",
92
+ encoding="utf-8",
93
+ )
94
+
95
+ self.assertEqual(
96
+ artificial_intelligence_usage.analyze_session(session_file),
97
+ (1, 120),
98
+ )
99
+
100
+ def test_session_rate_limits_are_selected_by_window(self):
101
+ with tempfile.TemporaryDirectory() as directory:
102
+ session_file = Path(directory) / "rollout-test.jsonl"
103
+ event = self.token_event(100)
104
+ limits = event["payload"]["rate_limits"]
105
+ limits["primary"], limits["secondary"] = (
106
+ limits["secondary"],
107
+ limits["primary"],
108
+ )
109
+ session_file.write_text(json.dumps(event) + "\n", encoding="utf-8")
110
+
111
+ self.assertEqual(
112
+ artificial_intelligence_usage.session_rate_limits(session_file),
113
+ ("75% remaining", "60% remaining"),
114
+ )
115
+
116
+ def test_empty_directory_prints_status_and_no_sessions_message(self):
117
+ with tempfile.TemporaryDirectory() as directory:
118
+ output = io.StringIO()
119
+ with contextlib.redirect_stdout(output):
120
+ result = artificial_intelligence_usage.run_report(
121
+ Path(directory), days=14, today=date(2026, 10, 4)
122
+ )
123
+
124
+ self.assertEqual(result, 0)
125
+ self.assertIn("Codex status (latest session)", output.getvalue())
126
+ self.assertIn("No Codex sessions found in the last 14 days", output.getvalue())
127
+
128
+ def test_report_aggregates_sessions_turns_tokens_and_limits(self):
129
+ with tempfile.TemporaryDirectory() as directory:
130
+ sessions_dir = Path(directory)
131
+ session_meta = {
132
+ "timestamp": "2026-10-04T09:00:00Z",
133
+ "type": "session_meta",
134
+ "payload": {"id": "session-1", "cli_version": "1.2.3"},
135
+ }
136
+ turn_context = {
137
+ "timestamp": "2026-10-04T09:01:00Z",
138
+ "type": "turn_context",
139
+ "payload": {
140
+ "model": "gpt-test",
141
+ "effort": "high",
142
+ "cwd": "/work",
143
+ "sandbox_policy": {"type": "workspace-write"},
144
+ "approval_policy": {"on-request": {}},
145
+ },
146
+ }
147
+ user_turn = {
148
+ "type": "response_item",
149
+ "payload": {"type": "message", "role": "user"},
150
+ }
151
+ self.write_session(
152
+ sessions_dir,
153
+ date(2026, 10, 4),
154
+ "01",
155
+ [session_meta, turn_context, user_turn, self.token_event(100)],
156
+ )
157
+ self.write_session(
158
+ sessions_dir,
159
+ date(2026, 10, 4),
160
+ "02",
161
+ [user_turn, user_turn, self.token_event(250, 30, 45)],
162
+ )
163
+ self.write_session(
164
+ sessions_dir,
165
+ date(2026, 9, 1),
166
+ "old",
167
+ [user_turn, self.token_event(999)],
168
+ )
169
+
170
+ output = io.StringIO()
171
+ with contextlib.redirect_stdout(output):
172
+ result = artificial_intelligence_usage.run_report(
173
+ sessions_dir, days=2, today=date(2026, 10, 4)
174
+ )
175
+
176
+ report = output.getvalue()
177
+ self.assertEqual(result, 0)
178
+ self.assertRegex(report, r"2026-10-04\s+2\s+3\s+1\.5\s+350")
179
+ self.assertIn("70% remaining", report)
180
+ self.assertIn("55% remaining", report)
181
+ daily_report = report[report.index("Date") :]
182
+ self.assertNotIn("999", daily_report)
183
+
184
+ def test_main_can_be_called_repeatedly(self):
185
+ with patch.object(
186
+ artificial_intelligence_usage, "run_report", return_value=0
187
+ ) as run_report:
188
+ self.assertEqual(artificial_intelligence_usage.main(["--days", "3"]), 0)
189
+ self.assertEqual(artificial_intelligence_usage.main(["--days", "5"]), 0)
190
+
191
+ self.assertEqual(run_report.call_count, 2)
192
+ self.assertEqual(run_report.call_args_list[0].args[1], 3)
193
+ self.assertEqual(run_report.call_args_list[1].args[1], 5)
194
+
195
+
196
+ if __name__ == "__main__":
197
+ unittest.main()
@@ -0,0 +1,141 @@
1
+ import os
2
+ import shutil
3
+ import stat
4
+ import subprocess
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+
10
+ class PublishPypiScriptTests(unittest.TestCase):
11
+ def run_script_with_fake_python(
12
+ self,
13
+ fake_python_body: str,
14
+ create_dist: bool = True,
15
+ create_artifact: bool = True,
16
+ ):
17
+ source_script = Path(__file__).resolve().parents[1] / "publish-pypi.sh"
18
+
19
+ directory = tempfile.TemporaryDirectory()
20
+ self.addCleanup(directory.cleanup)
21
+ root = Path(directory.name)
22
+ script = root / "publish-pypi.sh"
23
+ shutil.copy(source_script, script)
24
+ script.chmod(script.stat().st_mode | stat.S_IXUSR)
25
+
26
+ if create_dist:
27
+ dist = root / "dist"
28
+ dist.mkdir()
29
+ if create_artifact:
30
+ (dist / "package-0.1.0.tar.gz").write_text(
31
+ "artifact", encoding="utf-8"
32
+ )
33
+
34
+ bin_dir = root / "bin"
35
+ bin_dir.mkdir()
36
+ fake_python = bin_dir / "python3"
37
+ fake_python.write_text(fake_python_body, encoding="utf-8")
38
+ fake_python.chmod(fake_python.stat().st_mode | stat.S_IXUSR)
39
+
40
+ env = os.environ.copy()
41
+ env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
42
+
43
+ result = subprocess.run(
44
+ [str(script)],
45
+ cwd=root,
46
+ env=env,
47
+ capture_output=True,
48
+ text=True,
49
+ check=False,
50
+ )
51
+ return result, root
52
+
53
+ def test_reports_already_published_when_twine_returns_duplicate_400(self):
54
+ result, _root = self.run_script_with_fake_python(
55
+ """#!/usr/bin/env bash
56
+ set -euo pipefail
57
+ if [[ "$1" == "-c" ]]; then exit 0; fi
58
+ if [[ "$1" == "-m" && "$2" == "twine" && "$3" == "upload" ]]; then
59
+ echo "HTTPError: 400 Bad Request" >&2
60
+ echo "File already exists" >&2
61
+ exit 1
62
+ fi
63
+ exit 2
64
+ """
65
+ )
66
+
67
+ output = result.stdout + result.stderr
68
+ self.assertEqual(result.returncode, 1)
69
+ self.assertIn("HTTP 400", output)
70
+ self.assertIn("already been published", output)
71
+
72
+ def test_requires_dist_directory(self):
73
+ result, _root = self.run_script_with_fake_python(
74
+ """#!/usr/bin/env bash
75
+ set -euo pipefail
76
+ if [[ "$1" == "-c" ]]; then exit 0; fi
77
+ exit 2
78
+ """,
79
+ create_dist=False,
80
+ )
81
+
82
+ self.assertEqual(result.returncode, 1)
83
+ self.assertIn("dist/ was not found", result.stderr)
84
+
85
+ def test_requires_dist_artifacts(self):
86
+ result, _root = self.run_script_with_fake_python(
87
+ """#!/usr/bin/env bash
88
+ set -euo pipefail
89
+ if [[ "$1" == "-c" ]]; then exit 0; fi
90
+ exit 2
91
+ """,
92
+ create_artifact=False,
93
+ )
94
+
95
+ self.assertEqual(result.returncode, 1)
96
+ self.assertIn("dist/ is empty", result.stderr)
97
+
98
+ def test_uploads_generated_dist_artifacts(self):
99
+ result, root = self.run_script_with_fake_python(
100
+ """#!/usr/bin/env bash
101
+ set -euo pipefail
102
+ if [[ "$1" == "-c" ]]; then exit 0; fi
103
+ if [[ "$1" == "-m" && "$2" == "twine" && "$3" == "upload" ]]; then
104
+ printf '%s\n' "$@" > upload-args.txt
105
+ echo "Uploaded"
106
+ exit 0
107
+ fi
108
+ exit 2
109
+ """
110
+ )
111
+
112
+ self.assertEqual(result.returncode, 0)
113
+ self.assertIn("Uploaded", result.stdout)
114
+ upload_args = (root / "upload-args.txt").read_text(
115
+ encoding="utf-8"
116
+ ).splitlines()
117
+ self.assertEqual(upload_args[:3], ["-m", "twine", "upload"])
118
+ self.assertIn("dist/package-0.1.0.tar.gz", upload_args)
119
+ self.assertIn("--verbose", upload_args)
120
+
121
+ def test_generic_twine_error_does_not_report_already_published(self):
122
+ result, _root = self.run_script_with_fake_python(
123
+ """#!/usr/bin/env bash
124
+ set -euo pipefail
125
+ if [[ "$1" == "-c" ]]; then exit 0; fi
126
+ if [[ "$1" == "-m" && "$2" == "twine" && "$3" == "upload" ]]; then
127
+ echo "HTTPError: 500 Server Error" >&2
128
+ exit 1
129
+ fi
130
+ exit 2
131
+ """
132
+ )
133
+
134
+ output = result.stdout + result.stderr
135
+ self.assertEqual(result.returncode, 1)
136
+ self.assertIn("500 Server Error", output)
137
+ self.assertNotIn("already been published", output)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ unittest.main()