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.
@@ -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
+ )