cctally 1.74.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 +13 -0
- package/bin/_cctally_cache.py +9 -0
- package/bin/_cctally_config.py +3 -3
- package/bin/_cctally_core.py +12 -3
- package/bin/_cctally_dashboard.py +33 -15
- package/bin/_cctally_dashboard_conversation.py +2 -2
- package/bin/_cctally_dashboard_sources.py +401 -18
- package/bin/_cctally_db.py +694 -1
- 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_statusline.py +3 -3
- package/bin/_cctally_tui.py +3 -0
- package/bin/_lib_aggregators.py +7 -0
- package/bin/_lib_cache_report.py +25 -9
- package/bin/_lib_conversation_retention.py +22 -2
- package/bin/_lib_doctor.py +4 -4
- package/bin/cctally +27 -1
- package/dashboard/static/assets/index-fZRxsSf5.js +80 -0
- package/dashboard/static/assets/{index-ZiPO8veo.css → index-yftBNnLR.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-DeQjEMO6.js +0 -80
package/bin/_cctally_quota.py
CHANGED
|
@@ -35,6 +35,7 @@ from _lib_quota import (
|
|
|
35
35
|
quota_rule_fingerprint,
|
|
36
36
|
quota_threshold_decisions,
|
|
37
37
|
percent_milestones,
|
|
38
|
+
physical_order_key,
|
|
38
39
|
resolve_quota_rule,
|
|
39
40
|
select_baseline,
|
|
40
41
|
source_path_key,
|
|
@@ -902,8 +903,10 @@ def reconcile_codex_quota_projection(
|
|
|
902
903
|
|
|
903
904
|
def _load_active_milestones(
|
|
904
905
|
identity: QuotaWindowIdentity, resets_at: dt.datetime,
|
|
905
|
-
|
|
906
|
-
|
|
906
|
+
*, stats_conn: sqlite3.Connection | None = None,
|
|
907
|
+
) -> list[sqlite3.Row | tuple[object, ...]]:
|
|
908
|
+
owns_conn = stats_conn is None
|
|
909
|
+
stats = _cctally_core.open_db() if stats_conn is None else stats_conn
|
|
907
910
|
try:
|
|
908
911
|
return list(stats.execute(
|
|
909
912
|
"""SELECT percent_threshold, captured_at_utc, source_path, line_offset
|
|
@@ -918,24 +921,62 @@ def _load_active_milestones(
|
|
|
918
921
|
),
|
|
919
922
|
))
|
|
920
923
|
finally:
|
|
921
|
-
|
|
924
|
+
if owns_conn:
|
|
925
|
+
stats.close()
|
|
922
926
|
|
|
923
927
|
|
|
924
|
-
def
|
|
928
|
+
def _first_block_physical_tuple(
|
|
925
929
|
identity: QuotaWindowIdentity, resets_at: dt.datetime,
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
930
|
+
*, cache_conn: sqlite3.Connection | None = None,
|
|
931
|
+
) -> tuple[dt.datetime, str, int] | None:
|
|
932
|
+
"""Read the first physical tuple for one exact projected block.
|
|
933
|
+
|
|
934
|
+
The prior implementation reconstructed every retained quota observation
|
|
935
|
+
for the root in Python just to discover this boundary. Keep the same
|
|
936
|
+
physical ordering while letting SQLite filter the exact identity/reset.
|
|
937
|
+
``unixepoch`` deliberately accepts retained ``Z`` and ``+00:00`` spellings.
|
|
938
|
+
"""
|
|
939
|
+
owns_conn = cache_conn is None
|
|
940
|
+
if cache_conn is None:
|
|
941
|
+
try:
|
|
942
|
+
cache = _cache_connection()
|
|
943
|
+
except (FileNotFoundError, sqlite3.Error):
|
|
944
|
+
return None
|
|
945
|
+
else:
|
|
946
|
+
cache = cache_conn
|
|
947
|
+
try:
|
|
948
|
+
row = cache.execute(
|
|
949
|
+
"""SELECT captured_at_utc, source_path, line_offset
|
|
950
|
+
FROM quota_window_snapshots
|
|
951
|
+
WHERE source='codex' AND source_root_key=?
|
|
952
|
+
AND logical_limit_key=? AND observed_slot=?
|
|
953
|
+
AND window_minutes=?
|
|
954
|
+
AND unixepoch(resets_at_utc)=unixepoch(?)
|
|
955
|
+
ORDER BY unixepoch(captured_at_utc), unixepoch(resets_at_utc),
|
|
956
|
+
source_path, line_offset
|
|
957
|
+
LIMIT 1""",
|
|
958
|
+
(
|
|
959
|
+
identity.source_root_key, identity.logical_limit_key,
|
|
960
|
+
identity.observed_slot, identity.window_minutes,
|
|
961
|
+
_utc_iso(resets_at),
|
|
962
|
+
),
|
|
963
|
+
).fetchone()
|
|
964
|
+
finally:
|
|
965
|
+
if owns_conn:
|
|
966
|
+
cache.close()
|
|
967
|
+
if row is None:
|
|
968
|
+
return None
|
|
969
|
+
try:
|
|
970
|
+
return (_parse_utc(str(row[0]), "captured_at_utc"), str(row[1]), int(row[2]))
|
|
971
|
+
except (TypeError, ValueError):
|
|
972
|
+
return None
|
|
933
973
|
|
|
934
974
|
|
|
935
975
|
def codex_quota_breakdown(
|
|
936
976
|
identity: QuotaWindowIdentity,
|
|
937
977
|
resets_at: str | dt.datetime,
|
|
938
|
-
*, speed: str = "auto",
|
|
978
|
+
*, speed: str = "auto", cache_conn: sqlite3.Connection | None = None,
|
|
979
|
+
stats_conn: sqlite3.Connection | None = None,
|
|
939
980
|
) -> tuple[CodexQuotaBreakdownRow, ...]:
|
|
940
981
|
"""Correlate durable milestone boundaries with live-priced cache accounting.
|
|
941
982
|
|
|
@@ -948,16 +989,34 @@ def codex_quota_breakdown(
|
|
|
948
989
|
if reset.tzinfo is None or reset.utcoffset() is None:
|
|
949
990
|
raise ValueError("resets_at must be timezone-aware")
|
|
950
991
|
reset = reset.astimezone(UTC)
|
|
951
|
-
|
|
952
|
-
if not points:
|
|
953
|
-
return ()
|
|
954
|
-
milestones = _load_active_milestones(identity, reset)
|
|
992
|
+
milestones = _load_active_milestones(identity, reset, stats_conn=stats_conn)
|
|
955
993
|
if not milestones:
|
|
956
994
|
return ()
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
995
|
+
owns_cache = cache_conn is None
|
|
996
|
+
if cache_conn is None:
|
|
997
|
+
try:
|
|
998
|
+
cache = _cache_connection()
|
|
999
|
+
except (FileNotFoundError, sqlite3.Error):
|
|
1000
|
+
return ()
|
|
1001
|
+
else:
|
|
1002
|
+
cache = cache_conn
|
|
1003
|
+
start = _first_block_physical_tuple(identity, reset, cache_conn=cache)
|
|
1004
|
+
if start is None:
|
|
1005
|
+
if owns_cache:
|
|
1006
|
+
cache.close()
|
|
960
1007
|
return ()
|
|
1008
|
+
# ``timestamp_utc`` is stored in canonical ``Z`` form, while retained
|
|
1009
|
+
# quota observations may have arrived as ``+00:00``. Keep this SQL bound
|
|
1010
|
+
# deliberately one second wider and let the physical-tuple comparison
|
|
1011
|
+
# below enforce the exact inclusive endpoint. This preserves same-second
|
|
1012
|
+
# path/offset ordering without relying on mixed-spelling text equality.
|
|
1013
|
+
query_start = (
|
|
1014
|
+
start[0] - dt.timedelta(seconds=1)
|
|
1015
|
+
).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
1016
|
+
query_end = (
|
|
1017
|
+
_parse_utc(str(milestones[-1][1]), "captured_at_utc")
|
|
1018
|
+
+ dt.timedelta(seconds=1)
|
|
1019
|
+
).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
961
1020
|
try:
|
|
962
1021
|
entries = []
|
|
963
1022
|
for row in cache.execute(
|
|
@@ -965,21 +1024,28 @@ def codex_quota_breakdown(
|
|
|
965
1024
|
input_tokens, cached_input_tokens, output_tokens,
|
|
966
1025
|
reasoning_output_tokens, total_tokens
|
|
967
1026
|
FROM codex_session_entries
|
|
968
|
-
WHERE source_root_key=?
|
|
969
|
-
|
|
1027
|
+
WHERE source_root_key=?
|
|
1028
|
+
AND timestamp_utc>=? AND timestamp_utc<=?""",
|
|
1029
|
+
(
|
|
1030
|
+
identity.source_root_key,
|
|
1031
|
+
query_start,
|
|
1032
|
+
query_end,
|
|
1033
|
+
),
|
|
970
1034
|
):
|
|
971
1035
|
try:
|
|
972
|
-
physical = (
|
|
973
|
-
|
|
1036
|
+
physical = (
|
|
1037
|
+
_parse_utc(str(row[0]), "timestamp_utc"),
|
|
1038
|
+
str(row[1]), int(row[2]),
|
|
1039
|
+
)
|
|
974
1040
|
except (TypeError, ValueError):
|
|
975
1041
|
continue
|
|
976
1042
|
entries.append((physical, row))
|
|
977
1043
|
finally:
|
|
978
|
-
|
|
1044
|
+
if owns_cache:
|
|
1045
|
+
cache.close()
|
|
979
1046
|
entries.sort(key=lambda pair: pair[0])
|
|
980
1047
|
resolved_speed = sys.modules["cctally"]._resolve_codex_speed(speed)
|
|
981
1048
|
calculate_cost = sys.modules["cctally"]._calculate_codex_entry_cost
|
|
982
|
-
start = _physical_tuple(points[0])
|
|
983
1049
|
prior_cumulative = 0.0
|
|
984
1050
|
cumulative_input = 0
|
|
985
1051
|
cumulative_cached = 0
|
|
@@ -989,20 +1055,19 @@ def codex_quota_breakdown(
|
|
|
989
1055
|
result: list[CodexQuotaBreakdownRow] = []
|
|
990
1056
|
for milestone in milestones:
|
|
991
1057
|
end = (
|
|
992
|
-
_parse_utc(str(milestone[
|
|
993
|
-
str(milestone[
|
|
1058
|
+
_parse_utc(str(milestone[1]), "captured_at_utc"),
|
|
1059
|
+
str(milestone[2]), int(milestone[3]),
|
|
994
1060
|
)
|
|
995
1061
|
selected = [row for physical, row in entries if start < physical <= end]
|
|
996
|
-
input_tokens = sum(int(row[
|
|
997
|
-
cached = sum(int(row[
|
|
998
|
-
output = sum(int(row[
|
|
999
|
-
reasoning = sum(int(row[
|
|
1000
|
-
total = sum(int(row[
|
|
1062
|
+
input_tokens = sum(int(row[4]) for row in selected)
|
|
1063
|
+
cached = sum(int(row[5]) for row in selected)
|
|
1064
|
+
output = sum(int(row[6]) for row in selected)
|
|
1065
|
+
reasoning = sum(int(row[7]) for row in selected)
|
|
1066
|
+
total = sum(int(row[8]) for row in selected)
|
|
1001
1067
|
marginal = sum(
|
|
1002
1068
|
calculate_cost(
|
|
1003
|
-
str(row[
|
|
1004
|
-
int(row[
|
|
1005
|
-
int(row["reasoning_output_tokens"]), speed=resolved_speed,
|
|
1069
|
+
str(row[3]), int(row[4]), int(row[5]), int(row[6]),
|
|
1070
|
+
int(row[7]), speed=resolved_speed,
|
|
1006
1071
|
)
|
|
1007
1072
|
for row in selected
|
|
1008
1073
|
)
|
|
@@ -1013,7 +1078,7 @@ def codex_quota_breakdown(
|
|
|
1013
1078
|
cumulative_reasoning += reasoning
|
|
1014
1079
|
cumulative_total += total
|
|
1015
1080
|
result.append(CodexQuotaBreakdownRow(
|
|
1016
|
-
percent=int(milestone[
|
|
1081
|
+
percent=int(milestone[0]), captured_at=end[0],
|
|
1017
1082
|
input_tokens=cumulative_input, cached_input_tokens=cumulative_cached,
|
|
1018
1083
|
output_tokens=cumulative_output, reasoning_output_tokens=cumulative_reasoning,
|
|
1019
1084
|
total_tokens=cumulative_total, cost_usd=cumulative,
|
|
@@ -1024,6 +1089,76 @@ def codex_quota_breakdown(
|
|
|
1024
1089
|
return tuple(result)
|
|
1025
1090
|
|
|
1026
1091
|
|
|
1092
|
+
def codex_five_hour_percent_at_crossing(
|
|
1093
|
+
identity: QuotaWindowIdentity,
|
|
1094
|
+
captured_at: dt.datetime,
|
|
1095
|
+
observations: Iterable[QuotaObservation] | None = None,
|
|
1096
|
+
*, cache_conn: sqlite3.Connection | None = None,
|
|
1097
|
+
) -> float | None:
|
|
1098
|
+
"""Return the latest matching native five-hour percent at one crossing."""
|
|
1099
|
+
if observations is not None:
|
|
1100
|
+
eligible = tuple(
|
|
1101
|
+
observation for observation in observations
|
|
1102
|
+
if observation.identity.source_root_key == identity.source_root_key
|
|
1103
|
+
and observation.identity.window_minutes == 300
|
|
1104
|
+
and observation.identity.observed_slot == identity.observed_slot
|
|
1105
|
+
and observation.identity.limit_id == identity.limit_id
|
|
1106
|
+
and observation.captured_at <= captured_at < observation.resets_at
|
|
1107
|
+
)
|
|
1108
|
+
if not eligible:
|
|
1109
|
+
return None
|
|
1110
|
+
return float(max(eligible, key=physical_order_key).used_percent)
|
|
1111
|
+
|
|
1112
|
+
owns_conn = cache_conn is None
|
|
1113
|
+
if cache_conn is None:
|
|
1114
|
+
try:
|
|
1115
|
+
cache = _cache_connection()
|
|
1116
|
+
except (FileNotFoundError, sqlite3.Error):
|
|
1117
|
+
return None
|
|
1118
|
+
else:
|
|
1119
|
+
cache = cache_conn
|
|
1120
|
+
# A valid 300-minute observation that still covers the crossing must have
|
|
1121
|
+
# been captured within the preceding native window. The extra hour keeps
|
|
1122
|
+
# seconds-level reset jitter and shortened/re-anchored blocks in range
|
|
1123
|
+
# while avoiding a root-wide history reconstruction.
|
|
1124
|
+
lower = (captured_at - dt.timedelta(hours=6)).astimezone(UTC).strftime(
|
|
1125
|
+
"%Y-%m-%dT%H:%M:%S"
|
|
1126
|
+
)
|
|
1127
|
+
upper = (captured_at + dt.timedelta(seconds=1)).astimezone(UTC).strftime(
|
|
1128
|
+
"%Y-%m-%dT%H:%M:%S"
|
|
1129
|
+
)
|
|
1130
|
+
try:
|
|
1131
|
+
rows = cache.execute(
|
|
1132
|
+
"""SELECT captured_at_utc, resets_at_utc, source_path, line_offset,
|
|
1133
|
+
used_percent
|
|
1134
|
+
FROM quota_window_snapshots
|
|
1135
|
+
WHERE source='codex' AND source_root_key=?
|
|
1136
|
+
AND window_minutes=300 AND observed_slot=? AND limit_id IS ?
|
|
1137
|
+
AND captured_at_utc>=? AND captured_at_utc<?""",
|
|
1138
|
+
(
|
|
1139
|
+
identity.source_root_key, identity.observed_slot,
|
|
1140
|
+
identity.limit_id, lower, upper,
|
|
1141
|
+
),
|
|
1142
|
+
).fetchall()
|
|
1143
|
+
finally:
|
|
1144
|
+
if owns_conn:
|
|
1145
|
+
cache.close()
|
|
1146
|
+
eligible: list[tuple[tuple[dt.datetime, dt.datetime, str, int], float]] = []
|
|
1147
|
+
for row in rows:
|
|
1148
|
+
try:
|
|
1149
|
+
observed_at = _parse_utc(str(row[0]), "captured_at_utc")
|
|
1150
|
+
resets_at = _parse_utc(str(row[1]), "resets_at_utc")
|
|
1151
|
+
physical = (observed_at, resets_at, str(row[2]), int(row[3]))
|
|
1152
|
+
used_percent = float(row[4])
|
|
1153
|
+
except (TypeError, ValueError, OverflowError):
|
|
1154
|
+
continue
|
|
1155
|
+
if observed_at <= captured_at < resets_at:
|
|
1156
|
+
eligible.append((physical, used_percent))
|
|
1157
|
+
if not eligible:
|
|
1158
|
+
return None
|
|
1159
|
+
return max(eligible, key=lambda item: item[0])[1]
|
|
1160
|
+
|
|
1161
|
+
|
|
1027
1162
|
# === Canonical nested `cctally codex quota` CLI ===========================
|
|
1028
1163
|
|
|
1029
1164
|
|
|
@@ -1167,23 +1302,40 @@ def _select_histories(
|
|
|
1167
1302
|
return selected
|
|
1168
1303
|
|
|
1169
1304
|
|
|
1170
|
-
def _sync_and_load(
|
|
1305
|
+
def _sync_and_load(
|
|
1306
|
+
args, as_of: dt.datetime, *, current_fresh_only: bool = False,
|
|
1307
|
+
reconcile_projection: bool = True,
|
|
1308
|
+
) -> tuple[QuotaHistory, ...]:
|
|
1171
1309
|
c = _cctally()
|
|
1172
|
-
|
|
1310
|
+
sync_requested = getattr(args, "sync", None)
|
|
1311
|
+
should_sync = (
|
|
1312
|
+
bool(sync_requested)
|
|
1313
|
+
if sync_requested is not None
|
|
1314
|
+
else not getattr(args, "no_sync", False)
|
|
1315
|
+
)
|
|
1316
|
+
if should_sync:
|
|
1173
1317
|
cache = c.open_cache_db()
|
|
1174
1318
|
try:
|
|
1175
1319
|
c.sync_codex_cache(cache)
|
|
1176
1320
|
finally:
|
|
1177
1321
|
cache.close()
|
|
1178
|
-
#
|
|
1179
|
-
#
|
|
1180
|
-
#
|
|
1181
|
-
|
|
1182
|
-
|
|
1322
|
+
# The nested quota leaves heal the durable projection on every read. The
|
|
1323
|
+
# peer percent-breakdown command instead mirrors Claude's materialized-read
|
|
1324
|
+
# default; its explicit --sync path still reconciles before rendering.
|
|
1325
|
+
if reconcile_projection:
|
|
1326
|
+
reconcile_codex_quota_projection(now=as_of)
|
|
1327
|
+
observations = load_codex_quota_observations(
|
|
1328
|
+
captured_at_or_after=(
|
|
1329
|
+
as_of - dt.timedelta(hours=1) if current_fresh_only else None
|
|
1330
|
+
),
|
|
1331
|
+
)
|
|
1183
1332
|
return build_history(observations)
|
|
1184
1333
|
|
|
1185
1334
|
|
|
1186
|
-
def _command_context(
|
|
1335
|
+
def _command_context(
|
|
1336
|
+
args, *, range_args: bool = False, current_fresh_only: bool = False,
|
|
1337
|
+
reconcile_projection: bool = True,
|
|
1338
|
+
):
|
|
1187
1339
|
c = _cctally()
|
|
1188
1340
|
config = c._load_claude_config_for_args(args)
|
|
1189
1341
|
display_tz = c._resolve_display_tz_obj(config)
|
|
@@ -1194,7 +1346,10 @@ def _command_context(args, *, range_args: bool = False):
|
|
|
1194
1346
|
until = _parse_range_bound(getattr(args, "until", None), display_tz=display_tz, option="--until")
|
|
1195
1347
|
if since is not None and until is not None and until <= since:
|
|
1196
1348
|
raise QuotaCLIError("--until must be after --since")
|
|
1197
|
-
histories = _sync_and_load(
|
|
1349
|
+
histories = _sync_and_load(
|
|
1350
|
+
args, as_of, current_fresh_only=current_fresh_only,
|
|
1351
|
+
reconcile_projection=reconcile_projection,
|
|
1352
|
+
)
|
|
1198
1353
|
selected = _select_histories(
|
|
1199
1354
|
histories,
|
|
1200
1355
|
root_key=getattr(args, "root_key", None),
|
|
@@ -1431,3 +1586,114 @@ def cmd_codex_quota_breakdown(args) -> int:
|
|
|
1431
1586
|
if not rows:
|
|
1432
1587
|
text_rows.append("No percent milestones.")
|
|
1433
1588
|
return _emit(args, payload, "\n".join(text_rows))
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def cmd_codex_percent_breakdown(args) -> int:
|
|
1592
|
+
"""Render one native seven-day Codex cycle in the Claude table design."""
|
|
1593
|
+
try:
|
|
1594
|
+
reset_value = getattr(args, "reset_at", None)
|
|
1595
|
+
as_of, _since, _until, selected = _command_context(
|
|
1596
|
+
args, current_fresh_only=not bool(reset_value),
|
|
1597
|
+
reconcile_projection=bool(getattr(args, "sync", False)),
|
|
1598
|
+
)
|
|
1599
|
+
histories = tuple(
|
|
1600
|
+
history for history in selected
|
|
1601
|
+
if history.identity.window_minutes == 10_080
|
|
1602
|
+
)
|
|
1603
|
+
if reset_value:
|
|
1604
|
+
reset_at = _parse_reset_at(reset_value)
|
|
1605
|
+
matching = tuple(
|
|
1606
|
+
(history, block)
|
|
1607
|
+
for history in histories
|
|
1608
|
+
for block in build_blocks(history.physical_observations)
|
|
1609
|
+
if block.resets_at == reset_at
|
|
1610
|
+
)
|
|
1611
|
+
else:
|
|
1612
|
+
matching = tuple(
|
|
1613
|
+
(history, block)
|
|
1614
|
+
for history in histories
|
|
1615
|
+
if (
|
|
1616
|
+
(baseline := select_baseline(history.observations, as_of))
|
|
1617
|
+
is not None
|
|
1618
|
+
and baseline.resets_at > as_of
|
|
1619
|
+
and quota_freshness(
|
|
1620
|
+
history.physical_observations, as_of,
|
|
1621
|
+
).state == "fresh"
|
|
1622
|
+
)
|
|
1623
|
+
for block in build_blocks(history.physical_observations)
|
|
1624
|
+
if block.resets_at == baseline.resets_at
|
|
1625
|
+
)
|
|
1626
|
+
if len(matching) != 1:
|
|
1627
|
+
raise QuotaCLIError(
|
|
1628
|
+
"percent-breakdown matches no unique native 7-day quota cycle; "
|
|
1629
|
+
"use exact selectors or --reset-at for retained history; candidates:\n"
|
|
1630
|
+
+ _candidate_text(histories)
|
|
1631
|
+
)
|
|
1632
|
+
except QuotaCLIError as exc:
|
|
1633
|
+
eprint(f"cctally codex percent-breakdown: {exc}")
|
|
1634
|
+
return 2
|
|
1635
|
+
|
|
1636
|
+
c = _cctally()
|
|
1637
|
+
speed = c._resolve_codex_speed(args.speed)
|
|
1638
|
+
history, block = matching[0]
|
|
1639
|
+
try:
|
|
1640
|
+
cache = _cache_connection()
|
|
1641
|
+
except (FileNotFoundError, sqlite3.Error):
|
|
1642
|
+
cache = None
|
|
1643
|
+
try:
|
|
1644
|
+
rows = codex_quota_breakdown(
|
|
1645
|
+
history.identity, block.resets_at, speed=speed, cache_conn=cache,
|
|
1646
|
+
)
|
|
1647
|
+
five_hour_observations = load_codex_quota_observations(
|
|
1648
|
+
source_root_keys={history.identity.source_root_key},
|
|
1649
|
+
cache_conn=cache,
|
|
1650
|
+
captured_at_or_after=(
|
|
1651
|
+
rows[0].captured_at - dt.timedelta(hours=6) if rows else as_of
|
|
1652
|
+
),
|
|
1653
|
+
)
|
|
1654
|
+
milestone_list = [
|
|
1655
|
+
{
|
|
1656
|
+
"percentThreshold": row.percent,
|
|
1657
|
+
"cumulativeCostUSD": round(row.cost_usd, 9),
|
|
1658
|
+
"marginalCostUSD": round(row.marginal_cost_usd, 9),
|
|
1659
|
+
"capturedAt": _iso_z(row.captured_at),
|
|
1660
|
+
"fiveHourPercentAtCrossing": (
|
|
1661
|
+
round(five_hour_percent, 1)
|
|
1662
|
+
if (five_hour_percent := codex_five_hour_percent_at_crossing(
|
|
1663
|
+
history.identity, row.captured_at, five_hour_observations,
|
|
1664
|
+
)) is not None
|
|
1665
|
+
else None
|
|
1666
|
+
),
|
|
1667
|
+
}
|
|
1668
|
+
for row in rows
|
|
1669
|
+
]
|
|
1670
|
+
finally:
|
|
1671
|
+
if cache is not None:
|
|
1672
|
+
cache.close()
|
|
1673
|
+
week_start = block.nominal_start_at.astimezone(UTC)
|
|
1674
|
+
week_end = block.resets_at.astimezone(UTC)
|
|
1675
|
+
output = {
|
|
1676
|
+
"source": "codex",
|
|
1677
|
+
"identity": _identity_wire(history.identity),
|
|
1678
|
+
"weekStartDate": week_start.date().isoformat(),
|
|
1679
|
+
"weekEndDate": (week_end - dt.timedelta(microseconds=1)).date().isoformat(),
|
|
1680
|
+
"weekStartAt": _iso_z(week_start),
|
|
1681
|
+
"weekEndAt": _iso_z(week_end),
|
|
1682
|
+
"milestones": milestone_list,
|
|
1683
|
+
"generatedAt": _iso_z(as_of),
|
|
1684
|
+
}
|
|
1685
|
+
if args.json:
|
|
1686
|
+
print(json.dumps(stamp_schema_version(output), indent=2))
|
|
1687
|
+
return 0
|
|
1688
|
+
|
|
1689
|
+
config = c._load_claude_config_for_args(args)
|
|
1690
|
+
tz = c.resolve_display_tz(args, config)
|
|
1691
|
+
print(c._render_percent_breakdown_terminal(
|
|
1692
|
+
week_start_date=output["weekStartDate"],
|
|
1693
|
+
week_end_date=output["weekEndDate"],
|
|
1694
|
+
display_start_iso=output["weekStartAt"],
|
|
1695
|
+
display_end_iso=output["weekEndAt"],
|
|
1696
|
+
milestone_list=milestone_list,
|
|
1697
|
+
tz=tz,
|
|
1698
|
+
))
|
|
1699
|
+
return 0
|
package/bin/_cctally_record.py
CHANGED
|
@@ -3593,6 +3593,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
3593
3593
|
# ``_resolve_active_five_hour_reset_event_id`` to find
|
|
3594
3594
|
# the active segment for the latest snapshot's window.
|
|
3595
3595
|
need_5h_heal = False
|
|
3596
|
+
incoming_block_saved: dict[str, Any] | None = None
|
|
3596
3597
|
window_key = latest_row["five_hour_window_key"]
|
|
3597
3598
|
if window_key is not None:
|
|
3598
3599
|
block_row = heal_conn.execute(
|
|
@@ -3647,10 +3648,55 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
3647
3648
|
existing_5h_m, latest_5h_floor
|
|
3648
3649
|
):
|
|
3649
3650
|
need_5h_heal = True
|
|
3651
|
+
# Window-rollover heal: this dedup tick observed a NEW 5h
|
|
3652
|
+
# window (its canonical ``five_hour_window_key`` differs
|
|
3653
|
+
# from the latest STORED snapshot's) whose
|
|
3654
|
+
# ``five_hour_blocks`` anchor does not exist yet. The dedup
|
|
3655
|
+
# above swallowed the snapshot insert because the weekly/5h
|
|
3656
|
+
# percents were flat, so ``latest_row`` still points at the
|
|
3657
|
+
# PREVIOUS window and the ``need_5h_heal`` probe only ever
|
|
3658
|
+
# checked that old (still-fresh) window — leaving the
|
|
3659
|
+
# current window unanchored. Materialize the missing block
|
|
3660
|
+
# now so ``blocks`` and the dashboard anchor the ACTIVE
|
|
3661
|
+
# block to its API-derived window instead of falling back to
|
|
3662
|
+
# the heuristic "~" until the percent next moves (the
|
|
3663
|
+
# statusline is unaffected — it renders the live
|
|
3664
|
+
# rate_limits, not the DB). Block-only: NO snapshot is
|
|
3665
|
+
# inserted (the tick stays deduped); the "latest snapshot"
|
|
3666
|
+
# weekly/5h surfaces and monotonicity clamps are untouched.
|
|
3667
|
+
# ``maybe_update_five_hour_block`` is an upsert keyed on
|
|
3668
|
+
# ``five_hour_window_key``, so later flat ticks in the same
|
|
3669
|
+
# window re-run it as an idempotent no-op.
|
|
3670
|
+
if (
|
|
3671
|
+
five_hour_window_key is not None
|
|
3672
|
+
and five_hour_percent is not None
|
|
3673
|
+
and five_hour_resets_at_str is not None
|
|
3674
|
+
and (
|
|
3675
|
+
window_key is None
|
|
3676
|
+
or int(window_key) != int(five_hour_window_key)
|
|
3677
|
+
)
|
|
3678
|
+
):
|
|
3679
|
+
incoming_block_row = heal_conn.execute(
|
|
3680
|
+
"SELECT 1 FROM five_hour_blocks "
|
|
3681
|
+
"WHERE five_hour_window_key = ? LIMIT 1",
|
|
3682
|
+
(int(five_hour_window_key),),
|
|
3683
|
+
).fetchone()
|
|
3684
|
+
if incoming_block_row is None:
|
|
3685
|
+
incoming_block_saved = {
|
|
3686
|
+
# ``id`` is extracted-but-unused by
|
|
3687
|
+
# maybe_update_five_hour_block; reuse latest_row's
|
|
3688
|
+
# for output-dict shape parity with a real insert.
|
|
3689
|
+
"id": int(latest_row["id"]),
|
|
3690
|
+
"capturedAt": now_utc_iso(),
|
|
3691
|
+
"weeklyPercent": weekly_percent,
|
|
3692
|
+
"fiveHourPercent": five_hour_percent,
|
|
3693
|
+
"fiveHourResetsAt": five_hour_resets_at_str,
|
|
3694
|
+
"fiveHourWindowKey": int(five_hour_window_key),
|
|
3695
|
+
}
|
|
3650
3696
|
finally:
|
|
3651
3697
|
heal_conn.close()
|
|
3652
3698
|
|
|
3653
|
-
if need_milestone_heal or need_5h_heal:
|
|
3699
|
+
if need_milestone_heal or need_5h_heal or incoming_block_saved:
|
|
3654
3700
|
if need_milestone_heal:
|
|
3655
3701
|
try:
|
|
3656
3702
|
maybe_record_milestone(latest_saved)
|
|
@@ -3661,6 +3707,11 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
3661
3707
|
maybe_update_five_hour_block(latest_saved)
|
|
3662
3708
|
except Exception as exc:
|
|
3663
3709
|
eprint(f"[5h-block] self-heal error: {exc}")
|
|
3710
|
+
if incoming_block_saved is not None:
|
|
3711
|
+
try:
|
|
3712
|
+
maybe_update_five_hour_block(incoming_block_saved)
|
|
3713
|
+
except Exception as exc:
|
|
3714
|
+
eprint(f"[5h-block] window-rollover heal error: {exc}")
|
|
3664
3715
|
|
|
3665
3716
|
# Dollar-decoupled axes (budget / project-budget / projected) heal on
|
|
3666
3717
|
# EVERY dedup tick — USD spend can cross a $ threshold while the
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -603,6 +603,28 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
603
603
|
if not token:
|
|
604
604
|
return _RefreshUsageResult(status="no_oauth_token", reason="no token")
|
|
605
605
|
|
|
606
|
+
# Force-refresh deliberately bypasses the pre-fetch backoff gate, but its
|
|
607
|
+
# resulting 429/success transition must be ordered with the automatic
|
|
608
|
+
# refresh writer. Hold the canonical selected-state lock from the fetch
|
|
609
|
+
# through authoritative publication and the matching backoff update.
|
|
610
|
+
try:
|
|
611
|
+
with c._selected_state_lock():
|
|
612
|
+
return _refresh_usage_inproc_locked(
|
|
613
|
+
c,
|
|
614
|
+
token=token,
|
|
615
|
+
timeout_seconds=timeout_seconds,
|
|
616
|
+
)
|
|
617
|
+
except OSError as exc:
|
|
618
|
+
return _RefreshUsageResult(status="record_failed", reason=str(exc))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _refresh_usage_inproc_locked(
|
|
622
|
+
c,
|
|
623
|
+
*,
|
|
624
|
+
token: str,
|
|
625
|
+
timeout_seconds: float,
|
|
626
|
+
) -> _RefreshUsageResult:
|
|
627
|
+
"""Force-refresh with the canonical selected-state lock already held."""
|
|
606
628
|
try:
|
|
607
629
|
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
608
630
|
except RefreshUsageRateLimitError as exc:
|
|
@@ -683,6 +705,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
683
705
|
"sevenDay",
|
|
684
706
|
*({"fiveHour"} if five_pct is not None else set()),
|
|
685
707
|
},
|
|
708
|
+
lock_held=True,
|
|
686
709
|
)
|
|
687
710
|
if authoritative.status != "ok":
|
|
688
711
|
return _RefreshUsageResult(
|
|
@@ -831,6 +854,9 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
831
854
|
|
|
832
855
|
if result.status == "record_failed":
|
|
833
856
|
reason = result.reason or ""
|
|
857
|
+
if c._is_sqlite_corruption_error(reason):
|
|
858
|
+
eprint(f"cctally: {c._stats_corruption_guidance()}")
|
|
859
|
+
return 3
|
|
834
860
|
if reason.startswith("exit "):
|
|
835
861
|
try:
|
|
836
862
|
rc = int(reason.split()[1])
|
|
@@ -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
|
@@ -2546,6 +2546,9 @@ def _tui_build_source_bundle(
|
|
|
2546
2546
|
week_start_name=semantics.week_start_name,
|
|
2547
2547
|
speed=semantics.speed,
|
|
2548
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,
|
|
2549
2552
|
),
|
|
2550
2553
|
data_version=codex_version,
|
|
2551
2554
|
)
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -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
|
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
|