cctally 1.73.0 → 1.75.0
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 +24 -0
- package/bin/_cctally_cache.py +35 -5
- package/bin/_cctally_config.py +3 -3
- package/bin/_cctally_core.py +12 -3
- package/bin/_cctally_dashboard.py +57 -5
- package/bin/_cctally_dashboard_conversation.py +2 -2
- package/bin/_cctally_dashboard_share.py +38 -3
- package/bin/_cctally_dashboard_sources.py +1199 -78
- package/bin/_cctally_db.py +798 -2
- package/bin/_cctally_doctor.py +27 -4
- package/bin/_cctally_parser.py +100 -6
- package/bin/_cctally_percent_breakdown.py +59 -34
- package/bin/_cctally_quota.py +311 -45
- package/bin/_cctally_record.py +52 -1
- package/bin/_cctally_refresh.py +26 -0
- package/bin/_cctally_source_analytics.py +288 -3
- package/bin/_cctally_statusline.py +3 -3
- package/bin/_cctally_tui.py +20 -14
- package/bin/_lib_aggregators.py +55 -34
- package/bin/_lib_cache_report.py +25 -9
- package/bin/_lib_conversation_retention.py +22 -2
- package/bin/_lib_dashboard_sources.py +20 -7
- package/bin/_lib_doctor.py +48 -4
- package/bin/_lib_jsonl.py +11 -0
- package/bin/_lib_pricing.py +7 -4
- package/bin/_lib_pricing_check.py +7 -1
- package/bin/_lib_quota.py +12 -11
- package/bin/_lib_view_models.py +50 -14
- package/bin/cctally +27 -1
- package/dashboard/static/assets/index-fZRxsSf5.js +80 -0
- package/dashboard/static/assets/{index-D2nwo_ln.css → index-yftBNnLR.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BWadAHxC.js +0 -80
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import datetime as dt
|
|
5
|
+
import dataclasses
|
|
5
6
|
import hashlib
|
|
6
7
|
import json
|
|
7
8
|
import pathlib
|
|
@@ -20,6 +21,7 @@ from _cctally_core import (
|
|
|
20
21
|
)
|
|
21
22
|
from _cctally_cache import _codex_provider_roots
|
|
22
23
|
from _lib_quota import build_blocks
|
|
24
|
+
from _lib_pricing import _calculate_codex_entry_cost
|
|
23
25
|
from _cctally_quota import load_codex_quota_observations
|
|
24
26
|
from _lib_source_analytics import (
|
|
25
27
|
AnalyticsWindow,
|
|
@@ -50,6 +52,37 @@ class SourceUsageError(ValueError):
|
|
|
50
52
|
"""A provider-aware request is syntactically or semantically invalid."""
|
|
51
53
|
|
|
52
54
|
|
|
55
|
+
@dataclasses.dataclass(frozen=True)
|
|
56
|
+
class CodexProjectMetadataHealth:
|
|
57
|
+
"""A root-qualified partition of retained Codex accounting metadata."""
|
|
58
|
+
|
|
59
|
+
total_rows: int
|
|
60
|
+
qualified_rows: int
|
|
61
|
+
missing_conversation_key_rows: int
|
|
62
|
+
missing_thread_join_rows: int
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def incomplete_rows(self) -> int:
|
|
66
|
+
return self.missing_conversation_key_rows + self.missing_thread_join_rows
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclasses.dataclass(frozen=True)
|
|
70
|
+
class RootedCodexAccountingEntry:
|
|
71
|
+
"""One cache-only Codex accounting row with authoritative root identity."""
|
|
72
|
+
|
|
73
|
+
timestamp: dt.datetime
|
|
74
|
+
session_id: str
|
|
75
|
+
source_path: str
|
|
76
|
+
source_root_key: str
|
|
77
|
+
model: str
|
|
78
|
+
input_tokens: int
|
|
79
|
+
cached_input_tokens: int
|
|
80
|
+
output_tokens: int
|
|
81
|
+
reasoning_output_tokens: int
|
|
82
|
+
total_tokens: int
|
|
83
|
+
cost_usd: float
|
|
84
|
+
|
|
85
|
+
|
|
53
86
|
_OPAQUE_PROJECT_KEY_RE = re.compile(r"^project:[0-9a-f]{24}$")
|
|
54
87
|
|
|
55
88
|
|
|
@@ -74,6 +107,18 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
|
|
|
74
107
|
"""
|
|
75
108
|
|
|
76
109
|
|
|
110
|
+
_INHERITED_CODEX_PROJECT_METADATA_SQL = """
|
|
111
|
+
SELECT files.source_root_key, files.path, inherited.cwd, inherited.git_json
|
|
112
|
+
FROM codex_session_files AS files
|
|
113
|
+
JOIN codex_conversation_threads AS inherited
|
|
114
|
+
ON inherited.source_root_key = files.source_root_key
|
|
115
|
+
AND inherited.native_thread_id = files.last_native_thread_id
|
|
116
|
+
WHERE files.last_native_thread_id IS NOT NULL
|
|
117
|
+
AND files.last_native_thread_id != ''
|
|
118
|
+
ORDER BY inherited.last_seen_utc DESC, inherited.conversation_key DESC
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
77
122
|
_CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
78
123
|
SELECT timestamp_utc, source_root_key, conversation_key, model,
|
|
79
124
|
input_tokens, cached_input_tokens, output_tokens,
|
|
@@ -86,6 +131,75 @@ _CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
|
86
131
|
"""
|
|
87
132
|
|
|
88
133
|
|
|
134
|
+
_ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL = """
|
|
135
|
+
SELECT timestamp_utc, session_id, source_path, source_root_key, model,
|
|
136
|
+
input_tokens, cached_input_tokens, output_tokens,
|
|
137
|
+
reasoning_output_tokens, total_tokens
|
|
138
|
+
FROM codex_session_entries INDEXED BY idx_codex_entries_ts_root_conversation
|
|
139
|
+
WHERE timestamp_utc >= ?
|
|
140
|
+
AND timestamp_utc < ?
|
|
141
|
+
{root_predicate}
|
|
142
|
+
ORDER BY timestamp_utc ASC, source_root_key ASC, conversation_key ASC, id ASC
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_CODEX_PROJECT_METADATA_HEALTH_SQL = """
|
|
147
|
+
SELECT
|
|
148
|
+
COUNT(*) AS total_rows,
|
|
149
|
+
COALESCE(SUM(CASE
|
|
150
|
+
WHEN entries.conversation_key IS NULL OR entries.conversation_key = ''
|
|
151
|
+
THEN 1 ELSE 0 END), 0) AS missing_conversation_key_rows,
|
|
152
|
+
COALESCE(SUM(CASE
|
|
153
|
+
WHEN entries.conversation_key IS NOT NULL
|
|
154
|
+
AND entries.conversation_key != ''
|
|
155
|
+
AND threads.conversation_key IS NULL
|
|
156
|
+
AND NOT EXISTS (
|
|
157
|
+
SELECT 1 FROM codex_session_files AS files
|
|
158
|
+
JOIN codex_conversation_threads AS inherited
|
|
159
|
+
ON inherited.source_root_key = entries.source_root_key
|
|
160
|
+
AND inherited.native_thread_id = files.last_native_thread_id
|
|
161
|
+
WHERE files.path = entries.source_path
|
|
162
|
+
AND files.source_root_key = entries.source_root_key
|
|
163
|
+
)
|
|
164
|
+
THEN 1 ELSE 0 END), 0) AS missing_thread_join_rows
|
|
165
|
+
FROM codex_session_entries AS entries
|
|
166
|
+
LEFT JOIN codex_conversation_threads AS threads
|
|
167
|
+
ON threads.conversation_key = entries.conversation_key
|
|
168
|
+
AND threads.source_root_key = entries.source_root_key
|
|
169
|
+
WHERE (? IS NULL OR entries.timestamp_utc >= ?)
|
|
170
|
+
AND (? IS NULL OR entries.timestamp_utc < ?)
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_CODEX_PROJECT_METADATA_HEALTH_LEGACY_SQL = """
|
|
175
|
+
SELECT
|
|
176
|
+
COUNT(*) AS total_rows,
|
|
177
|
+
COALESCE(SUM(CASE
|
|
178
|
+
WHEN entries.conversation_key IS NULL OR entries.conversation_key = ''
|
|
179
|
+
THEN 1 ELSE 0 END), 0) AS missing_conversation_key_rows,
|
|
180
|
+
COALESCE(SUM(CASE
|
|
181
|
+
WHEN entries.conversation_key IS NOT NULL
|
|
182
|
+
AND entries.conversation_key != ''
|
|
183
|
+
AND threads.conversation_key IS NULL
|
|
184
|
+
THEN 1 ELSE 0 END), 0) AS missing_thread_join_rows
|
|
185
|
+
FROM codex_session_entries AS entries
|
|
186
|
+
LEFT JOIN codex_conversation_threads AS threads
|
|
187
|
+
ON threads.conversation_key = entries.conversation_key
|
|
188
|
+
AND threads.source_root_key = entries.source_root_key
|
|
189
|
+
WHERE (? IS NULL OR entries.timestamp_utc >= ?)
|
|
190
|
+
AND (? IS NULL OR entries.timestamp_utc < ?)
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _supports_native_file_aliases(cache_conn: sqlite3.Connection) -> bool:
|
|
195
|
+
"""Return whether this cache generation can link child files to a root task."""
|
|
196
|
+
try:
|
|
197
|
+
columns = cache_conn.execute("PRAGMA table_info(codex_session_files)").fetchall()
|
|
198
|
+
except sqlite3.Error:
|
|
199
|
+
return False
|
|
200
|
+
return any(len(row) > 1 and row[1] == "last_native_thread_id" for row in columns)
|
|
201
|
+
|
|
202
|
+
|
|
89
203
|
def _cctally():
|
|
90
204
|
return sys.modules["cctally"]
|
|
91
205
|
|
|
@@ -100,6 +214,160 @@ def _parse_timestamp(value: object) -> dt.datetime:
|
|
|
100
214
|
return timestamp.astimezone(UTC)
|
|
101
215
|
|
|
102
216
|
|
|
217
|
+
def _metadata_health_bound(value: dt.datetime | None, *, name: str) -> str | None:
|
|
218
|
+
if value is None:
|
|
219
|
+
return None
|
|
220
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
221
|
+
raise ValueError(f"{name} must be timezone-aware")
|
|
222
|
+
# Retain the canonical ``+00:00`` representation written by Codex ingest
|
|
223
|
+
# and consumed by the accounting readers: SQLite TEXT bounds must sort in
|
|
224
|
+
# the same encoding for the [start, end) window to remain exact.
|
|
225
|
+
return value.astimezone(UTC).isoformat()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_codex_project_metadata_health(
|
|
229
|
+
*,
|
|
230
|
+
cache_conn: sqlite3.Connection,
|
|
231
|
+
start: dt.datetime | None = None,
|
|
232
|
+
end: dt.datetime | None = None,
|
|
233
|
+
) -> CodexProjectMetadataHealth:
|
|
234
|
+
"""Classify retained Codex accounting in one root-qualified SQL read.
|
|
235
|
+
|
|
236
|
+
Bounds are both timezone-aware or both omitted; the omitted form serves the
|
|
237
|
+
all-history doctor check. The output is an exhaustive, mutually-exclusive
|
|
238
|
+
count partition and contains no source or identity data.
|
|
239
|
+
"""
|
|
240
|
+
if (start is None) != (end is None):
|
|
241
|
+
raise ValueError("start and end must be supplied together or both omitted")
|
|
242
|
+
bound_start = _metadata_health_bound(start, name="start")
|
|
243
|
+
bound_end = _metadata_health_bound(end, name="end")
|
|
244
|
+
if start is not None and end is not None and start > end:
|
|
245
|
+
raise ValueError("start must not be after end")
|
|
246
|
+
|
|
247
|
+
row = cache_conn.execute(
|
|
248
|
+
(_CODEX_PROJECT_METADATA_HEALTH_SQL if _supports_native_file_aliases(cache_conn)
|
|
249
|
+
else _CODEX_PROJECT_METADATA_HEALTH_LEGACY_SQL),
|
|
250
|
+
(bound_start, bound_start, bound_end, bound_end),
|
|
251
|
+
).fetchone()
|
|
252
|
+
if row is None or len(row) != 3:
|
|
253
|
+
raise RuntimeError("Codex project metadata health query returned no partition")
|
|
254
|
+
try:
|
|
255
|
+
total_rows, missing_key_rows, missing_join_rows = (int(value) for value in row)
|
|
256
|
+
except (TypeError, ValueError) as exc:
|
|
257
|
+
raise RuntimeError("Codex project metadata health query returned invalid counts") from exc
|
|
258
|
+
qualified_rows = total_rows - missing_key_rows - missing_join_rows
|
|
259
|
+
values = (total_rows, qualified_rows, missing_key_rows, missing_join_rows)
|
|
260
|
+
if any(value < 0 for value in values) or total_rows != sum(values[1:]):
|
|
261
|
+
raise RuntimeError("Codex project metadata health partition is invalid")
|
|
262
|
+
return CodexProjectMetadataHealth(
|
|
263
|
+
total_rows=total_rows,
|
|
264
|
+
qualified_rows=qualified_rows,
|
|
265
|
+
missing_conversation_key_rows=missing_key_rows,
|
|
266
|
+
missing_thread_join_rows=missing_join_rows,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_cached_rooted_codex_accounting_entries(
|
|
271
|
+
start: dt.datetime,
|
|
272
|
+
end: dt.datetime,
|
|
273
|
+
*,
|
|
274
|
+
speed: str,
|
|
275
|
+
cache_conn: sqlite3.Connection,
|
|
276
|
+
source_root_keys: Iterable[str] | None = None,
|
|
277
|
+
) -> tuple[RootedCodexAccountingEntry, ...]:
|
|
278
|
+
"""Read bounded accounting from one caller-owned cache snapshot only.
|
|
279
|
+
|
|
280
|
+
This reader intentionally never syncs, opens or closes a database, reads
|
|
281
|
+
rollouts, resolves projects, or discovers configured Codex roots. The
|
|
282
|
+
cached root key plus source path are the dashboard's file identity. A
|
|
283
|
+
caller can additionally constrain the read to roots that established one
|
|
284
|
+
native cycle; root identities remain internal to this adapter.
|
|
285
|
+
"""
|
|
286
|
+
if start.tzinfo is None or start.utcoffset() is None:
|
|
287
|
+
raise ValueError("start must be timezone-aware")
|
|
288
|
+
if end.tzinfo is None or end.utcoffset() is None:
|
|
289
|
+
raise ValueError("end must be timezone-aware")
|
|
290
|
+
if end <= start:
|
|
291
|
+
raise ValueError("end must be after start")
|
|
292
|
+
root_keys = (
|
|
293
|
+
tuple(sorted({key for key in source_root_keys if isinstance(key, str) and key}))
|
|
294
|
+
if source_root_keys is not None else ()
|
|
295
|
+
)
|
|
296
|
+
if source_root_keys is not None and not root_keys:
|
|
297
|
+
return ()
|
|
298
|
+
root_predicate = (
|
|
299
|
+
"AND source_root_key IN (" + ", ".join("?" for _ in root_keys) + ")"
|
|
300
|
+
if source_root_keys is not None else ""
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
rows = tuple(cache_conn.execute(
|
|
304
|
+
_ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL.format(root_predicate=root_predicate),
|
|
305
|
+
(
|
|
306
|
+
start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(), *root_keys,
|
|
307
|
+
),
|
|
308
|
+
))
|
|
309
|
+
except sqlite3.Error as exc:
|
|
310
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
|
|
311
|
+
|
|
312
|
+
result: list[RootedCodexAccountingEntry] = []
|
|
313
|
+
for row in rows:
|
|
314
|
+
try:
|
|
315
|
+
(
|
|
316
|
+
timestamp_raw, session_id_raw, source_path_raw, source_root_key_raw,
|
|
317
|
+
model_raw, input_raw, cached_input_raw, output_raw, reasoning_raw,
|
|
318
|
+
total_raw,
|
|
319
|
+
) = row
|
|
320
|
+
source_path = source_path_raw if isinstance(source_path_raw, str) else ""
|
|
321
|
+
source_root_key = source_root_key_raw if isinstance(source_root_key_raw, str) else ""
|
|
322
|
+
if not source_path or not source_root_key:
|
|
323
|
+
raise ValueError("rooted accounting identity is absent")
|
|
324
|
+
model = str(model_raw)
|
|
325
|
+
input_tokens = int(input_raw)
|
|
326
|
+
cached_input_tokens = int(cached_input_raw)
|
|
327
|
+
output_tokens = int(output_raw)
|
|
328
|
+
reasoning_output_tokens = int(reasoning_raw)
|
|
329
|
+
total_tokens = int(total_raw)
|
|
330
|
+
cost_usd = _calculate_codex_entry_cost(
|
|
331
|
+
model,
|
|
332
|
+
input_tokens,
|
|
333
|
+
cached_input_tokens,
|
|
334
|
+
output_tokens,
|
|
335
|
+
reasoning_output_tokens,
|
|
336
|
+
speed=speed,
|
|
337
|
+
)
|
|
338
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
339
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
|
|
340
|
+
result.append(RootedCodexAccountingEntry(
|
|
341
|
+
timestamp=_parse_timestamp(timestamp_raw),
|
|
342
|
+
session_id=str(session_id_raw or ""),
|
|
343
|
+
source_path=source_path,
|
|
344
|
+
source_root_key=source_root_key,
|
|
345
|
+
model=model,
|
|
346
|
+
input_tokens=input_tokens,
|
|
347
|
+
cached_input_tokens=cached_input_tokens,
|
|
348
|
+
output_tokens=output_tokens,
|
|
349
|
+
reasoning_output_tokens=reasoning_output_tokens,
|
|
350
|
+
total_tokens=total_tokens,
|
|
351
|
+
cost_usd=cost_usd,
|
|
352
|
+
))
|
|
353
|
+
return tuple(result)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def has_cached_codex_accounting_entries(*, cache_conn: sqlite3.Connection) -> bool:
|
|
357
|
+
"""Return whether any retained Codex accounting exists without range filtering.
|
|
358
|
+
|
|
359
|
+
This intentionally tiny caller-owned cache read lets a missing native cycle
|
|
360
|
+
fail closed even when the dashboard's visible or budget range is empty.
|
|
361
|
+
It neither resolves roots nor reads rollout files.
|
|
362
|
+
"""
|
|
363
|
+
try:
|
|
364
|
+
return cache_conn.execute(
|
|
365
|
+
"SELECT 1 FROM codex_session_entries LIMIT 1"
|
|
366
|
+
).fetchone() is not None
|
|
367
|
+
except sqlite3.Error as exc:
|
|
368
|
+
raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
|
|
369
|
+
|
|
370
|
+
|
|
103
371
|
def _project_label(value: object) -> str:
|
|
104
372
|
"""Render only a basename-like label; absolute paths remain internal."""
|
|
105
373
|
raw = str(value).strip()
|
|
@@ -191,6 +459,12 @@ def load_qualified_codex_entries(
|
|
|
191
459
|
_QUALIFIED_CODEX_ENTRIES_SQL,
|
|
192
460
|
(start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat()),
|
|
193
461
|
))
|
|
462
|
+
inherited_metadata: dict[tuple[str, str], sqlite3.Row] = {}
|
|
463
|
+
if _supports_native_file_aliases(conn):
|
|
464
|
+
for inherited in conn.execute(_INHERITED_CODEX_PROJECT_METADATA_SQL):
|
|
465
|
+
identity = (str(inherited["source_root_key"] or ""), str(inherited["path"] or ""))
|
|
466
|
+
if all(identity):
|
|
467
|
+
inherited_metadata.setdefault(identity, inherited)
|
|
194
468
|
except sqlite3.Error as exc:
|
|
195
469
|
raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable") from exc
|
|
196
470
|
finally:
|
|
@@ -204,8 +478,19 @@ def load_qualified_codex_entries(
|
|
|
204
478
|
resolved_by_git_json: dict[str, str | None] = {}
|
|
205
479
|
result: list[QualifiedCodexEntry] = []
|
|
206
480
|
for row in rows:
|
|
207
|
-
root_key
|
|
208
|
-
|
|
481
|
+
root_key = row["source_root_key"]
|
|
482
|
+
conversation_key = row["conversation_key"]
|
|
483
|
+
inherited = inherited_metadata.get((str(root_key or ""), str(row["source_path"] or "")))
|
|
484
|
+
try:
|
|
485
|
+
root_key, conversation_key = _require_joined_metadata(row)
|
|
486
|
+
except QualifiedMetadataUnavailable:
|
|
487
|
+
if (
|
|
488
|
+
inherited is None
|
|
489
|
+
or not isinstance(root_key, str) or not root_key
|
|
490
|
+
or not isinstance(conversation_key, str) or not conversation_key
|
|
491
|
+
):
|
|
492
|
+
raise
|
|
493
|
+
cwd = row["cwd"] or (inherited["cwd"] if inherited is not None else None)
|
|
209
494
|
if isinstance(cwd, str) and cwd:
|
|
210
495
|
project = resolved_by_cwd.get(cwd)
|
|
211
496
|
if project is None:
|
|
@@ -218,7 +503,7 @@ def load_qualified_codex_entries(
|
|
|
218
503
|
else _project_label(project.display_key)
|
|
219
504
|
)
|
|
220
505
|
else:
|
|
221
|
-
git_json = row["git_json"]
|
|
506
|
+
git_json = row["git_json"] or (inherited["git_json"] if inherited is not None else None)
|
|
222
507
|
if isinstance(git_json, str) and git_json not in resolved_by_git_json:
|
|
223
508
|
resolved_by_git_json[git_json] = _git_resolved_key(git_json)
|
|
224
509
|
git_key = resolved_by_git_json.get(git_json) if isinstance(git_json, str) else None
|
|
@@ -940,9 +940,9 @@ def _authoritative_record_usage(
|
|
|
940
940
|
) -> _AuthoritativeRecordResult:
|
|
941
941
|
"""Record OAuth authority under write-ahead tombstones and reconcile it.
|
|
942
942
|
|
|
943
|
-
``lock_held`` is
|
|
944
|
-
lock across
|
|
945
|
-
All other callers acquire the same blocking lock here.
|
|
943
|
+
``lock_held`` is for OAuth refresh callers that already hold the selected
|
|
944
|
+
lock across their fetch, authoritative publication, and matching backoff
|
|
945
|
+
transition. All other callers acquire the same blocking lock here.
|
|
946
946
|
"""
|
|
947
947
|
if not lock_held:
|
|
948
948
|
try:
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -227,6 +227,7 @@ from _lib_fmt import stable_sum
|
|
|
227
227
|
# shared no-op singleton), so the _tui_build_snapshot seam wraps below cost
|
|
228
228
|
# nothing on the default path.
|
|
229
229
|
import _lib_perf as _perf
|
|
230
|
+
import _lib_log
|
|
230
231
|
|
|
231
232
|
import importlib.util as _ilu
|
|
232
233
|
|
|
@@ -261,7 +262,6 @@ from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, Bud
|
|
|
261
262
|
_ensure_sibling_loaded("_lib_dashboard_sources")
|
|
262
263
|
_ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
263
264
|
from _cctally_dashboard_sources import (
|
|
264
|
-
CodexProjectionIncoherent,
|
|
265
265
|
DashboardReadContext,
|
|
266
266
|
build_codex_source_state,
|
|
267
267
|
refresh_codex_source_clock,
|
|
@@ -2520,8 +2520,18 @@ def _tui_build_source_bundle(
|
|
|
2520
2520
|
else unavailable_source_state("codex", warning)
|
|
2521
2521
|
)
|
|
2522
2522
|
else:
|
|
2523
|
-
|
|
2524
|
-
|
|
2523
|
+
# Projection coherence can recover when S2 writes its certificate
|
|
2524
|
+
# without changing physical accounting. A current, hero-scoped
|
|
2525
|
+
# incoherence generation is intentionally not retained through
|
|
2526
|
+
# that repair opportunity; all other fresh partial states remain
|
|
2527
|
+
# eligible for exact reuse.
|
|
2528
|
+
codex = (
|
|
2529
|
+
None if prior_codex is not None and any(
|
|
2530
|
+
warning.code == "codex_projection_incoherent"
|
|
2531
|
+
for warning in prior_codex.warnings
|
|
2532
|
+
) else reuse_coherent_source_state(
|
|
2533
|
+
prior_codex, data_version=codex_version,
|
|
2534
|
+
)
|
|
2525
2535
|
)
|
|
2526
2536
|
if codex is None:
|
|
2527
2537
|
try:
|
|
@@ -2536,21 +2546,17 @@ def _tui_build_source_bundle(
|
|
|
2536
2546
|
week_start_name=semantics.week_start_name,
|
|
2537
2547
|
speed=semantics.speed,
|
|
2538
2548
|
codex_budget=semantics.codex_budget,
|
|
2549
|
+
codex_quota_actual_thresholds=semantics.codex_quota_actual_thresholds,
|
|
2550
|
+
codex_quota_projected_thresholds=semantics.codex_quota_projected_thresholds,
|
|
2551
|
+
cache_report_anomaly_threshold_pp=semantics.cache_report_anomaly_threshold_pp,
|
|
2539
2552
|
),
|
|
2540
2553
|
data_version=codex_version,
|
|
2541
2554
|
)
|
|
2542
|
-
except CodexProjectionIncoherent:
|
|
2543
|
-
warning = SourceDashboardWarning(
|
|
2544
|
-
"codex_projection_incoherent",
|
|
2545
|
-
"Codex quota projection is unavailable.",
|
|
2546
|
-
"quota",
|
|
2547
|
-
)
|
|
2548
|
-
codex = (
|
|
2549
|
-
degrade_source_state(prior_codex, warning)
|
|
2550
|
-
if prior_codex is not None
|
|
2551
|
-
else unavailable_source_state("codex", warning)
|
|
2552
|
-
)
|
|
2553
2555
|
except Exception:
|
|
2556
|
+
_lib_log.get_logger("dashboard").error(
|
|
2557
|
+
"codex_read_model source build failed",
|
|
2558
|
+
exc_info=True,
|
|
2559
|
+
)
|
|
2554
2560
|
warning = SourceDashboardWarning(
|
|
2555
2561
|
"source_build_failed", "Source data could not be built.", "read_model",
|
|
2556
2562
|
)
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -42,7 +42,7 @@ import os
|
|
|
42
42
|
import pathlib
|
|
43
43
|
import sys
|
|
44
44
|
from dataclasses import dataclass
|
|
45
|
-
from typing import Any, Callable
|
|
45
|
+
from typing import Any, Callable, Iterable
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def _cctally():
|
|
@@ -378,6 +378,13 @@ class CodexBucketUsage:
|
|
|
378
378
|
cost_usd: float
|
|
379
379
|
models: list[str] # Distinct full model names (first-seen order)
|
|
380
380
|
model_breakdowns: list[dict[str, Any]] # Sorted by cost desc
|
|
381
|
+
# Native quota-cycle context. Calendar daily/monthly callers leave these
|
|
382
|
+
# null; the dashboard's subscription-week adapter fills them from the
|
|
383
|
+
# retained OpenAI rate-limit observations.
|
|
384
|
+
period_start_at: dt.datetime | None = None
|
|
385
|
+
period_end_at: dt.datetime | None = None
|
|
386
|
+
used_pct: float | None = None
|
|
387
|
+
dollar_per_pct: float | None = None
|
|
381
388
|
|
|
382
389
|
|
|
383
390
|
@dataclass
|
|
@@ -414,6 +421,17 @@ class CodexSessionUsage:
|
|
|
414
421
|
codex_root: str = ""
|
|
415
422
|
|
|
416
423
|
|
|
424
|
+
@dataclass(frozen=True)
|
|
425
|
+
class CodexSessionIdentity:
|
|
426
|
+
"""Caller-supplied identity for the pure Codex session accumulator."""
|
|
427
|
+
|
|
428
|
+
group_key: tuple[str, str]
|
|
429
|
+
session_id_path: str
|
|
430
|
+
session_file: str
|
|
431
|
+
directory: str
|
|
432
|
+
codex_root: str
|
|
433
|
+
|
|
434
|
+
|
|
417
435
|
@dataclass
|
|
418
436
|
class ClaudeSessionUsage:
|
|
419
437
|
"""Aggregated Claude usage for one sessionId (may span multiple JSONL files)."""
|
|
@@ -619,41 +637,19 @@ def _codex_home_root_from_prefix(root_prefix: str) -> str:
|
|
|
619
637
|
return s
|
|
620
638
|
|
|
621
639
|
|
|
622
|
-
def
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
recent first), matching upstream's default view.
|
|
628
|
-
|
|
629
|
-
Per-model breakdowns include `isFallback: bool` — true when the model is
|
|
630
|
-
absent from CODEX_MODEL_PRICING.
|
|
631
|
-
"""
|
|
640
|
+
def _aggregate_codex_sessions_keyed(
|
|
641
|
+
entries: Iterable[tuple[CodexEntry, CodexSessionIdentity]],
|
|
642
|
+
speed: str = "standard",
|
|
643
|
+
) -> list[CodexSessionUsage]:
|
|
644
|
+
"""Pure Codex session arithmetic over caller-owned file identities."""
|
|
632
645
|
by_session: dict[tuple[str, str], dict[str, Any]] = {}
|
|
633
|
-
for entry in entries:
|
|
634
|
-
|
|
635
|
-
# Disambiguate identical relative paths under DIFFERENT $CODEX_HOME
|
|
636
|
-
# roots (issue #108). _session_path_parts strips the matched root, so
|
|
637
|
-
# <rootA>/sessions/2026/04/17/rollout-x.jsonl and the same relative
|
|
638
|
-
# path under <rootB> both yield id_path "2026/04/17/rollout-x";
|
|
639
|
-
# grouping on id_path alone would silently merge two distinct sessions
|
|
640
|
-
# (summed tokens, one UUID). Key on (root_prefix, id_path), where
|
|
641
|
-
# root_prefix is source_path with the id_path tail removed. Single-root
|
|
642
|
-
# data — and the bare-relative fixture form — has a constant prefix, so
|
|
643
|
-
# the grouping, insertion order, and every golden stay byte-identical;
|
|
644
|
-
# only a genuine cross-root collision splits into separate rows.
|
|
645
|
-
suffix = id_path + ".jsonl"
|
|
646
|
-
sp = entry.source_path
|
|
647
|
-
root_prefix = sp[: -len(suffix)] if sp.endswith(suffix) else sp
|
|
648
|
-
sess = by_session.setdefault((root_prefix, id_path), {
|
|
646
|
+
for entry, identity in entries:
|
|
647
|
+
sess = by_session.setdefault(identity.group_key, {
|
|
649
648
|
"session_id_uuid": entry.session_id,
|
|
650
|
-
"session_id_path":
|
|
651
|
-
"session_file":
|
|
652
|
-
"directory": directory,
|
|
653
|
-
|
|
654
|
-
# disambiguator. Derived from the same root_prefix that keys the
|
|
655
|
-
# group, so it's constant per group.
|
|
656
|
-
"codex_root": _codex_home_root_from_prefix(root_prefix),
|
|
649
|
+
"session_id_path": identity.session_id_path,
|
|
650
|
+
"session_file": identity.session_file,
|
|
651
|
+
"directory": identity.directory,
|
|
652
|
+
"codex_root": identity.codex_root,
|
|
657
653
|
"input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
|
|
658
654
|
"cost": 0.0, "models": {}, "models_order": [],
|
|
659
655
|
"last": entry.timestamp,
|
|
@@ -724,6 +720,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
724
720
|
return result
|
|
725
721
|
|
|
726
722
|
|
|
723
|
+
def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
|
|
724
|
+
"""Group CLI Codex entries by their existing filesystem-derived identity.
|
|
725
|
+
|
|
726
|
+
The wrapper preserves the historic CLI path parsing and byte-stable output;
|
|
727
|
+
only the accumulation itself lives in the keyed pure seam above.
|
|
728
|
+
"""
|
|
729
|
+
keyed: list[tuple[CodexEntry, CodexSessionIdentity]] = []
|
|
730
|
+
for entry in entries:
|
|
731
|
+
id_path, file_name, directory = _session_path_parts(entry.source_path)
|
|
732
|
+
# Disambiguate equal relative paths under separate configured roots.
|
|
733
|
+
suffix = id_path + ".jsonl"
|
|
734
|
+
source_path = entry.source_path
|
|
735
|
+
root_prefix = (
|
|
736
|
+
source_path[: -len(suffix)] if source_path.endswith(suffix) else source_path
|
|
737
|
+
)
|
|
738
|
+
keyed.append((entry, CodexSessionIdentity(
|
|
739
|
+
group_key=(root_prefix, id_path),
|
|
740
|
+
session_id_path=id_path,
|
|
741
|
+
session_file=file_name,
|
|
742
|
+
directory=directory,
|
|
743
|
+
codex_root=_codex_home_root_from_prefix(root_prefix),
|
|
744
|
+
)))
|
|
745
|
+
return _aggregate_codex_sessions_keyed(keyed, speed=speed)
|
|
746
|
+
|
|
747
|
+
|
|
727
748
|
def _aggregate_claude_sessions(
|
|
728
749
|
entries: list["_JoinedClaudeEntry"],
|
|
729
750
|
mode: str = "auto",
|
package/bin/_lib_cache_report.py
CHANGED
|
@@ -359,9 +359,17 @@ def _aggregate_cache_by_day(
|
|
|
359
359
|
cost = cost_calculator(entry.model, entry.usage, "auto", entry.cost_usd)
|
|
360
360
|
create_tok = entry.usage.get("cache_creation_input_tokens", 0)
|
|
361
361
|
read_tok = entry.usage.get("cache_read_input_tokens", 0)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
362
|
+
explicit_saved = getattr(entry, "cache_saved_usd", None)
|
|
363
|
+
explicit_wasted = getattr(entry, "cache_wasted_usd", None)
|
|
364
|
+
explicit_net = getattr(entry, "cache_net_usd", None)
|
|
365
|
+
if all(value is not None for value in (explicit_saved, explicit_wasted, explicit_net)):
|
|
366
|
+
saved, wasted, net = (
|
|
367
|
+
float(explicit_saved), float(explicit_wasted), float(explicit_net),
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
saved, wasted, net = _compute_entry_cache_dollars(
|
|
371
|
+
entry.model, create_tok, read_tok, pricing=pricing,
|
|
372
|
+
)
|
|
365
373
|
models = day_model_buckets.setdefault(day_key, {})
|
|
366
374
|
b = models.setdefault(entry.model, _Bucket())
|
|
367
375
|
b.input_tokens += entry.usage.get("input_tokens", 0)
|
|
@@ -792,12 +800,20 @@ def _aggregate_cache_breakdown(
|
|
|
792
800
|
b.input_tokens += getattr(e, "input_tokens", 0)
|
|
793
801
|
b.cache_creation_tokens += getattr(e, "cache_creation_tokens", 0)
|
|
794
802
|
b.cache_read_tokens += getattr(e, "cache_read_tokens", 0)
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
803
|
+
explicit_saved = getattr(e, "cache_saved_usd", None)
|
|
804
|
+
explicit_wasted = getattr(e, "cache_wasted_usd", None)
|
|
805
|
+
explicit_net = getattr(e, "cache_net_usd", None)
|
|
806
|
+
if all(value is not None for value in (explicit_saved, explicit_wasted, explicit_net)):
|
|
807
|
+
saved, wasted, net = (
|
|
808
|
+
float(explicit_saved), float(explicit_wasted), float(explicit_net),
|
|
809
|
+
)
|
|
810
|
+
else:
|
|
811
|
+
saved, wasted, net = _compute_entry_cache_dollars(
|
|
812
|
+
getattr(e, "model", ""),
|
|
813
|
+
getattr(e, "cache_creation_tokens", 0),
|
|
814
|
+
getattr(e, "cache_read_tokens", 0),
|
|
815
|
+
pricing=pricing,
|
|
816
|
+
)
|
|
801
817
|
b.saved_usd += saved
|
|
802
818
|
b.wasted_usd += wasted
|
|
803
819
|
b.net_usd += net
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""#313 P3:
|
|
1
|
+
"""#313 P3: conversation-transcript retention prune kernel.
|
|
2
2
|
|
|
3
3
|
Prunes ONLY the re-derivable transcript rows:
|
|
4
4
|
* Claude: ``conversation_messages`` + their ``conversation_file_touches`` /
|
|
@@ -63,7 +63,7 @@ def _cutoff_iso(cutoff_utc: dt.datetime) -> str:
|
|
|
63
63
|
``...Z`` timestamps.
|
|
64
64
|
|
|
65
65
|
Second-granular: the sub-second mixed-precision edge at the exact cutoff
|
|
66
|
-
second is immaterial for a
|
|
66
|
+
second is immaterial for a multi-day retention window — any message wrongly classified
|
|
67
67
|
there is re-derivable from JSONL and the boundary self-corrects on the next
|
|
68
68
|
(daily) prune.
|
|
69
69
|
"""
|
|
@@ -259,6 +259,26 @@ def _maybe_prune_conversation_retention(
|
|
|
259
259
|
except Exception:
|
|
260
260
|
conn.rollback()
|
|
261
261
|
raise
|
|
262
|
+
# Return the freed pages to the OS. On an INCREMENTAL auto-vacuum
|
|
263
|
+
# cache.db (the default for freshly-created DBs, #313 P3) this
|
|
264
|
+
# shrinks the file on disk instead of leaving a growing freelist,
|
|
265
|
+
# so the transcript prune reclaims space automatically without a
|
|
266
|
+
# manual `cctally db vacuum`; on a legacy auto_vacuum=NONE cache it
|
|
267
|
+
# is a harmless no-op (those still reclaim via `db vacuum` or a
|
|
268
|
+
# `cache-sync --rebuild`). Runs OUTSIDE the committed transaction,
|
|
269
|
+
# still under the maintenance + provider flocks (no concurrent
|
|
270
|
+
# writer), and best-effort — a reclaim error must never fail the
|
|
271
|
+
# already-durable prune.
|
|
272
|
+
if stats.total_rows > 0:
|
|
273
|
+
try:
|
|
274
|
+
# `.fetchall()` drives the pragma to completion — a bare
|
|
275
|
+
# execute() steps it once and reclaims a single page. This
|
|
276
|
+
# clears the freelist and drops page_count; the physical
|
|
277
|
+
# file shrinks on the next `wal_checkpoint(TRUNCATE)` the
|
|
278
|
+
# sync loop already forces (#297).
|
|
279
|
+
conn.execute("PRAGMA incremental_vacuum").fetchall()
|
|
280
|
+
except sqlite3.Error:
|
|
281
|
+
pass
|
|
262
282
|
return stats
|
|
263
283
|
finally:
|
|
264
284
|
try:
|