teamspend-cli 0.2.1__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.
teamspend/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """
2
+ Programmatic / agent-native entry point.
3
+
4
+ from teamspend import fetch_cursor_spend, fetch_claude_code_spend, build_comparison
5
+
6
+ before = fetch_cursor_spend(DateWindow("2026-04-01", "2026-04-30"), api_key)
7
+ after = fetch_claude_code_spend(DateWindow("2026-06-01", "2026-06-30"), api_key)
8
+
9
+ Each adapter returns the same normalized AdapterResult shape regardless of
10
+ source vendor, so a caller (CLI, CI script, or an agent framework) can
11
+ compare spend across tools without vendor-specific branching.
12
+
13
+ This is the Python port of the teamspend npm package
14
+ (https://www.npmjs.com/package/teamspend). Both distributions talk to the
15
+ same two admin APIs (Cursor, Anthropic Claude Enterprise Analytics) and
16
+ compute the same before/after delta; see
17
+ https://github.com/RudrenduPaul/teamspend for the canonical documentation
18
+ and the original TypeScript source.
19
+ """
20
+ from .adapters.claude_code import fetch_claude_code_spend
21
+ from .adapters.copilot import fetch_copilot_spend
22
+ from .adapters.csv_import import import_from_csv
23
+ from .adapters.cursor import fetch_cursor_spend
24
+ from .compare import ComparisonReport, PeriodOutcome, TopSpenderEntry, build_comparison
25
+ from .errors import (
26
+ AuthenticationError,
27
+ CSVRowError,
28
+ CSVSchemaError,
29
+ DataUnavailableError,
30
+ EmptyCSVError,
31
+ InvalidCliArgError,
32
+ RetryExhaustedError,
33
+ SchemaDriftError,
34
+ )
35
+ from .http_client import fetch_with_retry, require_field
36
+ from .output import (
37
+ render_terminal_summary,
38
+ report_to_json_dict,
39
+ scaffold_gitignore,
40
+ write_json_report,
41
+ )
42
+ from .types import AdapterResult, DateWindow, ToolId, UserUsage, sum_cost, top_spenders
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = [
47
+ "__version__",
48
+ # types
49
+ "AdapterResult",
50
+ "DateWindow",
51
+ "ToolId",
52
+ "UserUsage",
53
+ "sum_cost",
54
+ "top_spenders",
55
+ # errors
56
+ "AuthenticationError",
57
+ "RetryExhaustedError",
58
+ "SchemaDriftError",
59
+ "DataUnavailableError",
60
+ "CSVSchemaError",
61
+ "EmptyCSVError",
62
+ "CSVRowError",
63
+ "InvalidCliArgError",
64
+ # http
65
+ "fetch_with_retry",
66
+ "require_field",
67
+ # adapters
68
+ "fetch_cursor_spend",
69
+ "fetch_claude_code_spend",
70
+ "fetch_copilot_spend",
71
+ "import_from_csv",
72
+ # compare
73
+ "PeriodOutcome",
74
+ "ComparisonReport",
75
+ "TopSpenderEntry",
76
+ "build_comparison",
77
+ # output
78
+ "render_terminal_summary",
79
+ "write_json_report",
80
+ "report_to_json_dict",
81
+ "scaffold_gitignore",
82
+ ]
@@ -0,0 +1,9 @@
1
+ from .claude_code import fetch_claude_code_spend
2
+ from .csv_import import import_from_csv
3
+ from .cursor import fetch_cursor_spend
4
+
5
+ __all__ = [
6
+ "fetch_cursor_spend",
7
+ "fetch_claude_code_spend",
8
+ "import_from_csv",
9
+ ]
@@ -0,0 +1,77 @@
1
+ """
2
+ Claude Code (Anthropic Claude Enterprise Analytics API) adapter.
3
+
4
+ Ported from src/adapters/claude-code.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict
9
+
10
+ from ..errors import DataUnavailableError
11
+ from ..http_client import fetch_with_retry, require_field
12
+ from ..types import AdapterResult, DateWindow, UserUsage, sum_cost
13
+
14
+ TOOL = "claude-code"
15
+ ANALYTICS_API_START_DATE = "2026-01-01"
16
+ """Anthropic's Claude Enterprise Analytics API has no data before this date."""
17
+
18
+
19
+ def _normalize_user(raw: Dict[str, Any]) -> UserUsage:
20
+ input_tokens = require_field(raw, "input_tokens", TOOL)
21
+ output_tokens = require_field(raw, "output_tokens", TOOL)
22
+ cost_usd = require_field(raw, "spend_usd", TOOL)
23
+
24
+ # Claude.ai Team/Enterprise seats don't expose true per-user cost via the
25
+ # Admin API -- it reports a technically-valid but structurally
26
+ # uninformative spend_usd: 0 for a user who clearly has real token
27
+ # activity. Flag that specific combination as estimated rather than
28
+ # presenting a misleading exact-looking $0 (no requests field exists on
29
+ # this vendor's payload, so the check is token-only).
30
+ is_suspicious_zero = cost_usd == 0 and (input_tokens > 0 or output_tokens > 0)
31
+
32
+ return UserUsage(
33
+ user_id=require_field(raw, "user_id", TOOL),
34
+ user_email=require_field(raw, "email", TOOL),
35
+ input_tokens=input_tokens,
36
+ output_tokens=output_tokens,
37
+ cache_read_tokens=require_field(raw, "cache_read_tokens", TOOL),
38
+ cache_write_tokens=require_field(raw, "cache_write_tokens", TOOL),
39
+ requests=None,
40
+ cost_usd=cost_usd,
41
+ is_estimated=is_suspicious_zero,
42
+ )
43
+
44
+
45
+ def fetch_claude_code_spend(
46
+ window: DateWindow, api_key: str, **fetch_kwargs: Any
47
+ ) -> AdapterResult:
48
+ """
49
+ Fetches Claude Code spend via Anthropic's Analytics/Admin API. If the
50
+ window's start predates 2026-01-01 (the Analytics API's hard start date,
51
+ not a rolling window), raises DataUnavailableError rather than silently
52
+ returning an incomplete or zeroed result. The caller falls back to the
53
+ CSV-import path for that portion of the window.
54
+ """
55
+ if window.start < ANALYTICS_API_START_DATE:
56
+ raise DataUnavailableError(
57
+ TOOL,
58
+ f"requested window starts {window.start}, before the Analytics "
59
+ f"API's {ANALYTICS_API_START_DATE} start date",
60
+ )
61
+
62
+ auth_header = {"x-api-key": api_key}
63
+ url = (
64
+ "https://api.anthropic.com/v1/organizations/usage_report/claude_code"
65
+ f"?start={window.start}&end={window.end}"
66
+ )
67
+ raw = fetch_with_retry(TOOL, url, auth_header, **fetch_kwargs)
68
+ raw_users = require_field(raw, "users", TOOL)
69
+
70
+ users = [_normalize_user(u) for u in raw_users]
71
+ return AdapterResult(
72
+ source=TOOL,
73
+ window=window,
74
+ total_cost_usd=sum_cost(users),
75
+ is_estimated=any(u.is_estimated for u in users),
76
+ users=users,
77
+ )
@@ -0,0 +1,258 @@
1
+ """
2
+ Claude Code personal-usage adapter -- reads Claude Code's own local JSONL
3
+ session logs directly, no admin API, no network call, no credential.
4
+
5
+ Ported from src/adapters/claude-code-personal.ts. For someone who just
6
+ wants their own personal Claude Code spend/usage without org-admin access
7
+ to the Cursor Admin API or Anthropic's Claude Enterprise Analytics API that
8
+ every other adapter in this package requires.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import getpass
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from ..errors import DataUnavailableError
19
+ from ..types import AdapterResult, DateWindow, SessionUsage, UserUsage, sum_cost
20
+
21
+ TOOL = "claude-code-personal"
22
+
23
+
24
+ def _path_exists(candidate: Path) -> bool:
25
+ try:
26
+ return candidate.exists()
27
+ except OSError:
28
+ return False
29
+
30
+
31
+ def resolve_projects_dirs() -> List[Path]:
32
+ """
33
+ Resolves the directory (or directories) to scan for Claude Code session
34
+ logs, in the same order Claude Code itself resolves its config dir:
35
+
36
+ 1. `CLAUDE_CONFIG_DIR` (comma-separated list -- every entry is scanned,
37
+ not just the first). Each entry may either already point at a
38
+ `projects/` directory or be the parent config dir that contains one.
39
+ 2. Else, the XDG-style location (`$XDG_CONFIG_HOME/claude/projects`,
40
+ defaulting `XDG_CONFIG_HOME` itself to `~/.config` per the XDG spec)
41
+ IF that directory exists.
42
+ 3. Else, the legacy default: `~/.claude/projects`.
43
+
44
+ Only one of (2)/(3) is ever returned -- they're alternatives, not both
45
+ scanned -- while (1) can return several directories at once.
46
+ """
47
+ override = os.environ.get("CLAUDE_CONFIG_DIR")
48
+ if override and override.strip():
49
+ dirs = []
50
+ for entry in override.split(","):
51
+ entry = entry.strip()
52
+ if not entry:
53
+ continue
54
+ entry_path = Path(entry)
55
+ dirs.append(
56
+ entry_path
57
+ if entry_path.name == "projects"
58
+ else entry_path / "projects"
59
+ )
60
+ return dirs
61
+
62
+ xdg_base_raw = os.environ.get("XDG_CONFIG_HOME", "").strip()
63
+ xdg_base = Path(xdg_base_raw) if xdg_base_raw else Path.home() / ".config"
64
+ xdg_projects_dir = xdg_base / "claude" / "projects"
65
+ if _path_exists(xdg_projects_dir):
66
+ return [xdg_projects_dir]
67
+
68
+ return [Path.home() / ".claude" / "projects"]
69
+
70
+
71
+ def _collect_jsonl_files(directory: Path) -> List[Path]:
72
+ """
73
+ Recursively collects every `*.jsonl` file under `directory`, including
74
+ nested `subagents/` subdirectories. A missing/unreadable directory
75
+ yields an empty list rather than raising -- the caller decides what "no
76
+ files anywhere" means (DataUnavailableError), not this low-level walker.
77
+ """
78
+ if not _path_exists(directory):
79
+ return []
80
+ try:
81
+ return sorted(directory.rglob("*.jsonl"))
82
+ except OSError:
83
+ return []
84
+
85
+
86
+ def _within_window(timestamp: str, window: DateWindow) -> bool:
87
+ date = timestamp[:10]
88
+ return window.start <= date <= window.end
89
+
90
+
91
+ def _resolve_local_user_id() -> str:
92
+ """
93
+ Best-effort local identity for the single user this mode reports on.
94
+ There is no admin API here, so there is no vendor-supplied user_id/email
95
+ to fall back on (unlike cursor.py/claude_code.py) -- the OS login name
96
+ is the closest reliable local signal, matching csv_import.py's
97
+ precedent of using whatever identity is directly at hand rather than
98
+ guessing. Falls back to a fixed placeholder if the OS refuses to answer
99
+ (some minimal containers have no passwd entry for the running uid).
100
+ """
101
+ try:
102
+ username = getpass.getuser()
103
+ if username and username.strip():
104
+ return username
105
+ except (OSError, KeyError):
106
+ pass
107
+ return "local-user"
108
+
109
+
110
+ def fetch_claude_code_personal_usage(window: DateWindow) -> AdapterResult:
111
+ """
112
+ Reads Claude Code's own local JSONL session logs and reports the single
113
+ local user's usage for `window` -- no admin API, no network call, no
114
+ credential.
115
+
116
+ Entries are deduped by the (message.id, requestId) pair before summing,
117
+ since retried requests can appear more than once in the logs. `costUSD`
118
+ is trusted when present on a line; when it's absent, that line still
119
+ contributes its token counts but NOT a dollar amount, and the whole
120
+ result is flagged `is_estimated` -- matching this codebase's existing
121
+ "never present a guess as an exact number" rule (see
122
+ claude_code.py/cursor.py's suspicious-zero handling for the same
123
+ philosophy).
124
+ """
125
+ projects_dirs = resolve_projects_dirs()
126
+
127
+ jsonl_files: List[Path] = []
128
+ for directory in projects_dirs:
129
+ jsonl_files.extend(_collect_jsonl_files(directory))
130
+
131
+ if not jsonl_files:
132
+ checked = ", ".join(str(d) for d in projects_dirs)
133
+ raise DataUnavailableError(
134
+ TOOL,
135
+ f"no Claude Code session logs (*.jsonl) found under {checked} "
136
+ "(checked recursively, including subagents/ subdirectories)",
137
+ )
138
+
139
+ seen_keys = set()
140
+ input_tokens = 0
141
+ output_tokens = 0
142
+ cache_read_tokens = 0
143
+ cache_write_tokens = 0
144
+ requests = 0
145
+ cost_usd = 0.0
146
+ is_estimated = False
147
+ # Per-session totals, keyed by each JSONL line's own sessionId. A line
148
+ # with no sessionId still contributes to the flat totals above but
149
+ # can't be attributed to any session, so it's simply left out of this
150
+ # dict.
151
+ session_totals: Dict[str, Dict[str, Any]] = {}
152
+
153
+ for file_path in jsonl_files:
154
+ text = file_path.read_text(encoding="utf-8")
155
+ for line in text.split("\n"):
156
+ line = line.strip()
157
+ if not line:
158
+ continue
159
+
160
+ try:
161
+ entry: Dict[str, Any] = json.loads(line)
162
+ except (json.JSONDecodeError, ValueError):
163
+ # Skip a corrupted/partial line rather than failing the
164
+ # whole read -- Claude Code's own logs can end mid-write if
165
+ # a session crashes.
166
+ continue
167
+
168
+ timestamp = entry.get("timestamp")
169
+ if not timestamp or not _within_window(timestamp, window):
170
+ continue
171
+
172
+ message = entry.get("message") or {}
173
+ message_id: Optional[str] = message.get("id")
174
+ request_id: Optional[str] = entry.get("requestId")
175
+ if message_id or request_id:
176
+ dedupe_key = f"{message_id or ''}::{request_id or ''}"
177
+ if dedupe_key in seen_keys:
178
+ continue
179
+ seen_keys.add(dedupe_key)
180
+
181
+ usage = message.get("usage") or {}
182
+ line_input_tokens = usage.get("input_tokens") or 0
183
+ line_output_tokens = usage.get("output_tokens") or 0
184
+ input_tokens += line_input_tokens
185
+ output_tokens += line_output_tokens
186
+ cache_read_tokens += usage.get("cache_read_input_tokens") or 0
187
+ cache_write_tokens += usage.get("cache_creation_input_tokens") or 0
188
+ requests += 1
189
+
190
+ cost = entry.get("costUSD")
191
+ line_cost_usd = 0.0
192
+ line_is_estimated = False
193
+ if isinstance(cost, (int, float)) and not isinstance(cost, bool):
194
+ cost_usd += cost
195
+ line_cost_usd = float(cost)
196
+ else:
197
+ is_estimated = True
198
+ line_is_estimated = True
199
+
200
+ session_id = entry.get("sessionId")
201
+ if session_id:
202
+ totals = session_totals.setdefault(
203
+ session_id,
204
+ {
205
+ "cost_usd": 0.0,
206
+ "input_tokens": 0,
207
+ "output_tokens": 0,
208
+ "requests": 0,
209
+ "is_estimated": False,
210
+ },
211
+ )
212
+ totals["cost_usd"] += line_cost_usd
213
+ totals["input_tokens"] += line_input_tokens
214
+ totals["output_tokens"] += line_output_tokens
215
+ totals["requests"] += 1
216
+ totals["is_estimated"] = totals["is_estimated"] or line_is_estimated
217
+
218
+ # Only attach `sessions` when at least one line actually carried a
219
+ # sessionId -- an empty list would misleadingly claim "zero sessions"
220
+ # for a log format that simply predates the sessionId field, when the
221
+ # truth is teamspend couldn't group anything at all.
222
+ sessions: Optional[List[SessionUsage]] = (
223
+ [
224
+ SessionUsage(
225
+ session_id=session_id,
226
+ cost_usd=totals["cost_usd"],
227
+ input_tokens=totals["input_tokens"],
228
+ output_tokens=totals["output_tokens"],
229
+ requests=totals["requests"],
230
+ is_estimated=totals["is_estimated"],
231
+ )
232
+ for session_id, totals in session_totals.items()
233
+ ]
234
+ if session_totals
235
+ else None
236
+ )
237
+
238
+ user = UserUsage(
239
+ user_id=_resolve_local_user_id(),
240
+ user_email=None,
241
+ input_tokens=input_tokens,
242
+ output_tokens=output_tokens,
243
+ cache_read_tokens=cache_read_tokens,
244
+ cache_write_tokens=cache_write_tokens,
245
+ requests=requests,
246
+ cost_usd=cost_usd,
247
+ is_estimated=is_estimated,
248
+ sessions=sessions,
249
+ )
250
+
251
+ users = [user]
252
+ return AdapterResult(
253
+ source=TOOL,
254
+ window=window,
255
+ total_cost_usd=sum_cost(users),
256
+ is_estimated=any(u.is_estimated for u in users),
257
+ users=users,
258
+ )
@@ -0,0 +1,265 @@
1
+ """
2
+ Codex CLI local-file adapter.
3
+
4
+ Ported from src/adapters/codex.ts.
5
+
6
+ Codex CLI (github.com/openai/codex, OpenAI's coding agent CLI) has no
7
+ admin/team/billing API -- it is a local CLI with its own local session
8
+ logs, the same shape as claude_code_personal.py and opencode.py. Verified
9
+ directly against openai/codex's own Rust source (not a third-party guess):
10
+
11
+ - `codex-rs/utils/home-dir/src/lib.rs::find_codex_home` -- resolves via the
12
+ `CODEX_HOME` env var, defaulting to `~/.codex` if unset. Unlike Claude
13
+ Code's `CLAUDE_CONFIG_DIR`, this is a single path, never a
14
+ comma-separated list.
15
+ - `codex-rs/rollout/src/lib.rs` -- `SESSIONS_SUBDIR = "sessions"` and
16
+ `ARCHIVED_SESSIONS_SUBDIR = "archived_sessions"`, both scanned below.
17
+ - `codex-rs/rollout/src/list.rs` (doc comment) -- on-disk layout is
18
+ `<codex_home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl`.
19
+ - `codex-rs/protocol/src/protocol.rs::TokenUsage` / `TokenUsageInfo` -- the
20
+ exact per-turn usage record shape read below (`input_tokens`,
21
+ `cached_input_tokens`, `cache_write_input_tokens`, `output_tokens`,
22
+ `reasoning_output_tokens`, `total_tokens`, wrapped in
23
+ `{ total_token_usage, last_token_usage }`).
24
+ - `codex-rs/codex-api/src/sse/responses.rs` -- confirms `input_tokens` in
25
+ that struct is copied straight from OpenAI's Responses API
26
+ `usage.input_tokens`, which already *includes* `cached_tokens` as a
27
+ subset (its own test fixture: `input_tokens: 100`, `cached_tokens: 40`),
28
+ not an additional amount.
29
+
30
+ Cross-checked against ccusage's own Codex guide
31
+ (ccusage.com/guide/codex/) and mrexodia/agent-cost-dashboard's
32
+ `cost_dashboard.py` (`analyze_codex_jsonl_file`), which parse the
33
+ identical record shapes independently -- both agree with the Rust source
34
+ on every field name.
35
+
36
+ **Cold-storage limitation**: `codex-rs/rollout/src/compression.rs`
37
+ background-compresses any rollout file older than 7 days
38
+ (`MIN_ROLLOUT_AGE`) from `rollout-*.jsonl` to `rollout-*.jsonl.zst`
39
+ (zstd). This adapter only reads plain `.jsonl` files -- the same tradeoff
40
+ opencode.py already made for OpenCode's newer SQLite store: this package
41
+ adds no dependency for a secondary on-disk format. A requested window
42
+ reaching back more than ~7 days will under-report or come back empty for
43
+ Codex; use the CSV-import fallback to cover that period.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import getpass
48
+ import json
49
+ import os
50
+ from pathlib import Path
51
+ from typing import Any, Dict, List, Optional
52
+
53
+ from ..errors import DataUnavailableError
54
+ from ..types import AdapterResult, DateWindow, UserUsage, sum_cost
55
+
56
+ TOOL = "codex"
57
+ CODEX_HOME_ENV = "CODEX_HOME"
58
+ SESSIONS_SUBDIR = "sessions"
59
+ ARCHIVED_SESSIONS_SUBDIR = "archived_sessions"
60
+
61
+
62
+ def resolve_codex_sessions_dirs(
63
+ env: Optional[Dict[str, str]] = None,
64
+ ) -> List[str]:
65
+ """
66
+ Resolves the directories to scan for local Codex CLI session logs:
67
+ `<CODEX_HOME>/sessions` and `<CODEX_HOME>/archived_sessions`, where
68
+ `CODEX_HOME` defaults to `~/.codex` (see module docstring for the
69
+ source citation). Falls back to `HOME`/`USERPROFILE` off the same
70
+ `env` dict (rather than `Path.home()` directly) so tests can inject a
71
+ fake home directory the same way `resolve_opencode_data_dirs` does.
72
+ """
73
+ if env is None:
74
+ env = dict(os.environ)
75
+
76
+ override = env.get(CODEX_HOME_ENV, "").strip()
77
+ if override:
78
+ return [
79
+ str(Path(override, SESSIONS_SUBDIR)),
80
+ str(Path(override, ARCHIVED_SESSIONS_SUBDIR)),
81
+ ]
82
+
83
+ home = env.get("HOME") or env.get("USERPROFILE")
84
+ codex_home = Path(home, ".codex") if home else Path.home() / ".codex"
85
+ return [
86
+ str(codex_home / SESSIONS_SUBDIR),
87
+ str(codex_home / ARCHIVED_SESSIONS_SUBDIR),
88
+ ]
89
+
90
+
91
+ def _collect_jsonl_files(directory: str) -> List[Path]:
92
+ """
93
+ Recursively collects every plain `*.jsonl` rollout file under
94
+ `directory` (the real layout nests them `YYYY/MM/DD/rollout-*.jsonl`,
95
+ but this walks arbitrarily deep rather than hardcoding that depth).
96
+ `*.jsonl.zst` (compressed, cold) siblings are deliberately skipped --
97
+ see the module docstring. A missing/unreadable directory yields an
98
+ empty list rather than raising.
99
+ """
100
+ root = Path(directory)
101
+ if not root.is_dir():
102
+ return []
103
+ try:
104
+ return sorted(root.rglob("*.jsonl"))
105
+ except OSError:
106
+ return []
107
+
108
+
109
+ def _within_window(timestamp: str, window: DateWindow) -> bool:
110
+ date = timestamp[:10]
111
+ return window.start <= date <= window.end
112
+
113
+
114
+ def _resolve_local_user_id() -> str:
115
+ """
116
+ Best-effort local identity for the single user this mode reports on --
117
+ same rationale and fallback as claude_code_personal.py/opencode.py: no
118
+ admin API here means no vendor-supplied user_id/email to fall back on.
119
+ """
120
+ try:
121
+ username = getpass.getuser()
122
+ if username and username.strip():
123
+ return username
124
+ except (OSError, KeyError):
125
+ pass
126
+ return "local-user"
127
+
128
+
129
+ def fetch_codex_usage(
130
+ window: DateWindow, sessions_dirs: Optional[List[str]] = None
131
+ ) -> AdapterResult:
132
+ """
133
+ Reads Codex CLI's own local JSONL rollout logs and reports the single
134
+ local user's token usage for `window` -- no admin API, no network
135
+ call, no credential.
136
+
137
+ Codex's `event_msg` records with `payload.type == "token_count"` carry
138
+ per-turn token counts but, unlike Cursor/Claude Code's admin APIs,
139
+ **no cost field at all** -- not even a `cost: 0` placeholder the way
140
+ OpenCode's message files have. teamspend bundles no per-token pricing
141
+ table of its own (the same call opencode.py already made, for the
142
+ same reason: a bundled table drifts from real vendor prices and is
143
+ exactly the kind of guessed data this package avoids). `cost_usd` is
144
+ therefore always `0` and the result is always `is_estimated: True`
145
+ when any usage is found -- token counts are exact, dollars are simply
146
+ not reported by Codex at all.
147
+
148
+ `last_token_usage` on each `token_count` event is already the per-turn
149
+ delta (Codex's own `TokenUsageInfo::append_last_usage` sets it
150
+ verbatim per turn, distinct from the cumulative `total_token_usage`),
151
+ so this reads it directly rather than diffing consecutive cumulative
152
+ totals. Sessions old enough for `last_token_usage` to be entirely
153
+ absent predate the current schema and, per the cold-storage limitation
154
+ above, will already have been compressed to `.jsonl.zst` and skipped
155
+ -- such lines are simply skipped here too rather than deriving a delta
156
+ from `total_token_usage`.
157
+
158
+ Codex emits each `token_count` event twice in a row with
159
+ byte-identical `info` -- confirmed independently by
160
+ mrexodia/agent-cost-dashboard's own parser, which carries the same
161
+ dedup -- so a repeated `info` blob (matched by structural equality) is
162
+ skipped rather than double-counted.
163
+
164
+ `input_tokens` in Codex's schema already includes `cached_input_tokens`
165
+ as a subset (see module docstring), so this stores the net, uncached
166
+ portion as `input_tokens` to avoid double-counting input and
167
+ cache-read tokens the way `cache_read_tokens` already accounts for the
168
+ cached portion.
169
+ """
170
+ if sessions_dirs is None:
171
+ sessions_dirs = resolve_codex_sessions_dirs()
172
+
173
+ jsonl_files: List[Path] = []
174
+ for directory in sessions_dirs:
175
+ jsonl_files.extend(_collect_jsonl_files(directory))
176
+
177
+ if not jsonl_files:
178
+ checked = ", ".join(sessions_dirs)
179
+ raise DataUnavailableError(
180
+ TOOL,
181
+ f"no Codex CLI session logs (*.jsonl) found under {checked} -- "
182
+ "codex has no admin/team API, only local per-machine rollout "
183
+ "logs (set CODEX_HOME to override)",
184
+ )
185
+
186
+ input_tokens = 0
187
+ output_tokens = 0
188
+ cache_read_tokens = 0
189
+ cache_write_tokens = 0
190
+ requests = 0
191
+
192
+ for file_path in jsonl_files:
193
+ text = file_path.read_text(encoding="utf-8")
194
+ previous_signature: Optional[str] = None
195
+
196
+ for line in text.split("\n"):
197
+ line = line.strip()
198
+ if not line:
199
+ continue
200
+
201
+ try:
202
+ record: Dict[str, Any] = json.loads(line)
203
+ except (json.JSONDecodeError, ValueError):
204
+ # Skip a corrupted/partial line rather than failing the
205
+ # whole read -- a rollout file can end mid-write if Codex
206
+ # crashes or is killed mid-turn.
207
+ continue
208
+
209
+ payload = record.get("payload") or {}
210
+ if record.get("type") != "event_msg" or payload.get("type") != "token_count":
211
+ continue
212
+
213
+ info = payload.get("info")
214
+ if not info:
215
+ continue
216
+
217
+ signature = json.dumps(info, sort_keys=True)
218
+ if signature == previous_signature:
219
+ # Codex fires each token_count event twice in a row with
220
+ # identical usage -- see module docstring.
221
+ continue
222
+ previous_signature = signature
223
+
224
+ usage = info.get("last_token_usage")
225
+ if not usage:
226
+ continue
227
+
228
+ timestamp = record.get("timestamp")
229
+ if not timestamp or not _within_window(timestamp, window):
230
+ continue
231
+
232
+ raw_input = usage.get("input_tokens") or 0
233
+ cached_input = usage.get("cached_input_tokens") or 0
234
+ input_tokens += max(0, raw_input - cached_input)
235
+ cache_read_tokens += cached_input
236
+ cache_write_tokens += usage.get("cache_write_input_tokens") or 0
237
+ output_tokens += usage.get("output_tokens") or 0
238
+ requests += 1
239
+
240
+ if requests == 0:
241
+ return AdapterResult(
242
+ source=TOOL, window=window, total_cost_usd=0, is_estimated=False, users=[]
243
+ )
244
+
245
+ users = [
246
+ UserUsage(
247
+ user_id=_resolve_local_user_id(),
248
+ user_email=None,
249
+ input_tokens=input_tokens,
250
+ output_tokens=output_tokens,
251
+ cache_read_tokens=cache_read_tokens,
252
+ cache_write_tokens=cache_write_tokens,
253
+ requests=requests,
254
+ cost_usd=0,
255
+ is_estimated=True,
256
+ )
257
+ ]
258
+
259
+ return AdapterResult(
260
+ source=TOOL,
261
+ window=window,
262
+ total_cost_usd=sum_cost(users),
263
+ is_estimated=True,
264
+ users=users,
265
+ )