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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub Copilot usage metrics adapter.
|
|
3
|
+
|
|
4
|
+
Ported from src/adapters/copilot.ts. See that file's module docstring for
|
|
5
|
+
the full research writeup; summarized here:
|
|
6
|
+
|
|
7
|
+
- The endpoint this adapter calls, GET /orgs/{org}/copilot/metrics/reports/
|
|
8
|
+
users-1-day, is the CURRENT (non-deprecated) Copilot usage metrics API.
|
|
9
|
+
The older /orgs/{org}/copilot/metrics endpoint, which returned inline
|
|
10
|
+
org-aggregate JSON, was sunset by GitHub on 2026-04-02 and no longer
|
|
11
|
+
exists.
|
|
12
|
+
- That real API has no arbitrary start/end range parameter -- only
|
|
13
|
+
single-day (`users-1-day?day=YYYY-MM-DD`) or latest-rolling-28-day
|
|
14
|
+
(`users-28-day/latest`) granularity. This adapter requests one report per
|
|
15
|
+
calendar day in the window and sums per-user totals across days, the
|
|
16
|
+
Python mirror of fetch_cursor_spend's 30-day-chunk-and-sum pattern.
|
|
17
|
+
- Each report call returns `{download_links, report_day}`, not data inline.
|
|
18
|
+
download_links are short-lived, pre-signed GitHub-owned URLs pointing to
|
|
19
|
+
NDJSON (newline-delimited JSON) files -- this adapter fetches and parses
|
|
20
|
+
each one.
|
|
21
|
+
- GitHub's Copilot metrics API has NO cost/spend field at all (unlike
|
|
22
|
+
Cursor/Claude Code, which do report a native cost figure). The only real,
|
|
23
|
+
vendor-reported, per-user number is `ai_credits_used`, which this adapter
|
|
24
|
+
converts to USD at GitHub's own published, fixed $0.01/credit rate --
|
|
25
|
+
never fabricated. An optional `seat_price_usd` adds a flat per-seat
|
|
26
|
+
monthly price once per user (never per day), since GitHub does not expose
|
|
27
|
+
an org's actual negotiated seat price via any API -- the same structural
|
|
28
|
+
gap Cursor's and Claude Code's flat-seat plans have.
|
|
29
|
+
- `is_estimated` is unconditionally True for every Copilot result: every
|
|
30
|
+
dollar figure produced here is derived, never vendor-reported.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json as json_module
|
|
35
|
+
from datetime import date, timedelta
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
from urllib.parse import quote
|
|
38
|
+
|
|
39
|
+
from ..errors import AuthenticationError, DataUnavailableError, InvalidCliArgError
|
|
40
|
+
from ..http_client import fetch_with_retry, require_field
|
|
41
|
+
from ..types import AdapterResult, DateWindow, UserUsage, sum_cost
|
|
42
|
+
|
|
43
|
+
TOOL = "copilot"
|
|
44
|
+
COPILOT_API_VERSION = "2026-03-10"
|
|
45
|
+
|
|
46
|
+
COPILOT_METRICS_START_DATE = "2025-10-10"
|
|
47
|
+
"""GitHub's Copilot usage metrics reports have no data before this date."""
|
|
48
|
+
|
|
49
|
+
COPILOT_CREDIT_USD_RATE = 0.01
|
|
50
|
+
"""
|
|
51
|
+
GitHub's own published, fixed conversion rate from an AI credit to USD --
|
|
52
|
+
not a negotiated or per-org price. Source: GitHub Blog, "GitHub Copilot is
|
|
53
|
+
moving to usage-based billing" (2026) -- "1 AI credit = $0.01 USD".
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _split_into_days(window: DateWindow) -> List[str]:
|
|
58
|
+
start = date.fromisoformat(window.start)
|
|
59
|
+
end = date.fromisoformat(window.end)
|
|
60
|
+
days: List[str] = []
|
|
61
|
+
cursor = start
|
|
62
|
+
while cursor <= end:
|
|
63
|
+
days.append(cursor.isoformat())
|
|
64
|
+
cursor += timedelta(days=1)
|
|
65
|
+
return days
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _normalize_user(raw: Dict[str, Any]) -> UserUsage:
|
|
69
|
+
user_login = require_field(raw, "user_login", TOOL)
|
|
70
|
+
ai_credits_used = require_field(raw, "ai_credits_used", TOOL)
|
|
71
|
+
interaction_count = require_field(raw, "user_initiated_interaction_count", TOOL)
|
|
72
|
+
|
|
73
|
+
return UserUsage(
|
|
74
|
+
user_id=user_login,
|
|
75
|
+
# GitHub identifies Copilot users by user_id/user_login (a GitHub
|
|
76
|
+
# username), never an email address -- unlike Cursor and Claude Code.
|
|
77
|
+
user_email=None,
|
|
78
|
+
# Copilot's metrics reports carry no token counts at all.
|
|
79
|
+
input_tokens=None,
|
|
80
|
+
output_tokens=None,
|
|
81
|
+
cache_read_tokens=None,
|
|
82
|
+
cache_write_tokens=None,
|
|
83
|
+
requests=interaction_count,
|
|
84
|
+
cost_usd=ai_credits_used * COPILOT_CREDIT_USD_RATE,
|
|
85
|
+
is_estimated=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_ndjson(text: str) -> List[Dict[str, Any]]:
|
|
90
|
+
return [
|
|
91
|
+
json_module.loads(line)
|
|
92
|
+
for line in text.split("\n")
|
|
93
|
+
if line.strip()
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_copilot_spend(
|
|
98
|
+
window: DateWindow,
|
|
99
|
+
api_key: str,
|
|
100
|
+
org: str,
|
|
101
|
+
seat_price_usd: Optional[float] = None,
|
|
102
|
+
**fetch_kwargs: Any,
|
|
103
|
+
) -> AdapterResult:
|
|
104
|
+
"""
|
|
105
|
+
Fetches GitHub Copilot org usage for the given window and converts it to
|
|
106
|
+
a dollar figure. See this module's docstring for the full API research
|
|
107
|
+
writeup.
|
|
108
|
+
|
|
109
|
+
`fetch_kwargs` forwards to http_client.fetch_with_retry (e.g.
|
|
110
|
+
`transport` and `sleep` for tests) -- never used by real callers.
|
|
111
|
+
"""
|
|
112
|
+
if seat_price_usd is not None and not (seat_price_usd >= 0):
|
|
113
|
+
raise InvalidCliArgError(
|
|
114
|
+
f"copilot seat price must be a non-negative number, got {seat_price_usd}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if window.start < COPILOT_METRICS_START_DATE:
|
|
118
|
+
raise DataUnavailableError(
|
|
119
|
+
TOOL,
|
|
120
|
+
f"requested window starts {window.start}, before Copilot usage "
|
|
121
|
+
f"metrics reports' {COPILOT_METRICS_START_DATE} start date",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
auth_header = {
|
|
125
|
+
"Authorization": f"Bearer {api_key}",
|
|
126
|
+
"Accept": "application/vnd.github+json",
|
|
127
|
+
"X-GitHub-Api-Version": COPILOT_API_VERSION,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
user_totals: Dict[str, UserUsage] = {}
|
|
131
|
+
|
|
132
|
+
for day in _split_into_days(window):
|
|
133
|
+
url = (
|
|
134
|
+
f"https://api.github.com/orgs/{quote(org, safe='')}"
|
|
135
|
+
f"/copilot/metrics/reports/users-1-day?day={day}"
|
|
136
|
+
)
|
|
137
|
+
report = fetch_with_retry(
|
|
138
|
+
TOOL, url, auth_header, empty_on=(404,), **fetch_kwargs
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# 404 means "no report for this day" -- treated as zero users for
|
|
142
|
+
# that day, not a failure, the same way Cursor's adapter treats a
|
|
143
|
+
# 200 {"users": []} response.
|
|
144
|
+
if report is None:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
download_links = require_field(report, "download_links", TOOL)
|
|
148
|
+
|
|
149
|
+
for link in download_links:
|
|
150
|
+
try:
|
|
151
|
+
# Download links are short-lived, pre-signed GitHub-owned
|
|
152
|
+
# URLs -- they don't need (and can reject) the org's
|
|
153
|
+
# TEAMSPEND_COPILOT_TOKEN.
|
|
154
|
+
ndjson_text = fetch_with_retry(
|
|
155
|
+
TOOL,
|
|
156
|
+
link,
|
|
157
|
+
{},
|
|
158
|
+
response_type="text",
|
|
159
|
+
**fetch_kwargs,
|
|
160
|
+
)
|
|
161
|
+
except AuthenticationError as error:
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"{TOOL}: report download link for {day} was rejected "
|
|
164
|
+
f"(expired signed URL?) -- retry the command (org={org})"
|
|
165
|
+
) from error
|
|
166
|
+
|
|
167
|
+
for raw_user in _parse_ndjson(ndjson_text):
|
|
168
|
+
normalized = _normalize_user(raw_user)
|
|
169
|
+
existing = user_totals.get(normalized.user_id)
|
|
170
|
+
if existing:
|
|
171
|
+
existing.requests = (existing.requests or 0) + (
|
|
172
|
+
normalized.requests or 0
|
|
173
|
+
)
|
|
174
|
+
existing.cost_usd += normalized.cost_usd
|
|
175
|
+
else:
|
|
176
|
+
user_totals[normalized.user_id] = normalized
|
|
177
|
+
|
|
178
|
+
if seat_price_usd:
|
|
179
|
+
for user in user_totals.values():
|
|
180
|
+
user.cost_usd += seat_price_usd
|
|
181
|
+
|
|
182
|
+
users_list = list(user_totals.values())
|
|
183
|
+
return AdapterResult(
|
|
184
|
+
source=TOOL,
|
|
185
|
+
window=window,
|
|
186
|
+
total_cost_usd=sum_cost(users_list),
|
|
187
|
+
is_estimated=True,
|
|
188
|
+
users=users_list,
|
|
189
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CSV-import fallback adapter, for the history a live admin API can't reach.
|
|
3
|
+
|
|
4
|
+
Ported from src/adapters/csv-import.ts. Schema: date, user_email, cost_usd,
|
|
5
|
+
is_estimated. One row per user per day; rows are aggregated per user_email.
|
|
6
|
+
|
|
7
|
+
Uses a simple split(",") parser rather than the stdlib `csv` module, on
|
|
8
|
+
purpose: the TypeScript original does the same (no quoted-field or embedded-
|
|
9
|
+
comma support), and keeping the two implementations' exact parsing
|
|
10
|
+
semantics identical matters more here than gaining stdlib `csv`'s richer
|
|
11
|
+
quoting support that the npm CLI doesn't have either.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, List
|
|
19
|
+
|
|
20
|
+
from ..errors import CSVRowError, CSVSchemaError, EmptyCSVError
|
|
21
|
+
from ..types import AdapterResult, DateWindow, ToolId, UserUsage, sum_cost
|
|
22
|
+
|
|
23
|
+
EXPECTED_COLUMNS = ["date", "user_email", "cost_usd", "is_estimated"]
|
|
24
|
+
|
|
25
|
+
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def strip_control_chars(value: str) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Strips C0 control characters (0x00-0x1f), including ANSI/OSC terminal
|
|
31
|
+
escape sequences, from a string. Without this, a crafted user_email --
|
|
32
|
+
whether from an imported CSV or a live admin API response -- could
|
|
33
|
+
inject escape codes into the non-JSON terminal summary output.
|
|
34
|
+
"""
|
|
35
|
+
return _CONTROL_CHAR_RE.sub("", value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_csv(text: str) -> List[Dict[str, str]]:
|
|
39
|
+
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
|
40
|
+
if not lines:
|
|
41
|
+
raise EmptyCSVError("<csv content>")
|
|
42
|
+
|
|
43
|
+
header = [h.strip() for h in lines[0].split(",")]
|
|
44
|
+
missing_columns = [col for col in EXPECTED_COLUMNS if col not in header]
|
|
45
|
+
if missing_columns:
|
|
46
|
+
raise CSVSchemaError(EXPECTED_COLUMNS)
|
|
47
|
+
|
|
48
|
+
date_idx = header.index("date")
|
|
49
|
+
email_idx = header.index("user_email")
|
|
50
|
+
cost_idx = header.index("cost_usd")
|
|
51
|
+
estimated_idx = header.index("is_estimated")
|
|
52
|
+
|
|
53
|
+
rows: List[Dict[str, str]] = []
|
|
54
|
+
for line in lines[1:]:
|
|
55
|
+
cells = [strip_control_chars(c.strip()) for c in line.split(",")]
|
|
56
|
+
|
|
57
|
+
def cell(idx: int) -> str:
|
|
58
|
+
return cells[idx] if idx < len(cells) else ""
|
|
59
|
+
|
|
60
|
+
rows.append(
|
|
61
|
+
{
|
|
62
|
+
"date": cell(date_idx),
|
|
63
|
+
"user_email": cell(email_idx),
|
|
64
|
+
"cost_usd": cell(cost_idx),
|
|
65
|
+
"is_estimated": cell(estimated_idx),
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
return rows
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def import_from_csv(csv_path: str, source: ToolId, window: DateWindow) -> AdapterResult:
|
|
72
|
+
"""
|
|
73
|
+
Imports before-window spend from a CSV file for a tool whose admin API
|
|
74
|
+
doesn't cover the requested window (e.g. Claude Code before 2026-01-01).
|
|
75
|
+
"""
|
|
76
|
+
text = Path(csv_path).read_text(encoding="utf-8")
|
|
77
|
+
if not text.strip():
|
|
78
|
+
raise EmptyCSVError(csv_path)
|
|
79
|
+
|
|
80
|
+
rows = _parse_csv(text)
|
|
81
|
+
user_totals: Dict[str, UserUsage] = {}
|
|
82
|
+
|
|
83
|
+
for index, row in enumerate(rows):
|
|
84
|
+
row_number = index + 2 # +1 for header row, +1 for 1-based numbering
|
|
85
|
+
if len(row["user_email"]) == 0:
|
|
86
|
+
raise CSVRowError(row_number, "user_email is empty")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
cost = float(row["cost_usd"])
|
|
90
|
+
except ValueError:
|
|
91
|
+
cost = math.nan
|
|
92
|
+
# float() accepts "inf"/"nan" (case-insensitive) as valid input where
|
|
93
|
+
# the TypeScript port's Number.isFinite(Number.parseFloat(...)) check
|
|
94
|
+
# already rejects both -- close the same gap here rather than let a
|
|
95
|
+
# crafted CSV silently propagate inf/nan into totals and CI-gate math.
|
|
96
|
+
if not math.isfinite(cost):
|
|
97
|
+
raise CSVRowError(
|
|
98
|
+
row_number, f'cost_usd "{row["cost_usd"]}" is not a valid number'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
existing = user_totals.get(row["user_email"])
|
|
102
|
+
is_estimated = row["is_estimated"].lower() == "true"
|
|
103
|
+
|
|
104
|
+
if existing:
|
|
105
|
+
existing.cost_usd += cost
|
|
106
|
+
existing.is_estimated = existing.is_estimated or is_estimated
|
|
107
|
+
else:
|
|
108
|
+
user_totals[row["user_email"]] = UserUsage(
|
|
109
|
+
user_id=row["user_email"],
|
|
110
|
+
user_email=row["user_email"],
|
|
111
|
+
input_tokens=None,
|
|
112
|
+
output_tokens=None,
|
|
113
|
+
cache_read_tokens=None,
|
|
114
|
+
cache_write_tokens=None,
|
|
115
|
+
requests=None,
|
|
116
|
+
cost_usd=cost,
|
|
117
|
+
is_estimated=is_estimated,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
users_list = list(user_totals.values())
|
|
121
|
+
return AdapterResult(
|
|
122
|
+
source=source,
|
|
123
|
+
window=window,
|
|
124
|
+
total_cost_usd=sum_cost(users_list),
|
|
125
|
+
is_estimated=any(u.is_estimated for u in users_list),
|
|
126
|
+
users=users_list,
|
|
127
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cursor Admin API adapter.
|
|
3
|
+
|
|
4
|
+
Ported from src/adapters/cursor.ts. Paginates across the API's 30-day
|
|
5
|
+
per-call cap and sums the result. If any chunk fails after retries are
|
|
6
|
+
exhausted, the ENTIRE call fails -- never silently sums only the chunks
|
|
7
|
+
that succeeded, which would under-report spend without any indication the
|
|
8
|
+
number is incomplete.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import date, timedelta
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from ..http_client import fetch_with_retry, require_field
|
|
16
|
+
from ..types import AdapterResult, DateWindow, UserUsage, sum_cost
|
|
17
|
+
|
|
18
|
+
CURSOR_MAX_WINDOW_DAYS = 30
|
|
19
|
+
TOOL = "cursor"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _split_into_chunks(window: DateWindow) -> List[DateWindow]:
|
|
23
|
+
start = date.fromisoformat(window.start)
|
|
24
|
+
end = date.fromisoformat(window.end)
|
|
25
|
+
chunks: List[DateWindow] = []
|
|
26
|
+
chunk_start = start
|
|
27
|
+
|
|
28
|
+
while chunk_start <= end:
|
|
29
|
+
chunk_end = chunk_start + timedelta(days=CURSOR_MAX_WINDOW_DAYS - 1)
|
|
30
|
+
clamped_end = min(chunk_end, end)
|
|
31
|
+
chunks.append(
|
|
32
|
+
DateWindow(start=chunk_start.isoformat(), end=clamped_end.isoformat())
|
|
33
|
+
)
|
|
34
|
+
chunk_start = clamped_end + timedelta(days=1)
|
|
35
|
+
|
|
36
|
+
return chunks
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_user(raw: Dict[str, Any]) -> UserUsage:
|
|
40
|
+
input_tokens = require_field(raw, "input_tokens", TOOL)
|
|
41
|
+
output_tokens = require_field(raw, "output_tokens", TOOL)
|
|
42
|
+
requests = require_field(raw, "requests", TOOL)
|
|
43
|
+
cost_usd = require_field(raw, "cost_usd", TOOL)
|
|
44
|
+
|
|
45
|
+
# Cursor plans without usage overage don't expose true per-user cost via
|
|
46
|
+
# the Admin API -- it reports a technically-valid but structurally
|
|
47
|
+
# uninformative cost_usd: 0 for a user who clearly has real activity.
|
|
48
|
+
# Flag that specific combination as estimated rather than presenting a
|
|
49
|
+
# misleading exact-looking $0.
|
|
50
|
+
is_suspicious_zero = cost_usd == 0 and (
|
|
51
|
+
input_tokens > 0 or output_tokens > 0 or requests > 0
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return UserUsage(
|
|
55
|
+
user_id=require_field(raw, "user_id", TOOL),
|
|
56
|
+
user_email=raw.get("email"),
|
|
57
|
+
input_tokens=input_tokens,
|
|
58
|
+
output_tokens=output_tokens,
|
|
59
|
+
cache_read_tokens=require_field(raw, "cache_read_tokens", TOOL),
|
|
60
|
+
cache_write_tokens=require_field(raw, "cache_write_tokens", TOOL),
|
|
61
|
+
requests=requests,
|
|
62
|
+
cost_usd=cost_usd,
|
|
63
|
+
is_estimated=is_suspicious_zero,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fetch_cursor_spend(window: DateWindow, api_key: str, **fetch_kwargs: Any) -> AdapterResult:
|
|
68
|
+
"""
|
|
69
|
+
Fetches Cursor Admin API spend for the given window, paginating across
|
|
70
|
+
30-day chunks (the API's per-call cap) and summing the result.
|
|
71
|
+
|
|
72
|
+
`fetch_kwargs` forwards to http_client.fetch_with_retry (e.g. `transport`
|
|
73
|
+
and `sleep` for tests) -- never used by real callers.
|
|
74
|
+
"""
|
|
75
|
+
chunks = _split_into_chunks(window)
|
|
76
|
+
auth_header = {"Authorization": f"Bearer {api_key}"}
|
|
77
|
+
|
|
78
|
+
user_totals: Dict[str, UserUsage] = {}
|
|
79
|
+
|
|
80
|
+
for chunk in chunks:
|
|
81
|
+
url = f"https://api.cursor.com/admin/usage?start={chunk.start}&end={chunk.end}"
|
|
82
|
+
raw = fetch_with_retry(TOOL, url, auth_header, **fetch_kwargs)
|
|
83
|
+
users = require_field(raw, "users", TOOL)
|
|
84
|
+
|
|
85
|
+
for raw_user in users:
|
|
86
|
+
normalized = _normalize_user(raw_user)
|
|
87
|
+
existing = user_totals.get(normalized.user_id)
|
|
88
|
+
if existing:
|
|
89
|
+
existing.input_tokens = (existing.input_tokens or 0) + (
|
|
90
|
+
normalized.input_tokens or 0
|
|
91
|
+
)
|
|
92
|
+
existing.output_tokens = (existing.output_tokens or 0) + (
|
|
93
|
+
normalized.output_tokens or 0
|
|
94
|
+
)
|
|
95
|
+
existing.cache_read_tokens = (existing.cache_read_tokens or 0) + (
|
|
96
|
+
normalized.cache_read_tokens or 0
|
|
97
|
+
)
|
|
98
|
+
existing.cache_write_tokens = (existing.cache_write_tokens or 0) + (
|
|
99
|
+
normalized.cache_write_tokens or 0
|
|
100
|
+
)
|
|
101
|
+
existing.requests = (existing.requests or 0) + (normalized.requests or 0)
|
|
102
|
+
existing.cost_usd += normalized.cost_usd
|
|
103
|
+
existing.is_estimated = existing.is_estimated or normalized.is_estimated
|
|
104
|
+
else:
|
|
105
|
+
user_totals[normalized.user_id] = normalized
|
|
106
|
+
|
|
107
|
+
users_list = list(user_totals.values())
|
|
108
|
+
return AdapterResult(
|
|
109
|
+
source=TOOL,
|
|
110
|
+
window=window,
|
|
111
|
+
total_cost_usd=sum_cost(users_list),
|
|
112
|
+
is_estimated=any(u.is_estimated for u in users_list),
|
|
113
|
+
users=users_list,
|
|
114
|
+
)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCode local-file adapter.
|
|
3
|
+
|
|
4
|
+
Ported from src/adapters/opencode.ts.
|
|
5
|
+
|
|
6
|
+
OpenCode (github.com/anomalyco/opencode, formerly sst/opencode -- the GitHub
|
|
7
|
+
org renamed and the old slug now redirects) has no admin/team/billing API at
|
|
8
|
+
all -- confirmed against its own README, which describes only a local CLI
|
|
9
|
+
with no organization-level usage endpoint. Every session/message is written
|
|
10
|
+
to local JSON files under
|
|
11
|
+
`$OPENCODE_DATA_DIR/storage/message/{sessionID}/msg_{messageID}.json`
|
|
12
|
+
(default data dir: `~/.local/share/opencode`, including on Windows --
|
|
13
|
+
OpenCode follows the XDG layout on every platform). Verified two ways:
|
|
14
|
+
ccusage's own OpenCode data-source guide (ccusage.com/guide/opencode/,
|
|
15
|
+
already this codebase's cited prior art for adapter quirks) documents this
|
|
16
|
+
exact path and the `OPENCODE_DATA_DIR` override; tokscale's README
|
|
17
|
+
(github.com/junhoyeo/tokscale) independently lists the same
|
|
18
|
+
`~/.local/share/opencode/storage/message/` path as its "legacy/unmigrated"
|
|
19
|
+
fallback alongside a newer SQLite `opencode.db` (v1.2+) that tokscale also
|
|
20
|
+
reads but this adapter does not -- this package has zero runtime
|
|
21
|
+
dependencies by design, and a SQLite reader isn't worth adding against that
|
|
22
|
+
constraint, so only the plain-JSON message-file format is supported for now.
|
|
23
|
+
|
|
24
|
+
The per-message field shapes handled here (tokens.input/output/reasoning,
|
|
25
|
+
tokens.cache.read/write, cost, modelID, providerID) are taken verbatim from
|
|
26
|
+
OpenCode's own generated SDK types
|
|
27
|
+
(packages/sdk/js/src/gen/types.gen.ts, dev branch, AssistantMessage).
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import getpass
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
from ..errors import DataUnavailableError
|
|
39
|
+
from ..types import AdapterResult, DateWindow, SessionUsage, UserUsage, sum_cost
|
|
40
|
+
|
|
41
|
+
TOOL = "opencode"
|
|
42
|
+
OPENCODE_DATA_DIR_ENV = "OPENCODE_DATA_DIR"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_opencode_data_dirs(env: Optional[Dict[str, str]] = None) -> List[str]:
|
|
46
|
+
"""
|
|
47
|
+
Resolves the directories to scan for local OpenCode message logs.
|
|
48
|
+
`OPENCODE_DATA_DIR` may name a single directory or a comma-separated
|
|
49
|
+
list (ccusage's own adapter supports the same override, for the same
|
|
50
|
+
reason: OpenCode can be pointed at more than one data root). Falls back
|
|
51
|
+
to the documented default, `~/.local/share/opencode` -- including on
|
|
52
|
+
Windows, where OpenCode still uses the XDG-style relative path under
|
|
53
|
+
`%USERPROFILE%`.
|
|
54
|
+
"""
|
|
55
|
+
if env is None:
|
|
56
|
+
env = dict(os.environ)
|
|
57
|
+
|
|
58
|
+
env_value = env.get(OPENCODE_DATA_DIR_ENV)
|
|
59
|
+
if env_value and env_value.strip():
|
|
60
|
+
return [d.strip() for d in env_value.split(",") if d.strip()]
|
|
61
|
+
|
|
62
|
+
home = env.get("HOME") or env.get("USERPROFILE")
|
|
63
|
+
if not home:
|
|
64
|
+
return []
|
|
65
|
+
return [str(Path(home, ".local", "share", "opencode"))]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _window_bounds_ms(window: DateWindow) -> "tuple[int, int]":
|
|
69
|
+
start = datetime.fromisoformat(f"{window.start}T00:00:00.000+00:00")
|
|
70
|
+
end = datetime.fromisoformat(f"{window.end}T23:59:59.999+00:00")
|
|
71
|
+
return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _list_message_files(data_dir: str) -> List[Path]:
|
|
75
|
+
"""
|
|
76
|
+
Lists every message JSON file under each session subdirectory of
|
|
77
|
+
`<data_dir>/storage/message/`. Missing directories (data dir doesn't
|
|
78
|
+
exist, or a session subdirectory disappeared mid-scan) resolve to an
|
|
79
|
+
empty list rather than raising -- this is local disk state being read
|
|
80
|
+
opportunistically, not a versioned API contract, and a half-written or
|
|
81
|
+
already-cleaned-up session shouldn't fail the whole scan.
|
|
82
|
+
"""
|
|
83
|
+
message_dir = Path(data_dir, "storage", "message")
|
|
84
|
+
if not message_dir.is_dir():
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
files: List[Path] = []
|
|
88
|
+
for session_dir in sorted(message_dir.iterdir()):
|
|
89
|
+
if not session_dir.is_dir():
|
|
90
|
+
continue
|
|
91
|
+
for entry in sorted(session_dir.iterdir()):
|
|
92
|
+
if entry.is_file() and entry.suffix == ".json":
|
|
93
|
+
files.append(entry)
|
|
94
|
+
return files
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_opencode_spend(
|
|
98
|
+
window: DateWindow, data_dirs: Optional[List[str]] = None
|
|
99
|
+
) -> AdapterResult:
|
|
100
|
+
"""
|
|
101
|
+
Fetches local OpenCode spend for the given window by scanning message
|
|
102
|
+
JSON files directly on disk -- there is no admin API to call. Unlike the
|
|
103
|
+
Cursor/Claude Code adapters, this can only ever see the current
|
|
104
|
+
machine's own local usage: OpenCode's message files carry no user/email
|
|
105
|
+
field at all (confirmed against its own SDK types -- it's a
|
|
106
|
+
single-developer local tool with no team concept), so every message
|
|
107
|
+
found is attributed to one synthetic user: the OS account running
|
|
108
|
+
`teamspend`. Rolling up spend across a real team means running
|
|
109
|
+
`teamspend` on each person's machine (or collecting their numbers out of
|
|
110
|
+
band and using the CSV-import fallback).
|
|
111
|
+
|
|
112
|
+
Raises DataUnavailableError when no local message store is found at all
|
|
113
|
+
(missing data dir, or a data dir with no `storage/message` files), so
|
|
114
|
+
the CSV-import fallback engages the same way it does for a tool whose
|
|
115
|
+
live API can't cover part of the window. A data dir that exists but has
|
|
116
|
+
no messages *inside the requested window* is a different, legitimate
|
|
117
|
+
case (an inactive period) and returns a normal empty-users result
|
|
118
|
+
instead.
|
|
119
|
+
"""
|
|
120
|
+
if data_dirs is None:
|
|
121
|
+
data_dirs = resolve_opencode_data_dirs()
|
|
122
|
+
|
|
123
|
+
all_files: List[Path] = []
|
|
124
|
+
for data_dir in data_dirs:
|
|
125
|
+
all_files.extend(_list_message_files(data_dir))
|
|
126
|
+
|
|
127
|
+
if not all_files:
|
|
128
|
+
resolved = ", ".join(data_dirs) if data_dirs else (
|
|
129
|
+
"(no data directory resolved -- set OPENCODE_DATA_DIR or $HOME)"
|
|
130
|
+
)
|
|
131
|
+
raise DataUnavailableError(
|
|
132
|
+
TOOL,
|
|
133
|
+
f"no local OpenCode message logs found under {resolved} -- "
|
|
134
|
+
"opencode has no admin/team API, only local per-machine session logs",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
start_ms, end_ms = _window_bounds_ms(window)
|
|
138
|
+
|
|
139
|
+
input_tokens = 0
|
|
140
|
+
output_tokens = 0
|
|
141
|
+
cache_read_tokens = 0
|
|
142
|
+
cache_write_tokens = 0
|
|
143
|
+
requests = 0
|
|
144
|
+
cost_usd = 0.0
|
|
145
|
+
# Per-session totals, keyed by each message's own sessionID (the same
|
|
146
|
+
# sessionID that names its parent storage/message/{sessionID}/
|
|
147
|
+
# directory). A message with no sessionID still contributes to the flat
|
|
148
|
+
# totals above, just not to this dict.
|
|
149
|
+
session_totals: Dict[str, Dict[str, Any]] = {}
|
|
150
|
+
|
|
151
|
+
for file_path in all_files:
|
|
152
|
+
try:
|
|
153
|
+
raw: Dict[str, Any] = json.loads(file_path.read_text(encoding="utf-8"))
|
|
154
|
+
except (OSError, ValueError):
|
|
155
|
+
# Corrupt or partially-written file (e.g. opencode was mid-write
|
|
156
|
+
# when this ran) -- skip it rather than failing the entire
|
|
157
|
+
# snapshot over one bad file among what can be thousands on an
|
|
158
|
+
# active machine.
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if raw.get("role") != "assistant":
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
created_ms = raw.get("time", {}).get("created") if isinstance(raw.get("time"), dict) else None
|
|
165
|
+
if created_ms is None or created_ms < start_ms or created_ms > end_ms:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
requests += 1
|
|
169
|
+
tokens = raw.get("tokens") or {}
|
|
170
|
+
cache = tokens.get("cache") or {}
|
|
171
|
+
message_input_tokens = tokens.get("input") or 0
|
|
172
|
+
message_output_tokens = tokens.get("output") or 0
|
|
173
|
+
message_cost = raw.get("cost") or 0
|
|
174
|
+
input_tokens += message_input_tokens
|
|
175
|
+
output_tokens += message_output_tokens
|
|
176
|
+
cache_read_tokens += cache.get("read") or 0
|
|
177
|
+
cache_write_tokens += cache.get("write") or 0
|
|
178
|
+
cost_usd += message_cost
|
|
179
|
+
|
|
180
|
+
session_id = raw.get("sessionID")
|
|
181
|
+
if session_id:
|
|
182
|
+
totals = session_totals.setdefault(
|
|
183
|
+
session_id,
|
|
184
|
+
{"cost_usd": 0.0, "input_tokens": 0, "output_tokens": 0, "requests": 0},
|
|
185
|
+
)
|
|
186
|
+
totals["cost_usd"] += message_cost
|
|
187
|
+
totals["input_tokens"] += message_input_tokens
|
|
188
|
+
totals["output_tokens"] += message_output_tokens
|
|
189
|
+
totals["requests"] += 1
|
|
190
|
+
|
|
191
|
+
if requests == 0:
|
|
192
|
+
return AdapterResult(
|
|
193
|
+
source=TOOL, window=window, total_cost_usd=0, is_estimated=False, users=[]
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Only attach `sessions` when at least one message actually carried a
|
|
197
|
+
# sessionID -- an empty list would misleadingly claim "zero sessions"
|
|
198
|
+
# rather than "couldn't group anything."
|
|
199
|
+
sessions: Optional[List[SessionUsage]] = (
|
|
200
|
+
[
|
|
201
|
+
SessionUsage(
|
|
202
|
+
session_id=session_id,
|
|
203
|
+
cost_usd=totals["cost_usd"],
|
|
204
|
+
input_tokens=totals["input_tokens"],
|
|
205
|
+
output_tokens=totals["output_tokens"],
|
|
206
|
+
requests=totals["requests"],
|
|
207
|
+
# Same reasoning as the overall result below: OpenCode has
|
|
208
|
+
# no real cost source of truth, so every session's dollar
|
|
209
|
+
# figure is just as estimated as the aggregate one.
|
|
210
|
+
is_estimated=True,
|
|
211
|
+
)
|
|
212
|
+
for session_id, totals in session_totals.items()
|
|
213
|
+
]
|
|
214
|
+
if session_totals
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
users = [
|
|
219
|
+
UserUsage(
|
|
220
|
+
user_id=getpass.getuser(),
|
|
221
|
+
user_email=None,
|
|
222
|
+
input_tokens=input_tokens,
|
|
223
|
+
output_tokens=output_tokens,
|
|
224
|
+
cache_read_tokens=cache_read_tokens,
|
|
225
|
+
cache_write_tokens=cache_write_tokens,
|
|
226
|
+
requests=requests,
|
|
227
|
+
cost_usd=cost_usd,
|
|
228
|
+
# OpenCode has no admin-billed source of truth for cost the way
|
|
229
|
+
# Cursor/Claude Code do. Per ccusage's own OpenCode guide:
|
|
230
|
+
# "OpenCode stores cost: 0 in message files. Costs are
|
|
231
|
+
# calculated from token counts using LiteLLM pricing."
|
|
232
|
+
# teamspend bundles no per-token pricing table of its own -- one
|
|
233
|
+
# would drift from real vendor prices and would be exactly the
|
|
234
|
+
# kind of guessed data format this adapter is supposed to avoid
|
|
235
|
+
# -- so it only ever sums whatever cost OpenCode itself recorded
|
|
236
|
+
# (frequently $0) and always flags the result as estimated,
|
|
237
|
+
# even on the rare message where that field is genuinely
|
|
238
|
+
# populated.
|
|
239
|
+
is_estimated=True,
|
|
240
|
+
sessions=sessions,
|
|
241
|
+
)
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
return AdapterResult(
|
|
245
|
+
source=TOOL,
|
|
246
|
+
window=window,
|
|
247
|
+
total_cost_usd=sum_cost(users),
|
|
248
|
+
is_estimated=True,
|
|
249
|
+
users=users,
|
|
250
|
+
)
|