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 +82 -0
- teamspend/adapters/__init__.py +9 -0
- teamspend/adapters/claude_code.py +77 -0
- teamspend/adapters/claude_code_personal.py +258 -0
- teamspend/adapters/codex.py +265 -0
- teamspend/adapters/copilot.py +189 -0
- teamspend/adapters/csv_import.py +127 -0
- teamspend/adapters/cursor.py +114 -0
- teamspend/adapters/opencode.py +250 -0
- teamspend/cli.py +318 -0
- teamspend/compare.py +77 -0
- teamspend/errors.py +96 -0
- teamspend/http_client.py +204 -0
- teamspend/output.py +251 -0
- teamspend/py.typed +0 -0
- teamspend/types.py +111 -0
- teamspend_cli-0.2.1.dist-info/METADATA +386 -0
- teamspend_cli-0.2.1.dist-info/RECORD +21 -0
- teamspend_cli-0.2.1.dist-info/WHEEL +4 -0
- teamspend_cli-0.2.1.dist-info/entry_points.txt +2 -0
- teamspend_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
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,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
|
+
)
|