cctally 1.69.0 → 1.69.2
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.
- package/CHANGELOG.md +10 -0
- package/bin/_cctally_dashboard_sources.py +1049 -0
- package/bin/_cctally_source_analytics.py +2050 -0
- package/bin/_lib_dashboard_sources.py +504 -0
- package/bin/_lib_source_analytics.py +1375 -0
- package/package.json +5 -1
|
@@ -0,0 +1,2050 @@
|
|
|
1
|
+
"""SQLite adapter for S3's qualified Codex accounting foundation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
import sys
|
|
11
|
+
from copy import copy
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
from _cctally_core import (
|
|
16
|
+
_command_as_of,
|
|
17
|
+
compute_week_bounds,
|
|
18
|
+
get_week_start_name,
|
|
19
|
+
parse_iso_datetime,
|
|
20
|
+
)
|
|
21
|
+
from _cctally_cache import _codex_provider_roots
|
|
22
|
+
from _lib_quota import build_blocks
|
|
23
|
+
from _cctally_quota import load_codex_quota_observations
|
|
24
|
+
from _lib_source_analytics import (
|
|
25
|
+
AnalyticsWindow,
|
|
26
|
+
QUALIFIED_METADATA_WARNING,
|
|
27
|
+
QualifiedCodexEntry,
|
|
28
|
+
SourceResult,
|
|
29
|
+
build_codex_diff_result,
|
|
30
|
+
build_codex_project_result,
|
|
31
|
+
build_codex_range_result,
|
|
32
|
+
build_codex_report_result,
|
|
33
|
+
build_codex_reuse_result,
|
|
34
|
+
assign_collision_safe_project_labels,
|
|
35
|
+
emitted_project_label,
|
|
36
|
+
opaque_project_key,
|
|
37
|
+
resolve_codex_diff_normalization,
|
|
38
|
+
source_result_wire,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
UTC = dt.timezone.utc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class QualifiedMetadataUnavailable(RuntimeError):
|
|
46
|
+
"""S1-qualified metadata is absent or cannot be truthfully read."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SourceUsageError(ValueError):
|
|
50
|
+
"""A provider-aware request is syntactically or semantically invalid."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_OPAQUE_PROJECT_KEY_RE = re.compile(r"^project:[0-9a-f]{24}$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_QUALIFIED_CODEX_ENTRIES_SQL = """
|
|
57
|
+
SELECT entries.timestamp_utc, entries.session_id, entries.source_path,
|
|
58
|
+
entries.source_root_key,
|
|
59
|
+
entries.conversation_key, entries.model,
|
|
60
|
+
entries.input_tokens, entries.cached_input_tokens,
|
|
61
|
+
entries.output_tokens, entries.reasoning_output_tokens,
|
|
62
|
+
entries.total_tokens, threads.cwd, threads.git_json,
|
|
63
|
+
threads.conversation_key AS joined_conversation_key,
|
|
64
|
+
threads.source_root_key AS joined_source_root_key
|
|
65
|
+
FROM codex_session_entries AS entries
|
|
66
|
+
INDEXED BY idx_codex_entries_ts_root_conversation
|
|
67
|
+
LEFT JOIN codex_conversation_threads AS threads
|
|
68
|
+
ON threads.conversation_key = entries.conversation_key
|
|
69
|
+
AND threads.source_root_key = entries.source_root_key
|
|
70
|
+
WHERE entries.timestamp_utc >= ?
|
|
71
|
+
AND entries.timestamp_utc < ?
|
|
72
|
+
ORDER BY entries.timestamp_utc ASC, entries.source_root_key ASC,
|
|
73
|
+
entries.conversation_key ASC, entries.id ASC
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
78
|
+
SELECT timestamp_utc, source_root_key, conversation_key, model,
|
|
79
|
+
input_tokens, cached_input_tokens, output_tokens,
|
|
80
|
+
reasoning_output_tokens, total_tokens
|
|
81
|
+
FROM codex_session_entries
|
|
82
|
+
WHERE timestamp_utc >= ?
|
|
83
|
+
AND timestamp_utc < ?
|
|
84
|
+
AND source_root_key IS NOT NULL
|
|
85
|
+
ORDER BY timestamp_utc ASC, source_root_key ASC, conversation_key ASC, id ASC
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cctally():
|
|
90
|
+
return sys.modules["cctally"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_timestamp(value: object) -> dt.datetime:
|
|
94
|
+
try:
|
|
95
|
+
timestamp = dt.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
96
|
+
except (TypeError, ValueError) as exc:
|
|
97
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
|
|
98
|
+
if timestamp.tzinfo is None or timestamp.utcoffset() is None:
|
|
99
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable")
|
|
100
|
+
return timestamp.astimezone(UTC)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _project_label(value: object) -> str:
|
|
104
|
+
"""Render only a basename-like label; absolute paths remain internal."""
|
|
105
|
+
raw = str(value).strip()
|
|
106
|
+
normalized = raw.replace("\\", "/")
|
|
107
|
+
if normalized in {"", "/"}:
|
|
108
|
+
return "(root)" if normalized == "/" else "(unassigned)"
|
|
109
|
+
parts = tuple(part for part in normalized.split("/") if part)
|
|
110
|
+
# A cwd that is exactly a user home directory must never turn the local
|
|
111
|
+
# account name into a public project label. Keep the generic token too so
|
|
112
|
+
# it remains safe for fixture homes and Windows rollouts.
|
|
113
|
+
if (
|
|
114
|
+
len(parts) == 2 and parts[0] in {"Users", "home"}
|
|
115
|
+
) or (
|
|
116
|
+
len(parts) == 3 and parts[-2] in {"Users", "home"}
|
|
117
|
+
):
|
|
118
|
+
return "(home)"
|
|
119
|
+
return parts[-1] if parts else "(unassigned)"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _git_resolved_key(value: object) -> str | None:
|
|
123
|
+
"""Return a non-renderable identity for valid S1 git metadata."""
|
|
124
|
+
if not isinstance(value, str) or not value:
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
decoded = json.loads(value)
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
return None
|
|
130
|
+
if not isinstance(decoded, dict) or not decoded:
|
|
131
|
+
return None
|
|
132
|
+
canonical = json.dumps(decoded, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
133
|
+
return "git:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _require_joined_metadata(row: sqlite3.Row) -> tuple[str, str]:
|
|
137
|
+
root_key = row["source_root_key"]
|
|
138
|
+
conversation_key = row["conversation_key"]
|
|
139
|
+
if (
|
|
140
|
+
not isinstance(root_key, str) or not root_key
|
|
141
|
+
or not isinstance(conversation_key, str) or not conversation_key
|
|
142
|
+
or row["joined_conversation_key"] != conversation_key
|
|
143
|
+
or row["joined_source_root_key"] != root_key
|
|
144
|
+
):
|
|
145
|
+
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
|
|
146
|
+
return root_key, conversation_key
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_qualified_codex_entries(
|
|
150
|
+
start: dt.datetime,
|
|
151
|
+
end: dt.datetime,
|
|
152
|
+
*,
|
|
153
|
+
speed: str,
|
|
154
|
+
sync: bool = True,
|
|
155
|
+
group: str = "git-root",
|
|
156
|
+
cache_conn: sqlite3.Connection | None = None,
|
|
157
|
+
) -> tuple[QualifiedCodexEntry, ...]:
|
|
158
|
+
"""Load exactly one bounded, root-qualified Codex accounting read.
|
|
159
|
+
|
|
160
|
+
The S1 cache is the sole metadata source. Unlike unqualified Codex
|
|
161
|
+
accounting readers, this adapter deliberately has no direct-rollout fallback:
|
|
162
|
+
without the relational conversation join it cannot safely attribute projects.
|
|
163
|
+
"""
|
|
164
|
+
if start.tzinfo is None or start.utcoffset() is None:
|
|
165
|
+
raise ValueError("start must be timezone-aware")
|
|
166
|
+
if end.tzinfo is None or end.utcoffset() is None:
|
|
167
|
+
raise ValueError("end must be timezone-aware")
|
|
168
|
+
if end <= start:
|
|
169
|
+
raise ValueError("end must be after start")
|
|
170
|
+
|
|
171
|
+
if cache_conn is not None and sync:
|
|
172
|
+
raise ValueError("cache_conn requires sync=False")
|
|
173
|
+
|
|
174
|
+
c = _cctally()
|
|
175
|
+
owns_conn = cache_conn is None
|
|
176
|
+
if owns_conn:
|
|
177
|
+
try:
|
|
178
|
+
conn = c.open_cache_db()
|
|
179
|
+
except (OSError, sqlite3.Error) as exc:
|
|
180
|
+
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable") from exc
|
|
181
|
+
else:
|
|
182
|
+
conn = cache_conn
|
|
183
|
+
previous_row_factory = conn.row_factory
|
|
184
|
+
try:
|
|
185
|
+
if sync:
|
|
186
|
+
stats = c.sync_codex_cache(conn)
|
|
187
|
+
if stats.lock_contended:
|
|
188
|
+
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable")
|
|
189
|
+
conn.row_factory = sqlite3.Row
|
|
190
|
+
rows = tuple(conn.execute(
|
|
191
|
+
_QUALIFIED_CODEX_ENTRIES_SQL,
|
|
192
|
+
(start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat()),
|
|
193
|
+
))
|
|
194
|
+
except sqlite3.Error as exc:
|
|
195
|
+
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable") from exc
|
|
196
|
+
finally:
|
|
197
|
+
if owns_conn:
|
|
198
|
+
conn.close()
|
|
199
|
+
else:
|
|
200
|
+
conn.row_factory = previous_row_factory
|
|
201
|
+
|
|
202
|
+
resolver_cache: dict[object, object] = {}
|
|
203
|
+
resolved_by_cwd: dict[str, object] = {}
|
|
204
|
+
resolved_by_git_json: dict[str, str | None] = {}
|
|
205
|
+
result: list[QualifiedCodexEntry] = []
|
|
206
|
+
for row in rows:
|
|
207
|
+
root_key, conversation_key = _require_joined_metadata(row)
|
|
208
|
+
cwd = row["cwd"]
|
|
209
|
+
if isinstance(cwd, str) and cwd:
|
|
210
|
+
project = resolved_by_cwd.get(cwd)
|
|
211
|
+
if project is None:
|
|
212
|
+
project = c._resolve_project_key(cwd, group, resolver_cache)
|
|
213
|
+
resolved_by_cwd[cwd] = project
|
|
214
|
+
resolved_key = project.bucket_path
|
|
215
|
+
cwd_label = _project_label(cwd)
|
|
216
|
+
project_label = (
|
|
217
|
+
cwd_label if cwd_label in {"(home)", "(root)"}
|
|
218
|
+
else _project_label(project.display_key)
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
git_json = row["git_json"]
|
|
222
|
+
if isinstance(git_json, str) and git_json not in resolved_by_git_json:
|
|
223
|
+
resolved_by_git_json[git_json] = _git_resolved_key(git_json)
|
|
224
|
+
git_key = resolved_by_git_json.get(git_json) if isinstance(git_json, str) else None
|
|
225
|
+
if git_key is None:
|
|
226
|
+
resolved_key = "(unassigned)"
|
|
227
|
+
project_label = "(unassigned)"
|
|
228
|
+
else:
|
|
229
|
+
resolved_key = git_key
|
|
230
|
+
project_label = "Git project"
|
|
231
|
+
try:
|
|
232
|
+
cost_usd = c._calculate_codex_entry_cost(
|
|
233
|
+
str(row["model"]), int(row["input_tokens"]),
|
|
234
|
+
int(row["cached_input_tokens"]), int(row["output_tokens"]),
|
|
235
|
+
int(row["reasoning_output_tokens"]), speed=speed,
|
|
236
|
+
)
|
|
237
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
238
|
+
raise QualifiedMetadataUnavailable("Codex qualified accounting is unavailable") from exc
|
|
239
|
+
result.append(QualifiedCodexEntry(
|
|
240
|
+
timestamp=_parse_timestamp(row["timestamp_utc"]),
|
|
241
|
+
session_id=str(row["session_id"] or ""),
|
|
242
|
+
source_path=str(row["source_path"] or ""),
|
|
243
|
+
source_root_key=root_key,
|
|
244
|
+
conversation_key=conversation_key,
|
|
245
|
+
project_key=opaque_project_key("codex", root_key, resolved_key),
|
|
246
|
+
project_label=project_label,
|
|
247
|
+
model=str(row["model"]),
|
|
248
|
+
input_tokens=int(row["input_tokens"]),
|
|
249
|
+
cached_input_tokens=int(row["cached_input_tokens"]),
|
|
250
|
+
output_tokens=int(row["output_tokens"]),
|
|
251
|
+
reasoning_output_tokens=int(row["reasoning_output_tokens"]),
|
|
252
|
+
total_tokens=int(row["total_tokens"]),
|
|
253
|
+
cost_usd=cost_usd,
|
|
254
|
+
))
|
|
255
|
+
return tuple(result)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def load_codex_accounting_entries(
|
|
259
|
+
start: dt.datetime, end: dt.datetime, *, speed: str, sync: bool = True,
|
|
260
|
+
force_direct: bool = False,
|
|
261
|
+
) -> tuple[QualifiedCodexEntry, ...]:
|
|
262
|
+
"""Load accounting through the canonical cache-first Codex reader.
|
|
263
|
+
|
|
264
|
+
Unlike qualified project attribution, accounting remains useful when the
|
|
265
|
+
S1 cache is unavailable or its ingest lock is contended. The established
|
|
266
|
+
reader owns that cache/direct-JSONL fallback; this adapter only converts its
|
|
267
|
+
native accounting objects to the provider result contract. It never reads
|
|
268
|
+
rollout metadata for a project identity.
|
|
269
|
+
"""
|
|
270
|
+
if start.tzinfo is None or start.utcoffset() is None:
|
|
271
|
+
raise ValueError("start must be timezone-aware")
|
|
272
|
+
if end.tzinfo is None or end.utcoffset() is None:
|
|
273
|
+
raise ValueError("end must be timezone-aware")
|
|
274
|
+
if end <= start:
|
|
275
|
+
raise ValueError("end must be after start")
|
|
276
|
+
c = _cctally()
|
|
277
|
+
try:
|
|
278
|
+
entries = (
|
|
279
|
+
c._collect_codex_entries_direct(start, end)
|
|
280
|
+
if force_direct else c.get_codex_entries(start, end, skip_sync=not sync)
|
|
281
|
+
)
|
|
282
|
+
except (OSError, sqlite3.Error, ValueError) as exc:
|
|
283
|
+
raise RuntimeError("Codex accounting is unavailable") from exc
|
|
284
|
+
|
|
285
|
+
roots = tuple(_codex_provider_roots())
|
|
286
|
+
|
|
287
|
+
def root_key_for_path(source_path: object) -> str:
|
|
288
|
+
path = pathlib.Path(str(source_path)).expanduser()
|
|
289
|
+
try:
|
|
290
|
+
resolved = path.resolve()
|
|
291
|
+
except OSError:
|
|
292
|
+
resolved = path.absolute()
|
|
293
|
+
for root in roots:
|
|
294
|
+
try:
|
|
295
|
+
resolved.relative_to(root.provider_root)
|
|
296
|
+
except ValueError:
|
|
297
|
+
continue
|
|
298
|
+
return root.source_root_key
|
|
299
|
+
return "direct:" + hashlib.sha256(str(resolved.parent).encode("utf-8")).hexdigest()[:24]
|
|
300
|
+
|
|
301
|
+
result: list[QualifiedCodexEntry] = []
|
|
302
|
+
for entry in entries:
|
|
303
|
+
timestamp = getattr(entry, "timestamp", None)
|
|
304
|
+
if not isinstance(timestamp, dt.datetime):
|
|
305
|
+
continue
|
|
306
|
+
timestamp = _parse_timestamp(timestamp.isoformat())
|
|
307
|
+
if not start <= timestamp < end:
|
|
308
|
+
continue
|
|
309
|
+
source_path = str(getattr(entry, "source_path", ""))
|
|
310
|
+
root_key = root_key_for_path(source_path)
|
|
311
|
+
try:
|
|
312
|
+
cost_usd = c._calculate_codex_entry_cost(
|
|
313
|
+
str(entry.model), int(entry.input_tokens),
|
|
314
|
+
int(entry.cached_input_tokens), int(entry.output_tokens),
|
|
315
|
+
int(entry.reasoning_output_tokens), speed=speed,
|
|
316
|
+
)
|
|
317
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
318
|
+
raise RuntimeError("Codex accounting is unavailable") from exc
|
|
319
|
+
native_session = str(getattr(entry, "session_id", "") or "accounting")
|
|
320
|
+
conversation_key = "accounting:" + hashlib.sha256(
|
|
321
|
+
f"{root_key}\0{native_session}\0{source_path}".encode("utf-8")
|
|
322
|
+
).hexdigest()[:24]
|
|
323
|
+
result.append(QualifiedCodexEntry(
|
|
324
|
+
timestamp=timestamp,
|
|
325
|
+
session_id=native_session,
|
|
326
|
+
source_path=source_path,
|
|
327
|
+
source_root_key=root_key,
|
|
328
|
+
conversation_key=conversation_key,
|
|
329
|
+
project_key=opaque_project_key("codex", root_key, "(unassigned)"),
|
|
330
|
+
project_label="(unassigned)",
|
|
331
|
+
model=str(entry.model),
|
|
332
|
+
input_tokens=int(entry.input_tokens),
|
|
333
|
+
cached_input_tokens=int(entry.cached_input_tokens),
|
|
334
|
+
output_tokens=int(entry.output_tokens),
|
|
335
|
+
reasoning_output_tokens=int(entry.reasoning_output_tokens),
|
|
336
|
+
total_tokens=int(entry.total_tokens),
|
|
337
|
+
cost_usd=cost_usd,
|
|
338
|
+
))
|
|
339
|
+
return tuple(result)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _identity_sort_key(identity: object) -> tuple[str, str, str, str, int]:
|
|
343
|
+
return (
|
|
344
|
+
str(identity.source), str(identity.source_root_key),
|
|
345
|
+
str(identity.logical_limit_key), str(identity.observed_slot),
|
|
346
|
+
int(identity.window_minutes),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def select_codex_report_blocks(blocks: Iterable[object], *, weeks: int) -> tuple[object, ...]:
|
|
351
|
+
"""Select newest blocks per full native identity, never by display slot."""
|
|
352
|
+
if not isinstance(weeks, int) or isinstance(weeks, bool) or weeks <= 0:
|
|
353
|
+
raise ValueError("weeks must be a positive integer")
|
|
354
|
+
by_identity: dict[object, list[object]] = defaultdict(list)
|
|
355
|
+
for block in blocks:
|
|
356
|
+
by_identity[block.identity].append(block)
|
|
357
|
+
selected: list[object] = []
|
|
358
|
+
for identity in sorted(by_identity, key=_identity_sort_key):
|
|
359
|
+
newest = sorted(
|
|
360
|
+
by_identity[identity],
|
|
361
|
+
key=lambda block: (block.resets_at, block.nominal_start_at),
|
|
362
|
+
reverse=True,
|
|
363
|
+
)[:weeks]
|
|
364
|
+
selected.extend(sorted(newest, key=lambda block: (block.resets_at, block.nominal_start_at)))
|
|
365
|
+
return tuple(selected)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def select_codex_project_blocks(
|
|
369
|
+
blocks: Iterable[object], *, range_start: dt.datetime, range_end: dt.datetime,
|
|
370
|
+
as_of: dt.datetime,
|
|
371
|
+
) -> tuple[object, ...]:
|
|
372
|
+
"""Keep only native blocks that overlap one requested project interval.
|
|
373
|
+
|
|
374
|
+
Project attribution is root- and logical-limit-qualified. A block is
|
|
375
|
+
applicable only when its observed native interval overlaps the requested
|
|
376
|
+
accounting interval; adjacent or future blocks must not create a fictional
|
|
377
|
+
quota child on a project row.
|
|
378
|
+
"""
|
|
379
|
+
start = range_start.astimezone(UTC)
|
|
380
|
+
end = range_end.astimezone(UTC)
|
|
381
|
+
cutoff = as_of.astimezone(UTC)
|
|
382
|
+
selected: list[object] = []
|
|
383
|
+
for block in blocks:
|
|
384
|
+
identity = getattr(block, "identity", None)
|
|
385
|
+
if getattr(identity, "source", None) != "codex":
|
|
386
|
+
continue
|
|
387
|
+
nominal_start = getattr(block, "nominal_start_at", None)
|
|
388
|
+
resets_at = getattr(block, "resets_at", None)
|
|
389
|
+
if not isinstance(nominal_start, dt.datetime) or not isinstance(resets_at, dt.datetime):
|
|
390
|
+
continue
|
|
391
|
+
if nominal_start.tzinfo is None or resets_at.tzinfo is None:
|
|
392
|
+
continue
|
|
393
|
+
block_start = nominal_start.astimezone(UTC)
|
|
394
|
+
block_end = min(cutoff, resets_at.astimezone(UTC))
|
|
395
|
+
if block_start < end and block_end > start:
|
|
396
|
+
selected.append(block)
|
|
397
|
+
return tuple(sorted(
|
|
398
|
+
selected,
|
|
399
|
+
key=lambda block: (
|
|
400
|
+
_identity_sort_key(block.identity), block.resets_at, block.nominal_start_at,
|
|
401
|
+
),
|
|
402
|
+
))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _arg_datetime(args: object, primary: str, fallback: str) -> dt.datetime:
|
|
406
|
+
value = getattr(args, primary, getattr(args, fallback, None))
|
|
407
|
+
if not isinstance(value, dt.datetime):
|
|
408
|
+
raise ValueError(f"{primary} must be a resolved datetime")
|
|
409
|
+
return value
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _source_config_and_tz(args: object) -> tuple[dict, object]:
|
|
413
|
+
"""Resolve the display configuration once for a source-aware command."""
|
|
414
|
+
c = _cctally()
|
|
415
|
+
config = c._load_claude_config_for_args(args)
|
|
416
|
+
c._bridge_z_into_tz(args, config)
|
|
417
|
+
tz = c.resolve_display_tz(args, config)
|
|
418
|
+
args._resolved_tz = tz
|
|
419
|
+
return config, tz
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _source_date_range(args: object, *, config: dict, tz: object) -> tuple[dt.datetime, dt.datetime]:
|
|
423
|
+
"""Resolve shared project date flags to the source path's half-open range."""
|
|
424
|
+
c = _cctally()
|
|
425
|
+
parsed = c._parse_cli_date_range(args, now_utc=_command_as_of())
|
|
426
|
+
if isinstance(parsed, int):
|
|
427
|
+
raise ValueError("invalid date range")
|
|
428
|
+
start, end = parsed
|
|
429
|
+
if getattr(args, "until", None) and not any(
|
|
430
|
+
marker in args.until for marker in ("T", "+", "Z")
|
|
431
|
+
):
|
|
432
|
+
# The historical helper supplies the final microsecond of a civil
|
|
433
|
+
# date. Provider reads are half-open, so advance to the next midnight.
|
|
434
|
+
end += dt.timedelta(microseconds=1)
|
|
435
|
+
return start.astimezone(UTC), end.astimezone(UTC)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _resolve_source_project_range(args: object) -> tuple[dt.datetime, dt.datetime]:
|
|
439
|
+
if isinstance(getattr(args, "range_start", None), dt.datetime):
|
|
440
|
+
return _arg_datetime(args, "range_start", "start"), _arg_datetime(args, "range_end", "end")
|
|
441
|
+
c = _cctally()
|
|
442
|
+
config, tz = _source_config_and_tz(args)
|
|
443
|
+
weeks = getattr(args, "weeks", None)
|
|
444
|
+
if weeks is not None and weeks < 1:
|
|
445
|
+
raise ValueError("--weeks must be >= 1")
|
|
446
|
+
if weeks is not None and (getattr(args, "since", None) or getattr(args, "until", None)):
|
|
447
|
+
raise ValueError("--weeks cannot be combined with --since/--until")
|
|
448
|
+
if getattr(args, "since", None) or getattr(args, "until", None):
|
|
449
|
+
start, end = _source_date_range(args, config=config, tz=tz)
|
|
450
|
+
else:
|
|
451
|
+
now = _command_as_of()
|
|
452
|
+
local_now = now.astimezone(tz) if tz is not None else now.astimezone()
|
|
453
|
+
week_start, _ = compute_week_bounds(
|
|
454
|
+
local_now, get_week_start_name(config),
|
|
455
|
+
)
|
|
456
|
+
start = dt.datetime.combine(week_start, dt.time.min, tzinfo=local_now.tzinfo)
|
|
457
|
+
if weeks is not None:
|
|
458
|
+
start -= dt.timedelta(days=7 * (weeks - 1))
|
|
459
|
+
end = now
|
|
460
|
+
args.range_start, args.range_end = start.astimezone(UTC), end.astimezone(UTC)
|
|
461
|
+
args.as_of = end.astimezone(UTC)
|
|
462
|
+
return args.range_start, args.range_end
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _resolve_source_range_cost_range(args: object) -> tuple[dt.datetime, dt.datetime]:
|
|
466
|
+
if isinstance(getattr(args, "start", None), dt.datetime):
|
|
467
|
+
return _arg_datetime(args, "start", "range_start"), _arg_datetime(args, "end", "range_end")
|
|
468
|
+
c = _cctally()
|
|
469
|
+
start = parse_iso_datetime(args.start, "--start")
|
|
470
|
+
end = parse_iso_datetime(args.end, "--end") if getattr(args, "end", None) else _command_as_of()
|
|
471
|
+
if end < start:
|
|
472
|
+
raise ValueError("--end must be after --start")
|
|
473
|
+
args.start, args.end = start.astimezone(UTC), end.astimezone(UTC)
|
|
474
|
+
return args.start, args.end
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _resolve_source_cache_range(args: object) -> tuple[dt.datetime, dt.datetime]:
|
|
478
|
+
if isinstance(getattr(args, "start", None), dt.datetime):
|
|
479
|
+
return _arg_datetime(args, "start", "range_start"), _arg_datetime(args, "end", "range_end")
|
|
480
|
+
c = _cctally()
|
|
481
|
+
_config, tz = _source_config_and_tz(args)
|
|
482
|
+
start, end = c._resolve_cache_report_window(
|
|
483
|
+
args, now_utc=_command_as_of(), tz_name=(tz.key if tz is not None else None),
|
|
484
|
+
)
|
|
485
|
+
if getattr(args, "until", None) and not any(
|
|
486
|
+
marker in args.until for marker in ("T", "+", "Z")
|
|
487
|
+
):
|
|
488
|
+
end += dt.timedelta(microseconds=1)
|
|
489
|
+
args.start, args.end = start.astimezone(UTC), end.astimezone(UTC)
|
|
490
|
+
return args.start, args.end
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _resolve_source_diff_windows(args: object) -> tuple[AnalyticsWindow, AnalyticsWindow]:
|
|
494
|
+
if isinstance(getattr(args, "window_a", None), AnalyticsWindow):
|
|
495
|
+
return args.window_a, args.window_b
|
|
496
|
+
c = _cctally()
|
|
497
|
+
config, tz = _source_config_and_tz(args)
|
|
498
|
+
now = _command_as_of()
|
|
499
|
+
tz_name = tz.key if tz is not None else c._local_tz_name()
|
|
500
|
+
|
|
501
|
+
def resolve(token: str) -> AnalyticsWindow:
|
|
502
|
+
match = c._DIFF_NW_AGO_RE.match(token)
|
|
503
|
+
if token in {"this-week", "last-week"} or match:
|
|
504
|
+
local_now = now.astimezone(tz) if tz is not None else now.astimezone()
|
|
505
|
+
current_start, _ = compute_week_bounds(
|
|
506
|
+
local_now, get_week_start_name(config),
|
|
507
|
+
)
|
|
508
|
+
start = dt.datetime.combine(current_start, dt.time.min, tzinfo=local_now.tzinfo)
|
|
509
|
+
if token == "this-week":
|
|
510
|
+
end = now
|
|
511
|
+
else:
|
|
512
|
+
weeks_back = 1 if token == "last-week" else int(match.group(1))
|
|
513
|
+
start -= dt.timedelta(days=7 * weeks_back)
|
|
514
|
+
end = start + dt.timedelta(days=7)
|
|
515
|
+
return AnalyticsWindow(token, "week", start, end)
|
|
516
|
+
parsed = c._parse_diff_window(
|
|
517
|
+
token, now_utc=now, anchor_resets_at=None, anchor_week_start=None,
|
|
518
|
+
tz_name=tz_name,
|
|
519
|
+
)
|
|
520
|
+
return AnalyticsWindow(parsed.label, parsed.kind, parsed.start_utc, parsed.end_utc)
|
|
521
|
+
|
|
522
|
+
args.window_a, args.window_b = resolve(args.a), resolve(args.b)
|
|
523
|
+
return args.window_a, args.window_b
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _resolve_source_report_as_of(args: object) -> dt.datetime:
|
|
527
|
+
as_of = getattr(args, "as_of", None)
|
|
528
|
+
if isinstance(as_of, dt.datetime):
|
|
529
|
+
return as_of
|
|
530
|
+
args.as_of = _command_as_of().astimezone(UTC)
|
|
531
|
+
return args.as_of
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _source_entries(
|
|
535
|
+
args: object, start: dt.datetime, end: dt.datetime, *, qualified: bool,
|
|
536
|
+
inclusive_end: bool = False, group: str = "git-root", sync: bool | None = None,
|
|
537
|
+
force_direct: bool = False,
|
|
538
|
+
) -> tuple[QualifiedCodexEntry, ...]:
|
|
539
|
+
provided = getattr(args, "source_entries", None)
|
|
540
|
+
if provided is not None:
|
|
541
|
+
values = tuple(provided)
|
|
542
|
+
return assign_collision_safe_project_labels(values) if qualified else values
|
|
543
|
+
speed = _cctally()._resolve_codex_speed(str(getattr(args, "speed", "auto")))
|
|
544
|
+
if sync is None:
|
|
545
|
+
sync = bool(getattr(args, "_source_analytics_sync", not bool(getattr(args, "offline", False))))
|
|
546
|
+
query_end = end + dt.timedelta(microseconds=1) if inclusive_end else end
|
|
547
|
+
if qualified:
|
|
548
|
+
values = load_qualified_codex_entries(
|
|
549
|
+
start, query_end, speed=speed, sync=sync, group=group,
|
|
550
|
+
)
|
|
551
|
+
return assign_collision_safe_project_labels(values)
|
|
552
|
+
return load_codex_accounting_entries(
|
|
553
|
+
start, query_end, speed=speed, sync=sync, force_direct=force_direct,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _project_selectors(value: object) -> tuple[str, ...]:
|
|
558
|
+
if value is None:
|
|
559
|
+
return ()
|
|
560
|
+
raw = value if isinstance(value, (list, tuple)) else (value,)
|
|
561
|
+
selectors: list[str] = []
|
|
562
|
+
for item in raw:
|
|
563
|
+
if not isinstance(item, str) or not item:
|
|
564
|
+
raise SourceUsageError("--project must be a non-empty project key or display label")
|
|
565
|
+
selectors.append(item)
|
|
566
|
+
return tuple(selectors)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def validate_source_project_selectors(value: object) -> None:
|
|
570
|
+
"""Reject malformed opaque keys before a source cache read occurs."""
|
|
571
|
+
for selector in _project_selectors(value):
|
|
572
|
+
if selector.startswith("project:") and not _OPAQUE_PROJECT_KEY_RE.fullmatch(selector):
|
|
573
|
+
raise SourceUsageError(
|
|
574
|
+
"invalid opaque project key; expected project:<24 lowercase hex characters>"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _filter_project_entries(
|
|
579
|
+
entries: Iterable[QualifiedCodexEntry], value: object,
|
|
580
|
+
) -> tuple[QualifiedCodexEntry, ...]:
|
|
581
|
+
"""Apply exact opaque-key or unique display-label project selectors."""
|
|
582
|
+
selectors = _project_selectors(value)
|
|
583
|
+
values = tuple(entries)
|
|
584
|
+
if not selectors:
|
|
585
|
+
return values
|
|
586
|
+
validate_source_project_selectors(selectors)
|
|
587
|
+
by_emitted_label: dict[str, set[str]] = defaultdict(set)
|
|
588
|
+
by_raw_label: dict[str, set[str]] = defaultdict(set)
|
|
589
|
+
keys = {entry.project_key for entry in values}
|
|
590
|
+
for entry in values:
|
|
591
|
+
by_emitted_label[emitted_project_label(entry)].add(entry.project_key)
|
|
592
|
+
by_raw_label[entry.project_label].add(entry.project_key)
|
|
593
|
+
selected: set[str] = set()
|
|
594
|
+
for selector in selectors:
|
|
595
|
+
if selector.startswith("project:"):
|
|
596
|
+
if selector in keys:
|
|
597
|
+
selected.add(selector)
|
|
598
|
+
continue
|
|
599
|
+
# Exact emitted collision-safe labels win over raw labels so each
|
|
600
|
+
# label JSON, terminal, and share expose round-trips as a selector.
|
|
601
|
+
matches = by_emitted_label.get(selector)
|
|
602
|
+
if matches is None:
|
|
603
|
+
matches = by_raw_label.get(selector, set())
|
|
604
|
+
if len(matches) > 1:
|
|
605
|
+
raise SourceUsageError(
|
|
606
|
+
f"--project display label {selector!r} is ambiguous; use an exact projectKey"
|
|
607
|
+
)
|
|
608
|
+
selected.update(matches)
|
|
609
|
+
return tuple(entry for entry in values if entry.project_key in selected)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _filter_model_entries(
|
|
613
|
+
entries: Iterable[QualifiedCodexEntry], value: object,
|
|
614
|
+
) -> tuple[QualifiedCodexEntry, ...]:
|
|
615
|
+
patterns = value if isinstance(value, (list, tuple)) else (() if value is None else (value,))
|
|
616
|
+
normalized = tuple(str(pattern).lower() for pattern in patterns if str(pattern))
|
|
617
|
+
if not normalized:
|
|
618
|
+
return tuple(entries)
|
|
619
|
+
return tuple(
|
|
620
|
+
entry for entry in entries
|
|
621
|
+
if any(pattern in entry.model.lower() for pattern in normalized)
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
_CLAUDE_DIFF_SECTIONS = frozenset({"overall", "models", "projects", "cache"})
|
|
626
|
+
_CODEX_DIFF_SECTIONS = frozenset({"overall", "models", "projects", "token-reuse"})
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _diff_only_for_provider(value: object, provider: str) -> str | None:
|
|
630
|
+
"""Keep each all-source diff leg inside its own section vocabulary."""
|
|
631
|
+
if value is None:
|
|
632
|
+
return None
|
|
633
|
+
selected = [item.strip() for item in str(value).split(",") if item.strip()]
|
|
634
|
+
supported = _CLAUDE_DIFF_SECTIONS if provider == "claude" else _CODEX_DIFF_SECTIONS
|
|
635
|
+
return ",".join(item for item in selected if item in supported)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _validate_source_diff_controls(
|
|
639
|
+
args: object, window_a: AnalyticsWindow, window_b: AnalyticsWindow,
|
|
640
|
+
) -> str:
|
|
641
|
+
"""Reject invalid source-aware diff controls before either provider starts."""
|
|
642
|
+
source = getattr(args, "source", "codex")
|
|
643
|
+
selected = [item.strip() for item in str(getattr(args, "only", "")).split(",") if item.strip()]
|
|
644
|
+
if getattr(args, "only", None) is not None:
|
|
645
|
+
if not selected:
|
|
646
|
+
raise SourceUsageError("diff: --only specified no sections")
|
|
647
|
+
supported = _CODEX_DIFF_SECTIONS if source == "codex" else (_CLAUDE_DIFF_SECTIONS | _CODEX_DIFF_SECTIONS)
|
|
648
|
+
unknown = [item for item in selected if item not in supported]
|
|
649
|
+
if unknown:
|
|
650
|
+
raise SourceUsageError(
|
|
651
|
+
"diff: --only contains unknown section(s): " + ", ".join(unknown)
|
|
652
|
+
)
|
|
653
|
+
for extra in (item.strip() for item in str(getattr(args, "with_extra", "") or "").split(",")):
|
|
654
|
+
if extra in {"trend", "time"}:
|
|
655
|
+
raise RuntimeError(
|
|
656
|
+
f"diff: --with {extra} is not yet implemented (deferred to v1.1)"
|
|
657
|
+
)
|
|
658
|
+
return resolve_codex_diff_normalization(
|
|
659
|
+
window_a, window_b, allow_mismatch=bool(getattr(args, "allow_mismatch", False)),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _iso_z(value: dt.datetime) -> str:
|
|
664
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
_CLAUDE_COMMANDS = {
|
|
668
|
+
"project": "_cmd_project_claude",
|
|
669
|
+
"diff": "_cmd_diff_claude",
|
|
670
|
+
"range-cost": "_cmd_range_cost_claude",
|
|
671
|
+
"cache-report": "_cmd_cache_report_claude",
|
|
672
|
+
"report": "_cmd_report_claude",
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _claude_result_status(command: str, payload: dict[str, object]) -> str:
|
|
677
|
+
"""Classify an established Claude JSON payload without reshaping it."""
|
|
678
|
+
if command == "project":
|
|
679
|
+
return "ok" if payload.get("projects") else "empty"
|
|
680
|
+
if command == "range-cost":
|
|
681
|
+
return "ok" if int(payload.get("matchedEntries", 0)) else "empty"
|
|
682
|
+
if command == "cache-report":
|
|
683
|
+
rows = payload.get("sessions", payload.get("days", ()))
|
|
684
|
+
return "ok" if rows else "empty"
|
|
685
|
+
if command == "report":
|
|
686
|
+
return "ok" if payload.get("trend") else "empty"
|
|
687
|
+
if command == "diff":
|
|
688
|
+
for section in payload.get("sections", ()):
|
|
689
|
+
if isinstance(section, dict) and section.get("name") == "overall":
|
|
690
|
+
rows = section.get("rows", ())
|
|
691
|
+
if not rows:
|
|
692
|
+
return "empty"
|
|
693
|
+
row = rows[0] if isinstance(rows, list) else None
|
|
694
|
+
if not isinstance(row, dict):
|
|
695
|
+
return "empty"
|
|
696
|
+
for side in (row.get("a"), row.get("b")):
|
|
697
|
+
if isinstance(side, dict) and any(
|
|
698
|
+
side.get(name, 0) for name in (
|
|
699
|
+
"cost_usd", "tokens_input", "tokens_output",
|
|
700
|
+
"tokens_cache_read", "tokens_cache_write",
|
|
701
|
+
)
|
|
702
|
+
):
|
|
703
|
+
return "ok"
|
|
704
|
+
return "empty"
|
|
705
|
+
return "empty"
|
|
706
|
+
raise ValueError(f"unknown Claude analytics command: {command}")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _run_claude_json_adapter(args: object, command: str) -> SourceResult[dict[str, object] | None]:
|
|
710
|
+
"""Invoke an established Claude handler through its structured result seam.
|
|
711
|
+
|
|
712
|
+
The handlers retain their ordinary rendering paths. This adapter uses the
|
|
713
|
+
opt-in sink solely for the all-source composition path, so it never captures
|
|
714
|
+
or reparses terminal output and the provider block remains exact legacy JSON.
|
|
715
|
+
"""
|
|
716
|
+
c = _cctally()
|
|
717
|
+
handler_name = _CLAUDE_COMMANDS[command]
|
|
718
|
+
handler = getattr(c, handler_name)
|
|
719
|
+
claude_args = copy(args)
|
|
720
|
+
claude_args.source = "claude"
|
|
721
|
+
claude_args.format = None
|
|
722
|
+
claude_args.json = True
|
|
723
|
+
# This is an internal structured-result invocation, not a destination
|
|
724
|
+
# request. Clear the public share flags before the legacy handler's
|
|
725
|
+
# defense-in-depth validation so it observes a normal JSON invocation.
|
|
726
|
+
claude_args.output = None
|
|
727
|
+
claude_args.copy = False
|
|
728
|
+
claude_args.open_after_write = False
|
|
729
|
+
# The source-aware parser owns ``--format`` but not every legacy share
|
|
730
|
+
# presentation default. Give the structured invocation the same default
|
|
731
|
+
# namespace that an ordinary legacy JSON parse would have.
|
|
732
|
+
for name, value in (
|
|
733
|
+
("theme", "light"),
|
|
734
|
+
("no_branding", False),
|
|
735
|
+
("reveal_projects", False),
|
|
736
|
+
):
|
|
737
|
+
if not hasattr(claude_args, name):
|
|
738
|
+
setattr(claude_args, name, value)
|
|
739
|
+
if command == "range-cost":
|
|
740
|
+
# The shared Codex resolver stores parsed bounds on the original
|
|
741
|
+
# namespace; the established Claude handler intentionally owns its
|
|
742
|
+
# own ISO parsing, so hand it back the exact wire spelling.
|
|
743
|
+
if isinstance(getattr(claude_args, "start", None), dt.datetime):
|
|
744
|
+
claude_args.start = _iso_z(claude_args.start)
|
|
745
|
+
if isinstance(getattr(claude_args, "end", None), dt.datetime):
|
|
746
|
+
claude_args.end = _iso_z(claude_args.end)
|
|
747
|
+
# The all-source adapter needs the complete legacy JSON object to
|
|
748
|
+
# compose compatible physical cost. The public combined path renders
|
|
749
|
+
# its total-only scalar after both provider legs have been read.
|
|
750
|
+
claude_args.total_only = False
|
|
751
|
+
if command == "diff":
|
|
752
|
+
claude_args.emit_json = True
|
|
753
|
+
captured: list[dict[str, object]] = []
|
|
754
|
+
claude_args._source_result_sink = captured.append
|
|
755
|
+
exit_code = handler(claude_args)
|
|
756
|
+
if exit_code != 0 or len(captured) != 1:
|
|
757
|
+
return SourceResult("claude", "unavailable", None)
|
|
758
|
+
payload = captured[0]
|
|
759
|
+
return SourceResult("claude", _claude_result_status(command, payload), payload)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _source_block_wire(result: SourceResult, *, diff: bool) -> dict[str, object]:
|
|
763
|
+
"""Keep a provider block's native JSON exactly intact in an all result."""
|
|
764
|
+
if result.source == "claude":
|
|
765
|
+
return {
|
|
766
|
+
"source": "claude",
|
|
767
|
+
"status": result.status,
|
|
768
|
+
"data": result.data,
|
|
769
|
+
"warnings": [
|
|
770
|
+
{"code": warning.code, "message": warning.message}
|
|
771
|
+
for warning in result.warnings
|
|
772
|
+
],
|
|
773
|
+
}
|
|
774
|
+
direct = source_result_wire(result, diff=diff)
|
|
775
|
+
return {
|
|
776
|
+
"source": direct["source"],
|
|
777
|
+
"status": direct["status"],
|
|
778
|
+
"data": direct["data"],
|
|
779
|
+
"warnings": direct["warnings"],
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _legacy_claude_totals(payload: object) -> tuple[float, int]:
|
|
784
|
+
"""Read compatible accounting once from an exact legacy JSON payload."""
|
|
785
|
+
if not isinstance(payload, dict):
|
|
786
|
+
return 0.0, 0
|
|
787
|
+
totals = payload.get("totals")
|
|
788
|
+
if isinstance(totals, dict):
|
|
789
|
+
cost = totals.get("costUsd", totals.get("cost", 0.0))
|
|
790
|
+
tokens = totals.get("totalTokens", 0)
|
|
791
|
+
if "totalTokens" not in totals and isinstance(payload.get("projects"), list):
|
|
792
|
+
tokens = sum(
|
|
793
|
+
int(row.get("inputTokens", 0)) + int(row.get("outputTokens", 0))
|
|
794
|
+
+ int(row.get("cacheWriteTokens", 0)) + int(row.get("cacheReadTokens", 0))
|
|
795
|
+
for row in payload["projects"] if isinstance(row, dict)
|
|
796
|
+
)
|
|
797
|
+
return float(cost), int(tokens)
|
|
798
|
+
if "totalCostUSD" in payload:
|
|
799
|
+
return (
|
|
800
|
+
float(payload.get("totalCostUSD", 0.0)),
|
|
801
|
+
sum(int(row.get("totalTokens", 0)) for row in payload.get("modelBreakdowns", ()) if isinstance(row, dict)),
|
|
802
|
+
)
|
|
803
|
+
return 0.0, 0
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _codex_totals(result: SourceResult) -> tuple[float, int]:
|
|
807
|
+
"""Read one compatible physical Codex total from its native wire shape."""
|
|
808
|
+
direct = source_result_wire(result)
|
|
809
|
+
data = direct.get("data")
|
|
810
|
+
if not isinstance(data, dict):
|
|
811
|
+
return 0.0, 0
|
|
812
|
+
totals = data.get("totals")
|
|
813
|
+
if not isinstance(totals, dict):
|
|
814
|
+
return 0.0, 0
|
|
815
|
+
return float(totals.get("costUsd", 0.0)), int(totals.get("totalTokens", 0))
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _legacy_diff_totals(payload: object) -> tuple[tuple[float, int], tuple[float, int]]:
|
|
819
|
+
"""Extract the two physical Claude totals from the frozen diff payload."""
|
|
820
|
+
if not isinstance(payload, dict):
|
|
821
|
+
return (0.0, 0), (0.0, 0)
|
|
822
|
+
for section in payload.get("sections", ()):
|
|
823
|
+
if not isinstance(section, dict) or section.get("name") != "overall":
|
|
824
|
+
continue
|
|
825
|
+
rows = section.get("rows")
|
|
826
|
+
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
|
|
827
|
+
break
|
|
828
|
+
row = rows[0]
|
|
829
|
+
|
|
830
|
+
def side(name: str) -> tuple[float, int]:
|
|
831
|
+
values = row.get(name)
|
|
832
|
+
if not isinstance(values, dict):
|
|
833
|
+
return 0.0, 0
|
|
834
|
+
return (
|
|
835
|
+
float(values.get("cost_usd", 0.0)),
|
|
836
|
+
sum(int(values.get(token, 0)) for token in (
|
|
837
|
+
"tokens_input", "tokens_output", "tokens_cache_read", "tokens_cache_write",
|
|
838
|
+
)),
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
return side("a"), side("b")
|
|
842
|
+
return (0.0, 0), (0.0, 0)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _codex_diff_totals(result: SourceResult) -> tuple[tuple[float, int], tuple[float, int]]:
|
|
846
|
+
direct = source_result_wire(result, diff=True)
|
|
847
|
+
data = direct.get("data")
|
|
848
|
+
combined = data.get("combined") if isinstance(data, dict) else None
|
|
849
|
+
if not isinstance(combined, dict):
|
|
850
|
+
return (0.0, 0), (0.0, 0)
|
|
851
|
+
|
|
852
|
+
def side(name: str) -> tuple[float, int]:
|
|
853
|
+
costs = combined.get("cost_usd")
|
|
854
|
+
tokens = combined.get("total_tokens")
|
|
855
|
+
if not isinstance(costs, dict) or not isinstance(tokens, dict):
|
|
856
|
+
return 0.0, 0
|
|
857
|
+
return float(costs.get(name, 0.0)), int(tokens.get(name, 0))
|
|
858
|
+
|
|
859
|
+
return side("a"), side("b")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _all_source_wire(
|
|
863
|
+
claude: SourceResult, codex: SourceResult, *, diff: bool, report: bool,
|
|
864
|
+
) -> dict[str, object]:
|
|
865
|
+
"""Compose exact provider payloads and only compatible physical totals."""
|
|
866
|
+
sources = [
|
|
867
|
+
_source_block_wire(claude, diff=diff),
|
|
868
|
+
_source_block_wire(codex, diff=diff),
|
|
869
|
+
]
|
|
870
|
+
if diff:
|
|
871
|
+
result: dict[str, object] = {"schema_version": 1, "source": "all"}
|
|
872
|
+
if not report:
|
|
873
|
+
(ca, ta), (cb, tb) = _legacy_diff_totals(claude.data)
|
|
874
|
+
(xa, xa_tokens), (xb, xb_tokens) = _codex_diff_totals(codex)
|
|
875
|
+
result["combined"] = {
|
|
876
|
+
"cost_usd": {"a": ca + xa, "b": cb + xb, "delta": (cb + xb) - (ca + xa)},
|
|
877
|
+
"total_tokens": {"a": ta + xa_tokens, "b": tb + xb_tokens, "delta": (tb + xb_tokens) - (ta + xa_tokens)},
|
|
878
|
+
}
|
|
879
|
+
result["sources"] = sources
|
|
880
|
+
result["warnings"] = []
|
|
881
|
+
return result
|
|
882
|
+
result = {"schemaVersion": 1, "source": "all"}
|
|
883
|
+
if not report:
|
|
884
|
+
claude_cost, claude_tokens = _legacy_claude_totals(claude.data)
|
|
885
|
+
codex_cost, codex_tokens = _codex_totals(codex)
|
|
886
|
+
result["combined"] = {
|
|
887
|
+
"costUsd": claude_cost + codex_cost,
|
|
888
|
+
"totalTokens": claude_tokens + codex_tokens,
|
|
889
|
+
}
|
|
890
|
+
result["sources"] = sources
|
|
891
|
+
result["warnings"] = []
|
|
892
|
+
return result
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
_TERMINAL_TITLES = {
|
|
896
|
+
"project": "Project Report",
|
|
897
|
+
"diff": "Diff Report",
|
|
898
|
+
"range-cost": "Range Cost Report",
|
|
899
|
+
"cache-report": "Token Reuse Report",
|
|
900
|
+
"report": "Dollars-per-Percent Report",
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _share_datetime(value: object, fallback: dt.datetime) -> dt.datetime:
|
|
905
|
+
"""Return an aware UTC instant without accepting untrusted display strings."""
|
|
906
|
+
if isinstance(value, dt.datetime):
|
|
907
|
+
return value.astimezone(UTC)
|
|
908
|
+
if isinstance(value, str):
|
|
909
|
+
try:
|
|
910
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
911
|
+
except ValueError:
|
|
912
|
+
return fallback
|
|
913
|
+
if parsed.tzinfo is not None and parsed.utcoffset() is not None:
|
|
914
|
+
return parsed.astimezone(UTC)
|
|
915
|
+
return fallback
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _source_share_period(command: str, result: SourceResult) -> tuple[dt.datetime, dt.datetime]:
|
|
919
|
+
"""Derive a deterministic bounded share period from public result metadata."""
|
|
920
|
+
fallback = _command_as_of().astimezone(UTC)
|
|
921
|
+
data = result.data
|
|
922
|
+
start = getattr(data, "range_start", getattr(data, "start", None))
|
|
923
|
+
end = getattr(data, "range_end", getattr(data, "end", None))
|
|
924
|
+
if command == "report":
|
|
925
|
+
end = getattr(data, "as_of", end)
|
|
926
|
+
if command == "diff":
|
|
927
|
+
windows = getattr(data, "windows", ())
|
|
928
|
+
if len(windows) == 2:
|
|
929
|
+
start = min(window.window.start_at for window in windows)
|
|
930
|
+
end = max(window.window.end_at for window in windows)
|
|
931
|
+
if isinstance(data, dict):
|
|
932
|
+
start = data.get("rangeStart", data.get("rangeStartIso", data.get("start", start)))
|
|
933
|
+
end = data.get("rangeEnd", data.get("rangeEndIso", data.get("end", end)))
|
|
934
|
+
if command == "diff":
|
|
935
|
+
windows = data.get("windows")
|
|
936
|
+
if isinstance(windows, dict):
|
|
937
|
+
values = tuple(value for value in windows.values() if isinstance(value, dict))
|
|
938
|
+
starts = tuple(value.get("start_at") for value in values)
|
|
939
|
+
ends = tuple(value.get("end_at") for value in values)
|
|
940
|
+
parsed_starts = tuple(_share_datetime(value, fallback) for value in starts)
|
|
941
|
+
parsed_ends = tuple(_share_datetime(value, fallback) for value in ends)
|
|
942
|
+
if parsed_starts:
|
|
943
|
+
start = min(parsed_starts)
|
|
944
|
+
if parsed_ends:
|
|
945
|
+
end = max(parsed_ends)
|
|
946
|
+
if command == "report" and not start:
|
|
947
|
+
trend = data.get("trend")
|
|
948
|
+
if isinstance(trend, list) and trend and isinstance(trend[0], dict):
|
|
949
|
+
start = trend[0].get("weekStartDate", start)
|
|
950
|
+
last = trend[-1] if isinstance(trend[-1], dict) else trend[0]
|
|
951
|
+
end = last.get("weekStartDate", end)
|
|
952
|
+
end_at = _share_datetime(end, fallback)
|
|
953
|
+
start_at = _share_datetime(start, end_at)
|
|
954
|
+
return (start_at, end_at if end_at >= start_at else start_at)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _share_float(value: object, default: float = 0.0) -> float:
|
|
958
|
+
"""Read a legacy JSON number without letting malformed optional fields fail share."""
|
|
959
|
+
try:
|
|
960
|
+
return float(value)
|
|
961
|
+
except (TypeError, ValueError):
|
|
962
|
+
return default
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _share_int(value: object, default: int = 0) -> int:
|
|
966
|
+
"""Read a legacy JSON integer without treating bools as token counts."""
|
|
967
|
+
if isinstance(value, bool):
|
|
968
|
+
return default
|
|
969
|
+
try:
|
|
970
|
+
return int(value)
|
|
971
|
+
except (TypeError, ValueError):
|
|
972
|
+
return default
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _claude_project_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
976
|
+
"""Adapt the established ``project`` JSON payload without Codex field guesses."""
|
|
977
|
+
columns = (
|
|
978
|
+
lib.ColumnSpec(key="project", label="Project", kind="project"),
|
|
979
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
980
|
+
lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
981
|
+
)
|
|
982
|
+
rows = []
|
|
983
|
+
for payload_row in data.get("projects", ()):
|
|
984
|
+
if not isinstance(payload_row, dict):
|
|
985
|
+
continue
|
|
986
|
+
cost = _share_float(payload_row.get("costUsd"))
|
|
987
|
+
token_count = sum(_share_int(payload_row.get(name)) for name in (
|
|
988
|
+
"inputTokens", "outputTokens", "cacheWriteTokens", "cacheReadTokens",
|
|
989
|
+
))
|
|
990
|
+
rows.append(lib.Row(cells={
|
|
991
|
+
"project": lib.ProjectCell(
|
|
992
|
+
str(payload_row.get("displayKey", "(unknown)")), rank_cost=cost,
|
|
993
|
+
),
|
|
994
|
+
"cost": lib.MoneyCell(cost),
|
|
995
|
+
"tokens": lib.TextCell(f"{token_count:,}"),
|
|
996
|
+
}))
|
|
997
|
+
total_cost, total_tokens = _legacy_claude_totals(data)
|
|
998
|
+
totals = (
|
|
999
|
+
lib.Totalled(label="Total", value=f"${total_cost:,.2f}"),
|
|
1000
|
+
lib.Totalled(label="Tokens", value=f"{total_tokens:,}"),
|
|
1001
|
+
)
|
|
1002
|
+
return columns, tuple(rows), totals
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _diff_metric_tokens(metric: object) -> int | None:
|
|
1006
|
+
"""Read the source-native total-token field or its frozen Claude inputs."""
|
|
1007
|
+
if not isinstance(metric, dict):
|
|
1008
|
+
return None
|
|
1009
|
+
if "total_tokens" in metric:
|
|
1010
|
+
return _share_int(metric.get("total_tokens"))
|
|
1011
|
+
token_keys = (
|
|
1012
|
+
"tokens_input", "tokens_output", "tokens_cache_read", "tokens_cache_write",
|
|
1013
|
+
)
|
|
1014
|
+
if not any(key in metric for key in token_keys):
|
|
1015
|
+
return None
|
|
1016
|
+
return sum(_share_int(metric.get(key)) for key in token_keys)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _diff_sections(data: dict[str, object]):
|
|
1020
|
+
"""Yield legacy and Codex diff sections without flattening provider shape."""
|
|
1021
|
+
for section in data.get("sections", ()):
|
|
1022
|
+
if not isinstance(section, dict):
|
|
1023
|
+
continue
|
|
1024
|
+
key = str(section.get("key", section.get("name", "section")))
|
|
1025
|
+
label = str(section.get("label", key.replace("-", " ").title()))
|
|
1026
|
+
section_data = section.get("data")
|
|
1027
|
+
rows = (
|
|
1028
|
+
section_data.get("rows", ())
|
|
1029
|
+
if isinstance(section_data, dict) else section.get("rows", ())
|
|
1030
|
+
)
|
|
1031
|
+
yield key, label, str(section.get("status", "ok")), rows
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _diff_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
1035
|
+
"""Adapt one native diff payload with row identities and full comparison metrics."""
|
|
1036
|
+
columns = (
|
|
1037
|
+
lib.ColumnSpec(key="section", label="Section"),
|
|
1038
|
+
lib.ColumnSpec(key="item", label="Item"),
|
|
1039
|
+
lib.ColumnSpec(key="status", label="Status"),
|
|
1040
|
+
lib.ColumnSpec(key="a_cost", label="A $ Cost", align="right"),
|
|
1041
|
+
lib.ColumnSpec(key="b_cost", label="B $ Cost", align="right"),
|
|
1042
|
+
lib.ColumnSpec(key="delta_cost", label="Δ $ Cost", align="right"),
|
|
1043
|
+
lib.ColumnSpec(key="a_tokens", label="A Tokens", align="right"),
|
|
1044
|
+
lib.ColumnSpec(key="b_tokens", label="B Tokens", align="right"),
|
|
1045
|
+
lib.ColumnSpec(key="delta_tokens", label="Δ Tokens", align="right"),
|
|
1046
|
+
)
|
|
1047
|
+
rows = []
|
|
1048
|
+
for section_key, section_label, section_status, payload_rows in _diff_sections(data):
|
|
1049
|
+
for payload_row in payload_rows:
|
|
1050
|
+
if not isinstance(payload_row, dict):
|
|
1051
|
+
continue
|
|
1052
|
+
side_a = payload_row.get("a") if isinstance(payload_row.get("a"), dict) else {}
|
|
1053
|
+
side_b = payload_row.get("b") if isinstance(payload_row.get("b"), dict) else {}
|
|
1054
|
+
delta = payload_row.get("delta") if isinstance(payload_row.get("delta"), dict) else {}
|
|
1055
|
+
label = str(payload_row.get("label", section_label))
|
|
1056
|
+
status = str(payload_row.get("status", section_status))
|
|
1057
|
+
if section_key == "projects":
|
|
1058
|
+
item = lib.ProjectCell(
|
|
1059
|
+
label,
|
|
1060
|
+
rank_cost=_share_float(side_b.get("cost_usd")),
|
|
1061
|
+
identity=str(payload_row.get("key", payload_row.get("projectKey", label))),
|
|
1062
|
+
)
|
|
1063
|
+
else:
|
|
1064
|
+
item = lib.TextCell(label)
|
|
1065
|
+
a_tokens = _diff_metric_tokens(side_a)
|
|
1066
|
+
b_tokens = _diff_metric_tokens(side_b)
|
|
1067
|
+
delta_tokens = _diff_metric_tokens(delta)
|
|
1068
|
+
rows.append(lib.Row(cells={
|
|
1069
|
+
"section": lib.TextCell(section_label),
|
|
1070
|
+
"item": item,
|
|
1071
|
+
"status": lib.TextCell(status),
|
|
1072
|
+
"a_cost": lib.MoneyCell(_share_float(side_a.get("cost_usd"))),
|
|
1073
|
+
"b_cost": lib.MoneyCell(_share_float(side_b.get("cost_usd"))),
|
|
1074
|
+
"delta_cost": lib.DeltaCell(_share_float(delta.get("cost_usd")), "$"),
|
|
1075
|
+
"a_tokens": lib.TextCell("—" if a_tokens is None else f"{a_tokens:,}"),
|
|
1076
|
+
"b_tokens": lib.TextCell("—" if b_tokens is None else f"{b_tokens:,}"),
|
|
1077
|
+
"delta_tokens": lib.TextCell(
|
|
1078
|
+
"—" if delta_tokens is None else f"{delta_tokens:+,}"
|
|
1079
|
+
),
|
|
1080
|
+
}))
|
|
1081
|
+
if isinstance(data.get("combined"), dict):
|
|
1082
|
+
combined = data["combined"]
|
|
1083
|
+
costs = combined.get("cost_usd") if isinstance(combined.get("cost_usd"), dict) else {}
|
|
1084
|
+
tokens = combined.get("total_tokens") if isinstance(combined.get("total_tokens"), dict) else {}
|
|
1085
|
+
cost_a, cost_b = _share_float(costs.get("a")), _share_float(costs.get("b"))
|
|
1086
|
+
tokens_a, tokens_b = _share_int(tokens.get("a")), _share_int(tokens.get("b"))
|
|
1087
|
+
else:
|
|
1088
|
+
(cost_a, tokens_a), (cost_b, tokens_b) = _legacy_diff_totals(data)
|
|
1089
|
+
delta_cost = cost_b - cost_a
|
|
1090
|
+
totals = (
|
|
1091
|
+
lib.Totalled(label="A total", value=f"${cost_a:,.2f}"),
|
|
1092
|
+
lib.Totalled(label="B total", value=f"${cost_b:,.2f}"),
|
|
1093
|
+
lib.Totalled(
|
|
1094
|
+
label="Δ total",
|
|
1095
|
+
value=f"{'+' if delta_cost >= 0 else '-'}${abs(delta_cost):,.2f}",
|
|
1096
|
+
),
|
|
1097
|
+
lib.Totalled(label="A tokens", value=f"{tokens_a:,}"),
|
|
1098
|
+
lib.Totalled(label="B tokens", value=f"{tokens_b:,}"),
|
|
1099
|
+
lib.Totalled(label="Δ tokens", value=f"{(tokens_b - tokens_a):+,}"),
|
|
1100
|
+
)
|
|
1101
|
+
return columns, tuple(rows), totals
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _claude_diff_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
1105
|
+
"""Adapt frozen legacy diff sections with their A/B/delta quantities intact."""
|
|
1106
|
+
return _diff_share_rows(data, lib)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _claude_range_cost_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
1110
|
+
"""Adapt legacy ``modelBreakdowns`` and ``totalCostUSD`` exactly once."""
|
|
1111
|
+
columns = (
|
|
1112
|
+
lib.ColumnSpec(key="model", label="Model"),
|
|
1113
|
+
lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
1114
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1115
|
+
)
|
|
1116
|
+
rows = []
|
|
1117
|
+
for payload_row in data.get("modelBreakdowns", ()):
|
|
1118
|
+
if not isinstance(payload_row, dict):
|
|
1119
|
+
continue
|
|
1120
|
+
rows.append(lib.Row(cells={
|
|
1121
|
+
"model": lib.TextCell(str(payload_row.get("model", "(unknown)"))),
|
|
1122
|
+
"tokens": lib.TextCell(f"{_share_int(payload_row.get('totalTokens')):,}"),
|
|
1123
|
+
"cost": lib.MoneyCell(_share_float(payload_row.get("costUSD"))),
|
|
1124
|
+
}))
|
|
1125
|
+
if not rows and _share_int(data.get("matchedEntries")):
|
|
1126
|
+
cost, tokens = _legacy_claude_totals(data)
|
|
1127
|
+
rows.append(lib.Row(cells={
|
|
1128
|
+
"model": lib.TextCell("Total"),
|
|
1129
|
+
"tokens": lib.TextCell(f"{tokens:,}"),
|
|
1130
|
+
"cost": lib.MoneyCell(cost),
|
|
1131
|
+
}))
|
|
1132
|
+
cost, tokens = _legacy_claude_totals(data)
|
|
1133
|
+
totals = (
|
|
1134
|
+
lib.Totalled(label="Total", value=f"${cost:,.2f}"),
|
|
1135
|
+
lib.Totalled(label="Tokens", value=f"{tokens:,}"),
|
|
1136
|
+
)
|
|
1137
|
+
return columns, tuple(rows), totals
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _claude_cache_report_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
1141
|
+
"""Adapt legacy cache-day/session rows without exposing session/path fields."""
|
|
1142
|
+
columns = (
|
|
1143
|
+
lib.ColumnSpec(key="label", label="Group"),
|
|
1144
|
+
lib.ColumnSpec(key="reuse", label="Cached Input", align="right"),
|
|
1145
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1146
|
+
)
|
|
1147
|
+
payload_rows = data.get("days")
|
|
1148
|
+
if not isinstance(payload_rows, (list, tuple)):
|
|
1149
|
+
payload_rows = data.get("sessions")
|
|
1150
|
+
rows = []
|
|
1151
|
+
for index, payload_row in enumerate(payload_rows if isinstance(payload_rows, (list, tuple)) else ()):
|
|
1152
|
+
if not isinstance(payload_row, dict):
|
|
1153
|
+
continue
|
|
1154
|
+
# Session ids and paths are intentionally never suitable share labels.
|
|
1155
|
+
label = payload_row.get("date") or f"Session {index + 1}"
|
|
1156
|
+
cached_percent = payload_row.get("cacheHitPercent")
|
|
1157
|
+
rows.append(lib.Row(cells={
|
|
1158
|
+
"label": lib.TextCell(str(label)),
|
|
1159
|
+
"reuse": lib.TextCell(
|
|
1160
|
+
"—" if cached_percent is None else f"{_share_float(cached_percent):.1f}%"
|
|
1161
|
+
),
|
|
1162
|
+
"cost": lib.MoneyCell(_share_float(payload_row.get("cost"))),
|
|
1163
|
+
}))
|
|
1164
|
+
total_cost, total_tokens = _legacy_claude_totals(data)
|
|
1165
|
+
totals = (
|
|
1166
|
+
lib.Totalled(label="Total", value=f"${total_cost:,.2f}"),
|
|
1167
|
+
lib.Totalled(label="Tokens", value=f"{total_tokens:,}"),
|
|
1168
|
+
)
|
|
1169
|
+
return columns, tuple(rows), totals
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _claude_report_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
1173
|
+
"""Adapt the established weekly trend rather than inventing quota-series rows."""
|
|
1174
|
+
columns = (
|
|
1175
|
+
lib.ColumnSpec(key="week", label="Week"),
|
|
1176
|
+
lib.ColumnSpec(key="used", label="% Used", align="right"),
|
|
1177
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1178
|
+
lib.ColumnSpec(key="per_percent", label="$/1%", align="right"),
|
|
1179
|
+
)
|
|
1180
|
+
rows = []
|
|
1181
|
+
for payload_row in data.get("trend", ()):
|
|
1182
|
+
if not isinstance(payload_row, dict):
|
|
1183
|
+
continue
|
|
1184
|
+
date_value = str(payload_row.get("weekStartDate", "—"))
|
|
1185
|
+
try:
|
|
1186
|
+
display_week = dt.date.fromisoformat(date_value).strftime("%b %d")
|
|
1187
|
+
except ValueError:
|
|
1188
|
+
display_week = date_value
|
|
1189
|
+
used = payload_row.get("weeklyPercent")
|
|
1190
|
+
per_percent = payload_row.get("dollarsPerPercent")
|
|
1191
|
+
rows.append(lib.Row(cells={
|
|
1192
|
+
"week": lib.TextCell(display_week),
|
|
1193
|
+
"used": lib.TextCell("—" if used is None else f"{_share_float(used):.1f}%"),
|
|
1194
|
+
"cost": lib.MoneyCell(_share_float(payload_row.get("weeklyCostUSD"))),
|
|
1195
|
+
"per_percent": lib.TextCell(
|
|
1196
|
+
"—" if per_percent is None else f"${_share_float(per_percent):.2f}"
|
|
1197
|
+
),
|
|
1198
|
+
}))
|
|
1199
|
+
total_cost = sum(
|
|
1200
|
+
_share_float(row.get("weeklyCostUSD")) for row in data.get("trend", ())
|
|
1201
|
+
if isinstance(row, dict)
|
|
1202
|
+
)
|
|
1203
|
+
return columns, tuple(rows), (lib.Totalled(label="Trend total", value=f"${total_cost:,.2f}"),)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _claude_legacy_share_rows(command: str, data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
1207
|
+
"""Route each legacy payload through its explicit, source-native adapter."""
|
|
1208
|
+
adapters = {
|
|
1209
|
+
"project": _claude_project_share_rows,
|
|
1210
|
+
"diff": _claude_diff_share_rows,
|
|
1211
|
+
"range-cost": _claude_range_cost_share_rows,
|
|
1212
|
+
"cache-report": _claude_cache_report_share_rows,
|
|
1213
|
+
"report": _claude_report_share_rows,
|
|
1214
|
+
}
|
|
1215
|
+
return adapters[command](data, lib)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _source_share_rows(command: str, result: SourceResult, lib) -> tuple[tuple, tuple, tuple]:
|
|
1219
|
+
"""Build a compact, source-safe table from the public provider result shape."""
|
|
1220
|
+
if result.status == "unavailable" or result.data is None:
|
|
1221
|
+
return (), (), ()
|
|
1222
|
+
if result.source == "codex":
|
|
1223
|
+
wire = source_result_wire(result, diff=(command == "diff"))
|
|
1224
|
+
data = wire.get("data")
|
|
1225
|
+
else:
|
|
1226
|
+
data = result.data
|
|
1227
|
+
if not isinstance(data, dict):
|
|
1228
|
+
return (), (), ()
|
|
1229
|
+
|
|
1230
|
+
if command == "project":
|
|
1231
|
+
columns = (
|
|
1232
|
+
lib.ColumnSpec(key="project", label="Project", kind="project"),
|
|
1233
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1234
|
+
lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
1235
|
+
)
|
|
1236
|
+
rows = []
|
|
1237
|
+
for row in data.get("projects", ()):
|
|
1238
|
+
if not isinstance(row, dict):
|
|
1239
|
+
continue
|
|
1240
|
+
label = str(row.get("displayLabel", row.get("project", "(unknown)")))
|
|
1241
|
+
identity = row.get("projectKey") if result.source == "codex" else None
|
|
1242
|
+
tokens = row.get("tokens", {})
|
|
1243
|
+
token_count = tokens.get("totalTokens", 0) if isinstance(tokens, dict) else 0
|
|
1244
|
+
rows.append(lib.Row(cells={
|
|
1245
|
+
"project": lib.ProjectCell(label, rank_cost=float(row.get("costUsd", 0.0)), identity=identity),
|
|
1246
|
+
"cost": lib.MoneyCell(float(row.get("costUsd", 0.0))),
|
|
1247
|
+
"tokens": lib.TextCell(f"{int(token_count):,}"),
|
|
1248
|
+
}))
|
|
1249
|
+
return columns, tuple(rows), ()
|
|
1250
|
+
|
|
1251
|
+
if command == "range-cost":
|
|
1252
|
+
columns = (
|
|
1253
|
+
lib.ColumnSpec(key="model", label="Model"),
|
|
1254
|
+
lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
1255
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1256
|
+
)
|
|
1257
|
+
model_rows = tuple(row for row in data.get("models", ()) if isinstance(row, dict))
|
|
1258
|
+
if model_rows:
|
|
1259
|
+
rows = tuple(
|
|
1260
|
+
lib.Row(cells={
|
|
1261
|
+
"model": lib.TextCell(str(row.get("model", "(unknown)"))),
|
|
1262
|
+
"tokens": lib.TextCell(f"{int(row.get('totalTokens', 0)):,}"),
|
|
1263
|
+
"cost": lib.MoneyCell(float(row.get("costUsd", 0.0))),
|
|
1264
|
+
})
|
|
1265
|
+
for row in model_rows
|
|
1266
|
+
)
|
|
1267
|
+
else:
|
|
1268
|
+
totals = data.get("totals") if isinstance(data.get("totals"), dict) else {}
|
|
1269
|
+
rows = (lib.Row(cells={
|
|
1270
|
+
"model": lib.TextCell("Total"),
|
|
1271
|
+
"tokens": lib.TextCell(f"{int(totals.get('totalTokens', 0)):,}"),
|
|
1272
|
+
"cost": lib.MoneyCell(float(totals.get("costUsd", 0.0))),
|
|
1273
|
+
}),)
|
|
1274
|
+
return columns, rows, ()
|
|
1275
|
+
|
|
1276
|
+
if command == "cache-report":
|
|
1277
|
+
columns = (
|
|
1278
|
+
lib.ColumnSpec(key="label", label="Group"),
|
|
1279
|
+
lib.ColumnSpec(key="reuse", label="Cached Input", align="right"),
|
|
1280
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1281
|
+
)
|
|
1282
|
+
rows = []
|
|
1283
|
+
for section in data.get("sections", ()):
|
|
1284
|
+
if not isinstance(section, dict):
|
|
1285
|
+
continue
|
|
1286
|
+
for row in (section.get("data") or {}).get("rows", ()):
|
|
1287
|
+
if not isinstance(row, dict):
|
|
1288
|
+
continue
|
|
1289
|
+
rows.append(lib.Row(cells={
|
|
1290
|
+
"label": lib.TextCell(str(row.get("label", "Codex"))),
|
|
1291
|
+
"reuse": lib.TextCell(
|
|
1292
|
+
"—" if row.get("cachedInputPercent") is None
|
|
1293
|
+
else f"{float(row['cachedInputPercent']):.1f}%"
|
|
1294
|
+
),
|
|
1295
|
+
"cost": lib.MoneyCell(float(row.get("costUsd", 0.0))),
|
|
1296
|
+
}))
|
|
1297
|
+
return columns, tuple(rows), ()
|
|
1298
|
+
|
|
1299
|
+
if command == "diff":
|
|
1300
|
+
return _diff_share_rows(data, lib)
|
|
1301
|
+
|
|
1302
|
+
columns = (
|
|
1303
|
+
lib.ColumnSpec(key="series", label="Quota Series"),
|
|
1304
|
+
lib.ColumnSpec(key="used", label="% Used", align="right"),
|
|
1305
|
+
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1306
|
+
)
|
|
1307
|
+
rows = []
|
|
1308
|
+
for section in data.get("sections", ()):
|
|
1309
|
+
if not isinstance(section, dict):
|
|
1310
|
+
continue
|
|
1311
|
+
for series in (section.get("data") or {}).get("series", ()):
|
|
1312
|
+
if not isinstance(series, dict):
|
|
1313
|
+
continue
|
|
1314
|
+
for row in series.get("rows", ()):
|
|
1315
|
+
if not isinstance(row, dict):
|
|
1316
|
+
continue
|
|
1317
|
+
slot = str(series.get("slot", "quota"))
|
|
1318
|
+
window_minutes = series.get("windowMinutes")
|
|
1319
|
+
if isinstance(window_minutes, int) and window_minutes > 0:
|
|
1320
|
+
slot = f"{slot} · {window_minutes}m"
|
|
1321
|
+
rows.append(lib.Row(cells={
|
|
1322
|
+
"series": lib.TextCell(slot),
|
|
1323
|
+
"used": lib.TextCell("—" if row.get("usedPercent") is None else f"{float(row['usedPercent']):.1f}%"),
|
|
1324
|
+
"cost": lib.MoneyCell(float(row.get("costUsd", 0.0))),
|
|
1325
|
+
}))
|
|
1326
|
+
return columns, tuple(rows), ()
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def build_source_share_snapshot(
|
|
1330
|
+
command: str, source_result: SourceResult, *, reveal_projects: bool,
|
|
1331
|
+
) -> "ShareSnapshot":
|
|
1332
|
+
"""Return one source-bearing share snapshot without exposing local roots.
|
|
1333
|
+
|
|
1334
|
+
Callers compose two returned snapshots for an all-source artifact instead
|
|
1335
|
+
of inventing a synthetic ``source='all'`` snapshot.
|
|
1336
|
+
"""
|
|
1337
|
+
if command not in _TERMINAL_TITLES:
|
|
1338
|
+
raise ValueError(f"unknown source share command: {command}")
|
|
1339
|
+
if source_result.source not in {"claude", "codex"}:
|
|
1340
|
+
raise ValueError("source share snapshots require Claude or Codex")
|
|
1341
|
+
c = _cctally()
|
|
1342
|
+
lib = c._share_load_lib()
|
|
1343
|
+
start, end = _source_share_period(command, source_result)
|
|
1344
|
+
if source_result.source == "claude" and isinstance(source_result.data, dict):
|
|
1345
|
+
columns, rows, totals = _claude_legacy_share_rows(command, source_result.data, lib)
|
|
1346
|
+
else:
|
|
1347
|
+
columns, rows, totals = _source_share_rows(command, source_result, lib)
|
|
1348
|
+
reason = (
|
|
1349
|
+
source_result.warnings[0].message
|
|
1350
|
+
if source_result.status == "unavailable" and source_result.warnings
|
|
1351
|
+
else "Source analytics are unavailable."
|
|
1352
|
+
)
|
|
1353
|
+
availability = "unavailable" if source_result.status == "unavailable" else (
|
|
1354
|
+
"empty" if source_result.status == "empty" else "ok"
|
|
1355
|
+
)
|
|
1356
|
+
source_label = "Claude" if source_result.source == "claude" else "Codex"
|
|
1357
|
+
period_label = f"{start.date().isoformat()} → {end.date().isoformat()} (UTC)"
|
|
1358
|
+
notes = tuple(warning.message for warning in source_result.warnings if source_result.status != "unavailable")
|
|
1359
|
+
return lib.ShareSnapshot(
|
|
1360
|
+
cmd=command,
|
|
1361
|
+
title=_TERMINAL_TITLES[command],
|
|
1362
|
+
subtitle=period_label,
|
|
1363
|
+
period=lib.PeriodSpec(start=start, end=end, display_tz="UTC", label=period_label),
|
|
1364
|
+
columns=columns,
|
|
1365
|
+
rows=rows,
|
|
1366
|
+
chart=None,
|
|
1367
|
+
totals=totals,
|
|
1368
|
+
notes=notes,
|
|
1369
|
+
generated_at=end,
|
|
1370
|
+
version=c._share_resolve_version(),
|
|
1371
|
+
source=source_result.source,
|
|
1372
|
+
source_label=source_label,
|
|
1373
|
+
availability=availability,
|
|
1374
|
+
availability_reason=reason if availability == "unavailable" else None,
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _terminal_amount(value: object) -> str:
|
|
1379
|
+
try:
|
|
1380
|
+
return f"${float(value):.6f}"
|
|
1381
|
+
except (TypeError, ValueError):
|
|
1382
|
+
return "$0.000000"
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _terminal_tokens(value: object) -> str:
|
|
1386
|
+
try:
|
|
1387
|
+
return f"{int(value):,} tokens"
|
|
1388
|
+
except (TypeError, ValueError):
|
|
1389
|
+
return "0 tokens"
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _terminal_delta_amount(value: object) -> str:
|
|
1393
|
+
try:
|
|
1394
|
+
amount = float(value)
|
|
1395
|
+
except (TypeError, ValueError):
|
|
1396
|
+
amount = 0.0
|
|
1397
|
+
return f"{'+' if amount >= 0 else '-'}${abs(amount):.6f}"
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _terminal_delta_tokens(value: object) -> str:
|
|
1401
|
+
try:
|
|
1402
|
+
amount = int(value)
|
|
1403
|
+
except (TypeError, ValueError):
|
|
1404
|
+
amount = 0
|
|
1405
|
+
return f"{amount:+,} tokens"
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _append_diff_terminal(lines: list[str], data: dict[str, object]) -> None:
|
|
1409
|
+
"""Append source-native diff rows with comparison metrics and statuses."""
|
|
1410
|
+
combined = data.get("combined") if isinstance(data.get("combined"), dict) else {}
|
|
1411
|
+
costs = combined.get("cost_usd") if isinstance(combined.get("cost_usd"), dict) else {}
|
|
1412
|
+
tokens = combined.get("total_tokens") if isinstance(combined.get("total_tokens"), dict) else {}
|
|
1413
|
+
if costs or tokens:
|
|
1414
|
+
lines.append(
|
|
1415
|
+
"Overall: "
|
|
1416
|
+
f"A {_terminal_amount(costs.get('a'))} / {_terminal_tokens(tokens.get('a'))}; "
|
|
1417
|
+
f"B {_terminal_amount(costs.get('b'))} / {_terminal_tokens(tokens.get('b'))}; "
|
|
1418
|
+
f"Δ {_terminal_delta_amount(costs.get('delta'))} / "
|
|
1419
|
+
f"{_terminal_delta_tokens(tokens.get('delta'))}"
|
|
1420
|
+
)
|
|
1421
|
+
else:
|
|
1422
|
+
(cost_a, tokens_a), (cost_b, tokens_b) = _legacy_diff_totals(data)
|
|
1423
|
+
lines.append(
|
|
1424
|
+
"Overall: "
|
|
1425
|
+
f"A {_terminal_amount(cost_a)} / {_terminal_tokens(tokens_a)}; "
|
|
1426
|
+
f"B {_terminal_amount(cost_b)} / {_terminal_tokens(tokens_b)}; "
|
|
1427
|
+
f"Δ {_terminal_delta_amount(cost_b - cost_a)} / "
|
|
1428
|
+
f"{_terminal_delta_tokens(tokens_b - tokens_a)}"
|
|
1429
|
+
)
|
|
1430
|
+
for section_key, section_label, section_status, payload_rows in _diff_sections(data):
|
|
1431
|
+
lines.append(f"- {section_label}: {section_status}")
|
|
1432
|
+
for payload_row in payload_rows:
|
|
1433
|
+
if not isinstance(payload_row, dict):
|
|
1434
|
+
continue
|
|
1435
|
+
side_a = payload_row.get("a") if isinstance(payload_row.get("a"), dict) else {}
|
|
1436
|
+
side_b = payload_row.get("b") if isinstance(payload_row.get("b"), dict) else {}
|
|
1437
|
+
delta = payload_row.get("delta") if isinstance(payload_row.get("delta"), dict) else {}
|
|
1438
|
+
label = str(payload_row.get("label", section_key))
|
|
1439
|
+
status = str(payload_row.get("status", section_status))
|
|
1440
|
+
a_tokens = _diff_metric_tokens(side_a)
|
|
1441
|
+
b_tokens = _diff_metric_tokens(side_b)
|
|
1442
|
+
delta_tokens = _diff_metric_tokens(delta)
|
|
1443
|
+
lines.append(
|
|
1444
|
+
f" - {label} [{status}]: "
|
|
1445
|
+
f"A {_terminal_amount(side_a.get('cost_usd'))} / "
|
|
1446
|
+
f"{_terminal_tokens(a_tokens)}; "
|
|
1447
|
+
f"B {_terminal_amount(side_b.get('cost_usd'))} / "
|
|
1448
|
+
f"{_terminal_tokens(b_tokens)}; "
|
|
1449
|
+
f"Δ {_terminal_delta_amount(delta.get('cost_usd'))} / "
|
|
1450
|
+
f"{_terminal_delta_tokens(delta_tokens)}"
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _render_codex_terminal(
|
|
1455
|
+
command: str, result: SourceResult, *, diff: bool,
|
|
1456
|
+
include_unavailable_diagnostic: bool = True,
|
|
1457
|
+
) -> str:
|
|
1458
|
+
"""Render a compact, provider-native terminal report without raw paths."""
|
|
1459
|
+
title = _TERMINAL_TITLES.get(command, "Analytics Report")
|
|
1460
|
+
lines = [f"Codex {title}"]
|
|
1461
|
+
if result.status == "unavailable":
|
|
1462
|
+
if not include_unavailable_diagnostic:
|
|
1463
|
+
return "\n".join([*lines, "Unavailable."])
|
|
1464
|
+
diagnostic = result.warnings[0].message if result.warnings else "Codex analytics are unavailable."
|
|
1465
|
+
return "\n".join([*lines, f"Unavailable: {diagnostic}"])
|
|
1466
|
+
if result.status == "empty":
|
|
1467
|
+
return "\n".join([*lines, "No data."])
|
|
1468
|
+
if result.status == "partial":
|
|
1469
|
+
diagnostic = result.warnings[0].message if result.warnings else "Some Codex detail is unavailable."
|
|
1470
|
+
lines.append(f"Partial: {diagnostic}")
|
|
1471
|
+
wire = source_result_wire(result, diff=diff)
|
|
1472
|
+
data = wire.get("data")
|
|
1473
|
+
if not isinstance(data, dict):
|
|
1474
|
+
return "\n".join(lines)
|
|
1475
|
+
if command == "project":
|
|
1476
|
+
totals = data.get("totals", {})
|
|
1477
|
+
lines.append(
|
|
1478
|
+
f"Total: {_terminal_amount(totals.get('costUsd'))} · "
|
|
1479
|
+
f"{_terminal_tokens(totals.get('totalTokens'))}"
|
|
1480
|
+
)
|
|
1481
|
+
for row in data.get("projects", ()):
|
|
1482
|
+
if isinstance(row, dict):
|
|
1483
|
+
lines.append(
|
|
1484
|
+
f"- {row.get('displayLabel', '(unassigned)')}: "
|
|
1485
|
+
f"{_terminal_amount(row.get('costUsd'))} · "
|
|
1486
|
+
f"{_terminal_tokens(row.get('tokens', {}).get('totalTokens'))}"
|
|
1487
|
+
)
|
|
1488
|
+
# The pure project result omits this key unless --breakdown
|
|
1489
|
+
# was requested, so preserving it here makes the accepted
|
|
1490
|
+
# flag visible on both machine and terminal surfaces.
|
|
1491
|
+
for model in row.get("models", ()):
|
|
1492
|
+
if isinstance(model, dict):
|
|
1493
|
+
lines.append(
|
|
1494
|
+
f" - {model.get('model', '(unknown)')}: "
|
|
1495
|
+
f"{_terminal_amount(model.get('costUsd'))} · "
|
|
1496
|
+
f"{_terminal_tokens(model.get('totalTokens'))}"
|
|
1497
|
+
)
|
|
1498
|
+
elif command == "range-cost":
|
|
1499
|
+
totals = data.get("totals", {})
|
|
1500
|
+
lines.append(
|
|
1501
|
+
f"Total: {_terminal_amount(totals.get('costUsd'))} · "
|
|
1502
|
+
f"{_terminal_tokens(totals.get('totalTokens'))}"
|
|
1503
|
+
)
|
|
1504
|
+
for row in data.get("models", ()):
|
|
1505
|
+
if isinstance(row, dict):
|
|
1506
|
+
lines.append(f"- {row.get('model', '(unknown)')}: {_terminal_amount(row.get('costUsd'))}")
|
|
1507
|
+
elif command == "cache-report":
|
|
1508
|
+
totals = data.get("totals", {})
|
|
1509
|
+
lines.append(
|
|
1510
|
+
f"Total: {_terminal_amount(totals.get('costUsd'))} · "
|
|
1511
|
+
f"{_terminal_tokens(totals.get('totalTokens'))}"
|
|
1512
|
+
)
|
|
1513
|
+
for section in data.get("sections", ()):
|
|
1514
|
+
if not isinstance(section, dict) or section.get("key") != "token-reuse":
|
|
1515
|
+
continue
|
|
1516
|
+
section_data = section.get("data") or {}
|
|
1517
|
+
for row in section_data.get("rows", ()):
|
|
1518
|
+
if isinstance(row, dict):
|
|
1519
|
+
percent = row.get("cachedInputPercent")
|
|
1520
|
+
rendered = "—" if percent is None else f"{float(percent):.1f}%"
|
|
1521
|
+
lines.append(f"- {row.get('label', 'Codex')}: cached input {rendered}")
|
|
1522
|
+
elif command == "diff":
|
|
1523
|
+
_append_diff_terminal(lines, data)
|
|
1524
|
+
elif command == "report":
|
|
1525
|
+
sections = data.get("sections", ())
|
|
1526
|
+
series = 0
|
|
1527
|
+
for section in sections:
|
|
1528
|
+
if isinstance(section, dict):
|
|
1529
|
+
for source_series in (section.get("data") or {}).get("series", ()):
|
|
1530
|
+
if not isinstance(source_series, dict):
|
|
1531
|
+
continue
|
|
1532
|
+
series += 1
|
|
1533
|
+
slot = source_series.get("slot", "quota")
|
|
1534
|
+
for row in source_series.get("rows", ()):
|
|
1535
|
+
if not isinstance(row, dict):
|
|
1536
|
+
continue
|
|
1537
|
+
lines.append(
|
|
1538
|
+
f"- {slot}: {_terminal_amount(row.get('costUsd'))} · "
|
|
1539
|
+
f"{row.get('usedPercent', '—')}%"
|
|
1540
|
+
)
|
|
1541
|
+
for detail in row.get("detail", ()):
|
|
1542
|
+
if isinstance(detail, dict):
|
|
1543
|
+
lines.append(
|
|
1544
|
+
f" - {detail.get('percentThreshold', '—')}%: "
|
|
1545
|
+
f"{_terminal_amount(detail.get('cumulativeCostUSD'))}"
|
|
1546
|
+
)
|
|
1547
|
+
lines.append(f"Quota series: {series}")
|
|
1548
|
+
return "\n".join(lines)
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _render_claude_terminal(command: str, result: SourceResult) -> str:
|
|
1552
|
+
"""Render an all-source Claude section without changing legacy CLI paths."""
|
|
1553
|
+
title = _TERMINAL_TITLES.get(command, "Analytics Report")
|
|
1554
|
+
lines = [f"Claude {title}"]
|
|
1555
|
+
if result.status == "unavailable":
|
|
1556
|
+
return "\n".join([*lines, "Unavailable: Claude analytics are unavailable."])
|
|
1557
|
+
if result.status == "empty":
|
|
1558
|
+
return "\n".join([*lines, "No data."])
|
|
1559
|
+
if command == "diff" and isinstance(result.data, dict):
|
|
1560
|
+
_append_diff_terminal(lines, result.data)
|
|
1561
|
+
return "\n".join(lines)
|
|
1562
|
+
cost, tokens = _legacy_claude_totals(result.data)
|
|
1563
|
+
if cost or tokens:
|
|
1564
|
+
lines.append(f"Total: {_terminal_amount(cost)} · {_terminal_tokens(tokens)}")
|
|
1565
|
+
else:
|
|
1566
|
+
lines.append("Data available.")
|
|
1567
|
+
return "\n".join(lines)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _emit_source_share(
|
|
1571
|
+
args: object, result: SourceResult, *, command: str,
|
|
1572
|
+
claude: SourceResult | None,
|
|
1573
|
+
) -> int:
|
|
1574
|
+
"""Render one provider snapshot or a Claude-then-Codex composition."""
|
|
1575
|
+
c = _cctally()
|
|
1576
|
+
codex_snap = build_source_share_snapshot(
|
|
1577
|
+
command, result, reveal_projects=bool(getattr(args, "reveal_projects", False)),
|
|
1578
|
+
)
|
|
1579
|
+
if getattr(args, "source", "codex") != "all":
|
|
1580
|
+
c._share_render_and_emit(codex_snap, args)
|
|
1581
|
+
else:
|
|
1582
|
+
if claude is None:
|
|
1583
|
+
raise ValueError("all-source share rendering requires a Claude result")
|
|
1584
|
+
lib = c._share_load_lib()
|
|
1585
|
+
reveal_projects = bool(getattr(args, "reveal_projects", False))
|
|
1586
|
+
claude_snap = build_source_share_snapshot(
|
|
1587
|
+
command, claude, reveal_projects=reveal_projects,
|
|
1588
|
+
)
|
|
1589
|
+
sections = (
|
|
1590
|
+
lib.ComposedSection(
|
|
1591
|
+
snap=lib._scrub(claude_snap, reveal_projects=reveal_projects),
|
|
1592
|
+
drift_detected=False,
|
|
1593
|
+
),
|
|
1594
|
+
lib.ComposedSection(
|
|
1595
|
+
snap=lib._scrub(codex_snap, reveal_projects=reveal_projects),
|
|
1596
|
+
drift_detected=False,
|
|
1597
|
+
),
|
|
1598
|
+
)
|
|
1599
|
+
content = lib.compose(
|
|
1600
|
+
sections,
|
|
1601
|
+
opts=lib.ComposeOptions(
|
|
1602
|
+
title=f"{_TERMINAL_TITLES[command]} — Claude + Codex",
|
|
1603
|
+
theme=getattr(args, "theme", "light"),
|
|
1604
|
+
format=args.format,
|
|
1605
|
+
no_branding=bool(getattr(args, "no_branding", False)),
|
|
1606
|
+
reveal_projects=reveal_projects,
|
|
1607
|
+
),
|
|
1608
|
+
)
|
|
1609
|
+
utc_date = codex_snap.generated_at.astimezone(UTC).strftime("%Y-%m-%d")
|
|
1610
|
+
kind, value = c._resolve_destination(args, cmd=command, generated_at_utc_date=utc_date)
|
|
1611
|
+
c._emit(content, kind=kind, value=value)
|
|
1612
|
+
if getattr(args, "open_after_write", False) and kind == "file":
|
|
1613
|
+
c._share_open_file(value)
|
|
1614
|
+
return 3 if (
|
|
1615
|
+
getattr(args, "project", None)
|
|
1616
|
+
and command in {"range-cost", "cache-report"}
|
|
1617
|
+
and result.status == "unavailable"
|
|
1618
|
+
) else 0
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _emit_source_result(
|
|
1622
|
+
args: object, result: SourceResult, *, diff: bool = False, report: bool = False,
|
|
1623
|
+
claude: SourceResult | None = None,
|
|
1624
|
+
) -> int:
|
|
1625
|
+
if getattr(args, "format", None):
|
|
1626
|
+
return _emit_source_share(
|
|
1627
|
+
args, result,
|
|
1628
|
+
command=str(getattr(args, "command", "analytics")),
|
|
1629
|
+
claude=claude,
|
|
1630
|
+
)
|
|
1631
|
+
if getattr(args, "source", "codex") == "all":
|
|
1632
|
+
if claude is None:
|
|
1633
|
+
raise ValueError("all-source rendering requires a Claude result")
|
|
1634
|
+
wire = _all_source_wire(claude, result, diff=diff, report=report)
|
|
1635
|
+
if bool(getattr(args, "json", False)):
|
|
1636
|
+
print(json.dumps(wire, separators=(",", ":")))
|
|
1637
|
+
else:
|
|
1638
|
+
command = str(getattr(args, "command", "analytics"))
|
|
1639
|
+
project_degradation = (
|
|
1640
|
+
getattr(args, "project", None)
|
|
1641
|
+
and command in {"range-cost", "cache-report"}
|
|
1642
|
+
and result.status == "unavailable"
|
|
1643
|
+
)
|
|
1644
|
+
sections = [
|
|
1645
|
+
_render_claude_terminal(command, claude),
|
|
1646
|
+
_render_codex_terminal(
|
|
1647
|
+
command, result, diff=diff,
|
|
1648
|
+
include_unavailable_diagnostic=not project_degradation,
|
|
1649
|
+
),
|
|
1650
|
+
]
|
|
1651
|
+
if not report:
|
|
1652
|
+
combined = wire.get("combined")
|
|
1653
|
+
if isinstance(combined, dict):
|
|
1654
|
+
if diff:
|
|
1655
|
+
costs = combined.get("cost_usd", {})
|
|
1656
|
+
tokens = combined.get("total_tokens", {})
|
|
1657
|
+
sections.append(
|
|
1658
|
+
"Combined physical accounting: "
|
|
1659
|
+
f"A {_terminal_amount(costs.get('a') if isinstance(costs, dict) else None)}; "
|
|
1660
|
+
f"B {_terminal_amount(costs.get('b') if isinstance(costs, dict) else None)}; "
|
|
1661
|
+
f"Δ {_terminal_delta_amount(costs.get('delta') if isinstance(costs, dict) else None)}; "
|
|
1662
|
+
f"A {_terminal_tokens(tokens.get('a') if isinstance(tokens, dict) else None)}; "
|
|
1663
|
+
f"B {_terminal_tokens(tokens.get('b') if isinstance(tokens, dict) else None)}; "
|
|
1664
|
+
f"Δ {_terminal_delta_tokens(tokens.get('delta') if isinstance(tokens, dict) else None)}"
|
|
1665
|
+
)
|
|
1666
|
+
else:
|
|
1667
|
+
sections.append(
|
|
1668
|
+
"Combined physical accounting: "
|
|
1669
|
+
f"{_terminal_amount(combined.get('costUsd'))} · "
|
|
1670
|
+
f"{_terminal_tokens(combined.get('totalTokens'))}"
|
|
1671
|
+
)
|
|
1672
|
+
print("\n\n".join(sections))
|
|
1673
|
+
if project_degradation:
|
|
1674
|
+
diagnostic = result.warnings[0].message if result.warnings else "Codex analytics are unavailable."
|
|
1675
|
+
print(f"cctally: {diagnostic}", file=sys.stderr)
|
|
1676
|
+
if (
|
|
1677
|
+
getattr(args, "project", None)
|
|
1678
|
+
and getattr(args, "command", None) in {"range-cost", "cache-report"}
|
|
1679
|
+
and result.status == "unavailable"
|
|
1680
|
+
):
|
|
1681
|
+
return 3
|
|
1682
|
+
return 0
|
|
1683
|
+
wire = source_result_wire(result, diff=diff)
|
|
1684
|
+
if bool(getattr(args, "json", False)):
|
|
1685
|
+
print(json.dumps(wire, separators=(",", ":")))
|
|
1686
|
+
elif result.status == "unavailable":
|
|
1687
|
+
# Design 10.2 keeps requested-project range/cache direct stdout empty;
|
|
1688
|
+
# the other provider reports carry their stable unavailable diagnostic.
|
|
1689
|
+
if not (
|
|
1690
|
+
getattr(args, "project", None)
|
|
1691
|
+
and getattr(args, "command", None) in {"range-cost", "cache-report"}
|
|
1692
|
+
):
|
|
1693
|
+
print(_render_codex_terminal(str(getattr(args, "command", "analytics")), result, diff=diff))
|
|
1694
|
+
else:
|
|
1695
|
+
print(_render_codex_terminal(str(getattr(args, "command", "analytics")), result, diff=diff))
|
|
1696
|
+
return 3 if result.status == "unavailable" else 0
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
def cmd_source_project(args: object) -> int:
|
|
1700
|
+
_cctally()._share_validate_args(args)
|
|
1701
|
+
if getattr(args, "source", "codex") == "claude" and getattr(args, "format", None):
|
|
1702
|
+
return _emit_source_result(args, _run_claude_json_adapter(args, "project"))
|
|
1703
|
+
start, end = _resolve_source_project_range(args)
|
|
1704
|
+
try:
|
|
1705
|
+
validate_source_project_selectors(getattr(args, "project", None))
|
|
1706
|
+
except SourceUsageError as exc:
|
|
1707
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1708
|
+
return 2
|
|
1709
|
+
# Keep the Claude compatibility block on the single calendar interval
|
|
1710
|
+
# selected for the provider-aware command; without this, its legacy
|
|
1711
|
+
# default would silently choose a subscription-week boundary instead.
|
|
1712
|
+
# The provider reader is half-open; the legacy project command renders a
|
|
1713
|
+
# date-only --until as its inclusive final microsecond. These describe
|
|
1714
|
+
# the identical instants, while retaining the frozen Claude JSON labels.
|
|
1715
|
+
claude_end = end
|
|
1716
|
+
raw_until = getattr(args, "until", None)
|
|
1717
|
+
if isinstance(raw_until, str) and not any(
|
|
1718
|
+
marker in raw_until for marker in ("T", "+", "Z")
|
|
1719
|
+
):
|
|
1720
|
+
claude_end -= dt.timedelta(microseconds=1)
|
|
1721
|
+
args._source_analytics_range = (start, claude_end)
|
|
1722
|
+
claude = (
|
|
1723
|
+
_run_claude_json_adapter(args, "project")
|
|
1724
|
+
if getattr(args, "source", "codex") == "all" else None
|
|
1725
|
+
)
|
|
1726
|
+
try:
|
|
1727
|
+
population_entries = _source_entries(
|
|
1728
|
+
args, start, end, qualified=True, group=getattr(args, "group", "git-root"),
|
|
1729
|
+
)
|
|
1730
|
+
except QualifiedMetadataUnavailable:
|
|
1731
|
+
return _emit_source_result(
|
|
1732
|
+
args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
|
|
1733
|
+
claude=claude,
|
|
1734
|
+
)
|
|
1735
|
+
try:
|
|
1736
|
+
entries = _filter_project_entries(population_entries, getattr(args, "project", None))
|
|
1737
|
+
except SourceUsageError as exc:
|
|
1738
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1739
|
+
return 2
|
|
1740
|
+
entries = _filter_model_entries(entries, getattr(args, "model", None))
|
|
1741
|
+
allocation_entries = population_entries
|
|
1742
|
+
quota_available = True
|
|
1743
|
+
supplied_blocks = getattr(args, "blocks", None)
|
|
1744
|
+
if supplied_blocks is None:
|
|
1745
|
+
try:
|
|
1746
|
+
blocks = select_codex_project_blocks(
|
|
1747
|
+
build_blocks(load_codex_quota_observations()),
|
|
1748
|
+
range_start=start, range_end=end, as_of=getattr(args, "as_of", end),
|
|
1749
|
+
)
|
|
1750
|
+
except (OSError, sqlite3.Error, ValueError):
|
|
1751
|
+
# Accounting stays useful when S2's operational state is absent;
|
|
1752
|
+
# omit attributions rather than manufacturing a blended quota.
|
|
1753
|
+
blocks = ()
|
|
1754
|
+
quota_available = False
|
|
1755
|
+
else:
|
|
1756
|
+
blocks = select_codex_project_blocks(
|
|
1757
|
+
tuple(supplied_blocks),
|
|
1758
|
+
range_start=start, range_end=end, as_of=getattr(args, "as_of", end),
|
|
1759
|
+
)
|
|
1760
|
+
if blocks:
|
|
1761
|
+
block_start = min(block.nominal_start_at for block in blocks)
|
|
1762
|
+
block_end = max(
|
|
1763
|
+
min(getattr(args, "as_of", end), block.resets_at) for block in blocks
|
|
1764
|
+
)
|
|
1765
|
+
if block_start < start or block_end > end:
|
|
1766
|
+
try:
|
|
1767
|
+
read_args = copy(args)
|
|
1768
|
+
read_args._source_analytics_sync = False
|
|
1769
|
+
population_entries = _source_entries(
|
|
1770
|
+
read_args,
|
|
1771
|
+
min(start, block_start), max(end, block_end),
|
|
1772
|
+
qualified=True, group=getattr(args, "group", "git-root"),
|
|
1773
|
+
)
|
|
1774
|
+
entries = _filter_project_entries(population_entries, getattr(args, "project", None))
|
|
1775
|
+
entries = _filter_model_entries(entries, getattr(args, "model", None))
|
|
1776
|
+
allocation_entries = population_entries
|
|
1777
|
+
except QualifiedMetadataUnavailable:
|
|
1778
|
+
return _emit_source_result(
|
|
1779
|
+
args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
|
|
1780
|
+
claude=claude,
|
|
1781
|
+
)
|
|
1782
|
+
result = build_codex_project_result(
|
|
1783
|
+
entries, range_start=start, range_end=end,
|
|
1784
|
+
blocks=blocks,
|
|
1785
|
+
as_of=getattr(args, "as_of", end),
|
|
1786
|
+
sort=getattr(args, "sort", "cost"), order=getattr(args, "order", "desc"),
|
|
1787
|
+
include_breakdown=bool(getattr(args, "breakdown", False)),
|
|
1788
|
+
allocation_entries=allocation_entries,
|
|
1789
|
+
quota_available=quota_available,
|
|
1790
|
+
)
|
|
1791
|
+
return _emit_source_result(args, result, claude=claude)
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def cmd_source_diff(args: object) -> int:
|
|
1795
|
+
_cctally()._share_validate_args(args)
|
|
1796
|
+
if getattr(args, "source", "codex") == "claude" and getattr(args, "format", None):
|
|
1797
|
+
return _emit_source_result(args, _run_claude_json_adapter(args, "diff"), diff=True)
|
|
1798
|
+
window_a, window_b = _resolve_source_diff_windows(args)
|
|
1799
|
+
if getattr(args, "a", None) is None and getattr(args, "b", None) is None:
|
|
1800
|
+
# Direct adapter callers supply resolved AnalyticsWindow instances;
|
|
1801
|
+
# their enclosing test/host owns validation, so preserve the pure
|
|
1802
|
+
# half-open seam without pretending it is a parsed CLI request.
|
|
1803
|
+
normalization = "none"
|
|
1804
|
+
else:
|
|
1805
|
+
try:
|
|
1806
|
+
normalization = _validate_source_diff_controls(args, window_a, window_b)
|
|
1807
|
+
except SourceUsageError as exc:
|
|
1808
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1809
|
+
return 2
|
|
1810
|
+
except ValueError as exc:
|
|
1811
|
+
print(f"diff: {exc}", file=sys.stderr)
|
|
1812
|
+
return 2
|
|
1813
|
+
except RuntimeError as exc:
|
|
1814
|
+
print(str(exc), file=sys.stderr)
|
|
1815
|
+
return 1
|
|
1816
|
+
# The all-source calendar-window contract is resolved once here. The
|
|
1817
|
+
# established Claude renderer consumes the same absolute intervals through
|
|
1818
|
+
# its structured seam instead of attempting subscription-week anchoring.
|
|
1819
|
+
args._source_analytics_windows = (window_a, window_b)
|
|
1820
|
+
source = getattr(args, "source", "codex")
|
|
1821
|
+
claude_only = _diff_only_for_provider(getattr(args, "only", None), "claude")
|
|
1822
|
+
codex_only = _diff_only_for_provider(getattr(args, "only", None), "codex")
|
|
1823
|
+
if source == "all":
|
|
1824
|
+
if claude_only == "":
|
|
1825
|
+
claude = SourceResult("claude", "empty", {"sections": []})
|
|
1826
|
+
else:
|
|
1827
|
+
claude_args = copy(args)
|
|
1828
|
+
claude_args.only = claude_only
|
|
1829
|
+
claude = _run_claude_json_adapter(claude_args, "diff")
|
|
1830
|
+
else:
|
|
1831
|
+
claude = None
|
|
1832
|
+
start, end = min(window_a.start_at, window_b.start_at), max(window_a.end_at, window_b.end_at)
|
|
1833
|
+
requires_project_metadata = (
|
|
1834
|
+
codex_only is None or "projects" in codex_only.split(",")
|
|
1835
|
+
)
|
|
1836
|
+
try:
|
|
1837
|
+
entry_args = copy(args)
|
|
1838
|
+
entry_args._source_analytics_sync = bool(getattr(args, "sync", False))
|
|
1839
|
+
entries = _source_entries(
|
|
1840
|
+
entry_args, start, end, qualified=requires_project_metadata,
|
|
1841
|
+
)
|
|
1842
|
+
metadata_available = True
|
|
1843
|
+
except QualifiedMetadataUnavailable:
|
|
1844
|
+
try:
|
|
1845
|
+
entries = _source_entries(entry_args, start, end, qualified=False)
|
|
1846
|
+
except RuntimeError:
|
|
1847
|
+
return _emit_source_result(
|
|
1848
|
+
args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
|
|
1849
|
+
diff=True, claude=claude,
|
|
1850
|
+
)
|
|
1851
|
+
metadata_available = False
|
|
1852
|
+
except RuntimeError:
|
|
1853
|
+
return _emit_source_result(
|
|
1854
|
+
args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
|
|
1855
|
+
diff=True, claude=claude,
|
|
1856
|
+
)
|
|
1857
|
+
return _emit_source_result(
|
|
1858
|
+
args,
|
|
1859
|
+
build_codex_diff_result(
|
|
1860
|
+
entries, window_a, window_b,
|
|
1861
|
+
project_metadata_available=metadata_available,
|
|
1862
|
+
only=(codex_only if source == "all" else getattr(args, "only", None)),
|
|
1863
|
+
allow_mismatch=bool(getattr(args, "allow_mismatch", False)),
|
|
1864
|
+
show_all=bool(getattr(args, "show_all", False)),
|
|
1865
|
+
min_delta_usd=(
|
|
1866
|
+
0.10 if getattr(args, "min_delta_usd", None) is None
|
|
1867
|
+
else float(args.min_delta_usd)
|
|
1868
|
+
),
|
|
1869
|
+
min_delta_pct=(
|
|
1870
|
+
1.0 if getattr(args, "min_delta_pct", None) is None
|
|
1871
|
+
else float(args.min_delta_pct)
|
|
1872
|
+
),
|
|
1873
|
+
sort=str(getattr(args, "sort", "delta")),
|
|
1874
|
+
top=getattr(args, "top", None),
|
|
1875
|
+
normalization=normalization,
|
|
1876
|
+
classify_presence=True,
|
|
1877
|
+
),
|
|
1878
|
+
diff=True, claude=claude,
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def cmd_source_range_cost(args: object) -> int:
|
|
1883
|
+
_cctally()._share_validate_args(args)
|
|
1884
|
+
if getattr(args, "source", "codex") == "claude" and getattr(args, "format", None):
|
|
1885
|
+
return _emit_source_result(args, _run_claude_json_adapter(args, "range-cost"))
|
|
1886
|
+
start, end = _resolve_source_range_cost_range(args)
|
|
1887
|
+
try:
|
|
1888
|
+
validate_source_project_selectors(getattr(args, "project", None))
|
|
1889
|
+
except SourceUsageError as exc:
|
|
1890
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1891
|
+
return 2
|
|
1892
|
+
claude = (
|
|
1893
|
+
_run_claude_json_adapter(args, "range-cost")
|
|
1894
|
+
if getattr(args, "source", "codex") == "all" else None
|
|
1895
|
+
)
|
|
1896
|
+
try:
|
|
1897
|
+
entries = _source_entries(
|
|
1898
|
+
args, start, end, qualified=bool(getattr(args, "project", None)),
|
|
1899
|
+
inclusive_end=True,
|
|
1900
|
+
)
|
|
1901
|
+
except (QualifiedMetadataUnavailable, RuntimeError):
|
|
1902
|
+
return _emit_source_result(
|
|
1903
|
+
args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
|
|
1904
|
+
claude=claude,
|
|
1905
|
+
)
|
|
1906
|
+
try:
|
|
1907
|
+
entries = _filter_project_entries(entries, getattr(args, "project", None))
|
|
1908
|
+
except SourceUsageError as exc:
|
|
1909
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1910
|
+
return 2
|
|
1911
|
+
result = build_codex_range_result(
|
|
1912
|
+
entries, start, end, include_breakdown=bool(getattr(args, "breakdown", False)),
|
|
1913
|
+
)
|
|
1914
|
+
if getattr(args, "total_only", False) and not getattr(args, "format", None):
|
|
1915
|
+
codex_cost, _ = _codex_totals(result)
|
|
1916
|
+
if claude is not None:
|
|
1917
|
+
claude_cost, _ = _legacy_claude_totals(claude.data)
|
|
1918
|
+
codex_cost += claude_cost
|
|
1919
|
+
print(f"{codex_cost:.9f}")
|
|
1920
|
+
return 0
|
|
1921
|
+
return _emit_source_result(args, result, claude=claude)
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
def cmd_source_cache_report(args: object) -> int:
|
|
1925
|
+
_cctally()._share_validate_args(args)
|
|
1926
|
+
if getattr(args, "source", "codex") == "claude" and getattr(args, "format", None):
|
|
1927
|
+
return _emit_source_result(args, _run_claude_json_adapter(args, "cache-report"))
|
|
1928
|
+
start, end = _resolve_source_cache_range(args)
|
|
1929
|
+
try:
|
|
1930
|
+
validate_source_project_selectors(getattr(args, "project", None))
|
|
1931
|
+
except SourceUsageError as exc:
|
|
1932
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1933
|
+
return 2
|
|
1934
|
+
if getattr(args, "source", "codex") == "all":
|
|
1935
|
+
# ``reuse`` is Codex-only. The all-source Claude leg retains its
|
|
1936
|
+
# ordinary daily/session default rather than receiving an invalid
|
|
1937
|
+
# provider-specific sort; the Codex leg below still uses reuse-desc.
|
|
1938
|
+
claude_args = copy(args)
|
|
1939
|
+
if getattr(claude_args, "sort", None) == "reuse":
|
|
1940
|
+
claude_args.sort = None
|
|
1941
|
+
claude = _run_claude_json_adapter(claude_args, "cache-report")
|
|
1942
|
+
else:
|
|
1943
|
+
claude = None
|
|
1944
|
+
try:
|
|
1945
|
+
entry_args = copy(args)
|
|
1946
|
+
entry_args._source_analytics_sync = True
|
|
1947
|
+
entries = _source_entries(entry_args, start, end, qualified=True)
|
|
1948
|
+
metadata_available = True
|
|
1949
|
+
except QualifiedMetadataUnavailable:
|
|
1950
|
+
if getattr(args, "project", None):
|
|
1951
|
+
return _emit_source_result(
|
|
1952
|
+
args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
|
|
1953
|
+
claude=claude,
|
|
1954
|
+
)
|
|
1955
|
+
try:
|
|
1956
|
+
entries = _source_entries(entry_args, start, end, qualified=False)
|
|
1957
|
+
except RuntimeError:
|
|
1958
|
+
return _emit_source_result(
|
|
1959
|
+
args, SourceResult("codex", "unavailable", None, (QUALIFIED_METADATA_WARNING,)),
|
|
1960
|
+
claude=claude,
|
|
1961
|
+
)
|
|
1962
|
+
metadata_available = False
|
|
1963
|
+
try:
|
|
1964
|
+
entries = _filter_project_entries(entries, getattr(args, "project", None))
|
|
1965
|
+
except SourceUsageError as exc:
|
|
1966
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1967
|
+
return 2
|
|
1968
|
+
requested_sort = getattr(args, "sort", None)
|
|
1969
|
+
codex_sort = (
|
|
1970
|
+
requested_sort if requested_sort in {"date", "recent", "cost", "reuse"}
|
|
1971
|
+
else None
|
|
1972
|
+
)
|
|
1973
|
+
result = build_codex_reuse_result(
|
|
1974
|
+
entries,
|
|
1975
|
+
group_by=("session" if getattr(args, "by_session", False)
|
|
1976
|
+
else getattr(args, "group_by", "date")),
|
|
1977
|
+
project_metadata_available=metadata_available,
|
|
1978
|
+
sort=codex_sort,
|
|
1979
|
+
range_start=start,
|
|
1980
|
+
range_end=end,
|
|
1981
|
+
)
|
|
1982
|
+
return _emit_source_result(args, result, claude=claude)
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def cmd_source_report(args: object) -> int:
|
|
1986
|
+
_cctally()._share_validate_args(args)
|
|
1987
|
+
if getattr(args, "source", "codex") == "claude" and getattr(args, "format", None):
|
|
1988
|
+
return _emit_source_result(
|
|
1989
|
+
args, _run_claude_json_adapter(args, "report"), report=True,
|
|
1990
|
+
)
|
|
1991
|
+
as_of = _resolve_source_report_as_of(args)
|
|
1992
|
+
claude = (
|
|
1993
|
+
_run_claude_json_adapter(args, "report")
|
|
1994
|
+
if getattr(args, "source", "codex") == "all" else None
|
|
1995
|
+
)
|
|
1996
|
+
supplied_blocks = getattr(args, "blocks", None)
|
|
1997
|
+
already_synced = False
|
|
1998
|
+
force_direct_accounting = False
|
|
1999
|
+
if getattr(args, "sync_current", False) and supplied_blocks is None:
|
|
2000
|
+
c = _cctally()
|
|
2001
|
+
try:
|
|
2002
|
+
cache = c.open_cache_db()
|
|
2003
|
+
try:
|
|
2004
|
+
sync_stats = c.sync_codex_cache(cache)
|
|
2005
|
+
finally:
|
|
2006
|
+
cache.close()
|
|
2007
|
+
c.reconcile_codex_quota_projection(now=as_of)
|
|
2008
|
+
force_direct_accounting = bool(getattr(sync_stats, "lock_contended", False))
|
|
2009
|
+
already_synced = not force_direct_accounting
|
|
2010
|
+
except (OSError, sqlite3.Error, RuntimeError):
|
|
2011
|
+
return _emit_source_result(
|
|
2012
|
+
args, build_codex_report_result((), (), as_of=as_of, quota_available=False),
|
|
2013
|
+
report=True, claude=claude,
|
|
2014
|
+
)
|
|
2015
|
+
if supplied_blocks is None:
|
|
2016
|
+
try:
|
|
2017
|
+
blocks = select_codex_report_blocks(
|
|
2018
|
+
build_blocks(load_codex_quota_observations()),
|
|
2019
|
+
weeks=int(getattr(args, "weeks", 1)),
|
|
2020
|
+
)
|
|
2021
|
+
except (OSError, sqlite3.Error, ValueError):
|
|
2022
|
+
return _emit_source_result(
|
|
2023
|
+
args, build_codex_report_result((), (), as_of=as_of, quota_available=False),
|
|
2024
|
+
report=True, claude=claude,
|
|
2025
|
+
)
|
|
2026
|
+
else:
|
|
2027
|
+
blocks = tuple(supplied_blocks)
|
|
2028
|
+
if blocks:
|
|
2029
|
+
start = min(block.nominal_start_at for block in blocks)
|
|
2030
|
+
end = min(as_of, max(block.resets_at for block in blocks))
|
|
2031
|
+
try:
|
|
2032
|
+
entries = _source_entries(
|
|
2033
|
+
args, start, end, qualified=False, sync=not already_synced,
|
|
2034
|
+
force_direct=force_direct_accounting,
|
|
2035
|
+
)
|
|
2036
|
+
except RuntimeError:
|
|
2037
|
+
return _emit_source_result(
|
|
2038
|
+
args, build_codex_report_result((), (), as_of=as_of, quota_available=False),
|
|
2039
|
+
report=True, claude=claude,
|
|
2040
|
+
)
|
|
2041
|
+
else:
|
|
2042
|
+
entries = tuple(getattr(args, "source_entries", ()))
|
|
2043
|
+
return _emit_source_result(
|
|
2044
|
+
args,
|
|
2045
|
+
build_codex_report_result(
|
|
2046
|
+
entries, blocks, as_of=as_of,
|
|
2047
|
+
include_detail=bool(getattr(args, "detail", False)),
|
|
2048
|
+
),
|
|
2049
|
+
report=True, claude=claude,
|
|
2050
|
+
)
|