cctally 1.82.0 → 1.83.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 +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
package/bin/_cctally_update.py
CHANGED
|
@@ -1468,6 +1468,94 @@ def _resolved_install_target(state: dict, channel: str) -> "str | None":
|
|
|
1468
1468
|
return None
|
|
1469
1469
|
|
|
1470
1470
|
|
|
1471
|
+
def _beta_refresh_fallback_message(state: dict) -> str:
|
|
1472
|
+
"""Actionable diagnostic for an install-time beta refresh failure."""
|
|
1473
|
+
cached = state.get("latest_version")
|
|
1474
|
+
status = state.get("check_status") or "fetch_failed"
|
|
1475
|
+
detail = state.get("check_error")
|
|
1476
|
+
reason = f"{status}: {detail}" if detail else str(status)
|
|
1477
|
+
if cached:
|
|
1478
|
+
return (
|
|
1479
|
+
f"could not refresh beta update target ({reason}); using cached "
|
|
1480
|
+
f"{cached}. Check npm registry access or rerun with "
|
|
1481
|
+
"`cctally update --version X.Y.Z`."
|
|
1482
|
+
)
|
|
1483
|
+
return (
|
|
1484
|
+
f"could not refresh beta update target ({reason}); no cached target "
|
|
1485
|
+
"is available. Check npm registry access or rerun with "
|
|
1486
|
+
"`cctally update --version X.Y.Z`."
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _resolve_install_operation_target(
|
|
1491
|
+
method: InstallMethod,
|
|
1492
|
+
state: dict,
|
|
1493
|
+
channel: str,
|
|
1494
|
+
*,
|
|
1495
|
+
explicit_version: "str | None",
|
|
1496
|
+
persist_refresh: bool,
|
|
1497
|
+
) -> "tuple[str | None, dict, str | None]":
|
|
1498
|
+
"""Resolve one user-initiated install's truthful target.
|
|
1499
|
+
|
|
1500
|
+
Explicit pins and every non-beta-npm path are byte-preserving pass-throughs.
|
|
1501
|
+
An unpinned npm beta operation resolves the live SemVer-max(beta, latest)
|
|
1502
|
+
before no-op/downgrade decisions. Real installs reuse the canonical check
|
|
1503
|
+
pipeline (including marker-first and last-known-good persistence); dry-runs
|
|
1504
|
+
perform the same registry resolution against an in-memory state copy so
|
|
1505
|
+
their no-mutation contract remains exact.
|
|
1506
|
+
|
|
1507
|
+
Returns ``(target, effective_state, warning)``. A failed refresh falls back
|
|
1508
|
+
to the cached target when one exists and supplies an actionable warning.
|
|
1509
|
+
"""
|
|
1510
|
+
c = _cctally()
|
|
1511
|
+
if explicit_version is not None:
|
|
1512
|
+
return (explicit_version, state, None)
|
|
1513
|
+
if method.method != "npm" or channel != "beta":
|
|
1514
|
+
return (c._resolved_install_target(state, channel), state, None)
|
|
1515
|
+
|
|
1516
|
+
effective = dict(state)
|
|
1517
|
+
prior_cached_target = state.get("latest_version")
|
|
1518
|
+
warning = None
|
|
1519
|
+
if persist_refresh:
|
|
1520
|
+
c._do_update_check()
|
|
1521
|
+
effective = c._load_update_state() or effective
|
|
1522
|
+
if effective.get("check_status") != "ok":
|
|
1523
|
+
warning = _beta_refresh_fallback_message(effective)
|
|
1524
|
+
else:
|
|
1525
|
+
try:
|
|
1526
|
+
latest, dist_tag = c._resolve_npm_channel_target("beta")
|
|
1527
|
+
effective["latest_version"] = latest
|
|
1528
|
+
effective["latest_version_channel"] = "beta"
|
|
1529
|
+
effective["resolved_dist_tag"] = dist_tag
|
|
1530
|
+
except UpdateCheckRateLimited as e:
|
|
1531
|
+
effective["check_status"] = "rate_limited"
|
|
1532
|
+
effective["check_error"] = str(e)[:200]
|
|
1533
|
+
except (UpdateCheckNetworkError, UpdateCheckHTTPError) as e:
|
|
1534
|
+
effective["check_status"] = "fetch_failed"
|
|
1535
|
+
effective["check_error"] = str(e)[:200]
|
|
1536
|
+
except UpdateCheckParseError as e:
|
|
1537
|
+
effective["check_status"] = "parse_failed"
|
|
1538
|
+
effective["check_error"] = str(e)[:200]
|
|
1539
|
+
else:
|
|
1540
|
+
effective["check_status"] = "ok"
|
|
1541
|
+
effective["check_error"] = None
|
|
1542
|
+
if effective.get("check_status") != "ok":
|
|
1543
|
+
warning = _beta_refresh_fallback_message(effective)
|
|
1544
|
+
|
|
1545
|
+
# `_do_update_check` defaults a never-seen `latest_version` to the running
|
|
1546
|
+
# version so banner formatting remains comparable. That synthetic value is
|
|
1547
|
+
# not a last-known-good registry target and must not turn a failed first
|
|
1548
|
+
# install-time refresh into a misleading successful no-op.
|
|
1549
|
+
target = prior_cached_target if warning else effective.get("latest_version")
|
|
1550
|
+
if target is None:
|
|
1551
|
+
missing_cache_state = dict(effective)
|
|
1552
|
+
missing_cache_state.pop("latest_version", None)
|
|
1553
|
+
raise UpdateError(
|
|
1554
|
+
_beta_refresh_fallback_message(missing_cache_state)
|
|
1555
|
+
)
|
|
1556
|
+
return (target, effective, warning)
|
|
1557
|
+
|
|
1558
|
+
|
|
1471
1559
|
def _resolved_update_command(state: dict, config: "dict | None") -> str:
|
|
1472
1560
|
"""Channel-aware install command for the `--check` renderers / envelope.
|
|
1473
1561
|
|
|
@@ -1936,11 +2024,20 @@ def _do_update_install(
|
|
|
1936
2024
|
channel = c.resolve_update_channel(config)
|
|
1937
2025
|
state = c._load_update_state() or {}
|
|
1938
2026
|
|
|
1939
|
-
#
|
|
1940
|
-
#
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2027
|
+
# A user-initiated bare beta operation refreshes before exact-target
|
|
2028
|
+
# selection and before the no-op/downgrade guard. Dry-run resolves the same
|
|
2029
|
+
# live target without writing state or the throttle marker.
|
|
2030
|
+
resolved_version, state, refresh_warning = (
|
|
2031
|
+
_resolve_install_operation_target(
|
|
2032
|
+
method,
|
|
2033
|
+
state,
|
|
2034
|
+
channel,
|
|
2035
|
+
explicit_version=version,
|
|
2036
|
+
persist_refresh=not dry_run,
|
|
2037
|
+
)
|
|
2038
|
+
)
|
|
2039
|
+
if refresh_warning:
|
|
2040
|
+
print(f"Warning: {refresh_warning}", file=sys.stderr)
|
|
1944
2041
|
|
|
1945
2042
|
# Downgrade refusal for a BARE (unpinned) install: no-op + exit 0 when
|
|
1946
2043
|
# the resolved target is not SemVer-newer than the installed version.
|
|
@@ -2152,12 +2249,40 @@ class UpdateWorker:
|
|
|
2152
2249
|
log_fd = None
|
|
2153
2250
|
try:
|
|
2154
2251
|
method = c._detect_install_method(mutate=True)
|
|
2155
|
-
c.
|
|
2252
|
+
config = c.load_config()
|
|
2253
|
+
channel = c.resolve_update_channel(config)
|
|
2254
|
+
state = c._load_update_state() or {}
|
|
2255
|
+
resolved_version, _state, refresh_warning = (
|
|
2256
|
+
_resolve_install_operation_target(
|
|
2257
|
+
method,
|
|
2258
|
+
state,
|
|
2259
|
+
channel,
|
|
2260
|
+
explicit_version=version,
|
|
2261
|
+
persist_refresh=True,
|
|
2262
|
+
)
|
|
2263
|
+
)
|
|
2264
|
+
if refresh_warning:
|
|
2265
|
+
self._emit(
|
|
2266
|
+
run_id,
|
|
2267
|
+
{"type": "stderr", "data": f"Warning: {refresh_warning}"},
|
|
2268
|
+
)
|
|
2269
|
+
if resolved_version is not None:
|
|
2270
|
+
self._emit(
|
|
2271
|
+
run_id,
|
|
2272
|
+
{
|
|
2273
|
+
"type": "target",
|
|
2274
|
+
"version": resolved_version,
|
|
2275
|
+
"command": c._format_update_command(
|
|
2276
|
+
method.method, resolved_version
|
|
2277
|
+
),
|
|
2278
|
+
},
|
|
2279
|
+
)
|
|
2280
|
+
c._preflight_install(method, resolved_version)
|
|
2156
2281
|
_cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
2157
2282
|
lock_fd = c._acquire_update_lock()
|
|
2158
2283
|
log_fd = open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8")
|
|
2159
2284
|
_log_update_event(log_fd, "INSTALL_START", method=method.method)
|
|
2160
|
-
for step_name, cmd in c._build_update_steps(method,
|
|
2285
|
+
for step_name, cmd in c._build_update_steps(method, resolved_version):
|
|
2161
2286
|
self._emit(run_id, {"type": "step", "name": step_name})
|
|
2162
2287
|
_log_update_event(log_fd, "STEP_START", name=step_name)
|
|
2163
2288
|
rc = c._run_streaming(
|
|
@@ -2176,7 +2301,7 @@ class UpdateWorker:
|
|
|
2176
2301
|
self._emit(run_id, {"type": "done", "success": False})
|
|
2177
2302
|
return
|
|
2178
2303
|
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
2179
|
-
c._stamp_install_success_to_state(
|
|
2304
|
+
c._stamp_install_success_to_state(resolved_version, method)
|
|
2180
2305
|
entrypoint, exec_argv = c._resolve_execvp_target()
|
|
2181
2306
|
self._emit(run_id, {"type": "execvp", "argv": exec_argv})
|
|
2182
2307
|
try:
|
package/bin/_cctally_weekrefs.py
CHANGED
|
@@ -576,6 +576,7 @@ def _week_ref_has_reset_event(
|
|
|
576
576
|
|
|
577
577
|
def _compute_cost_for_weekref(
|
|
578
578
|
ref: WeekRef, *, skip_sync: bool = False, account_key: "str | None" = None,
|
|
579
|
+
as_of: "str | None" = None,
|
|
579
580
|
) -> float | None:
|
|
580
581
|
"""Live-compute USD cost over `ref`'s (possibly reset-adjusted) range
|
|
581
582
|
straight from session_entries. Mirrors what cmd_sync_week writes into
|
|
@@ -588,6 +589,11 @@ def _compute_cost_for_weekref(
|
|
|
588
589
|
once at the top of the rebuild); ``build_trend_view`` calls this once per
|
|
589
590
|
reset-event week, so without the flag each reset week re-globbed the whole
|
|
590
591
|
``~/.claude/projects`` tree — the CPU peg the sync-once refactor removes.
|
|
592
|
+
|
|
593
|
+
``as_of`` (#410 Task A) is the retained triggering observation clock. The
|
|
594
|
+
reset-adjusted range end is clamped to it before the cache query so this
|
|
595
|
+
alternate milestone-cost path has the same replay boundary as
|
|
596
|
+
``compute_week_cost(as_of=...)``.
|
|
591
597
|
"""
|
|
592
598
|
c = _cctally()
|
|
593
599
|
if not ref.week_start_at or not ref.week_end_at:
|
|
@@ -599,6 +605,14 @@ def _compute_cost_for_weekref(
|
|
|
599
605
|
return None
|
|
600
606
|
if end <= start:
|
|
601
607
|
return 0.0
|
|
608
|
+
if as_of is not None:
|
|
609
|
+
try:
|
|
610
|
+
retained_end = parse_iso_datetime(as_of, "asOf")
|
|
611
|
+
except ValueError:
|
|
612
|
+
return None
|
|
613
|
+
end = min(end, retained_end)
|
|
614
|
+
if end <= start:
|
|
615
|
+
return 0.0
|
|
602
616
|
return c._sum_cost_for_range(
|
|
603
617
|
start, end, mode="auto", skip_sync=skip_sync, account_key=account_key)
|
|
604
618
|
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -69,6 +69,8 @@ CodexEntry = _lib_jsonl.CodexEntry
|
|
|
69
69
|
|
|
70
70
|
_lib_pricing = _load_lib("_lib_pricing")
|
|
71
71
|
_calculate_entry_cost = _lib_pricing._calculate_entry_cost
|
|
72
|
+
# #195: the single construction point for every cost-feeding usage dict.
|
|
73
|
+
claude_usage_dict = _lib_pricing.claude_usage_dict
|
|
72
74
|
_calculate_codex_entry_cost = _lib_pricing._calculate_codex_entry_cost
|
|
73
75
|
_is_codex_fallback = _lib_pricing._is_codex_fallback
|
|
74
76
|
|
|
@@ -800,12 +802,14 @@ def _aggregate_claude_sessions(
|
|
|
800
802
|
if entry.project_path:
|
|
801
803
|
sess["project_path"] = entry.project_path
|
|
802
804
|
|
|
803
|
-
usage =
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
805
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
806
|
+
input_tokens=entry.input_tokens,
|
|
807
|
+
output_tokens=entry.output_tokens,
|
|
808
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
809
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
810
|
+
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
811
|
+
speed=getattr(entry, "speed", None),
|
|
812
|
+
)
|
|
809
813
|
cost = _calculate_entry_cost(entry.model, usage, mode=mode, cost_usd=entry.cost_usd)
|
|
810
814
|
|
|
811
815
|
sess["input"] += entry.input_tokens
|
package/bin/_lib_cache_report.py
CHANGED
|
@@ -56,6 +56,52 @@ def _import_stable_sum():
|
|
|
56
56
|
stable_sum = _import_stable_sum()
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def _import_pricing_kernel():
|
|
60
|
+
"""Resolve the ``_lib_pricing`` symbols this kernel needs (#195/#413):
|
|
61
|
+
``CACHE_WRITE_1H_MULTIPLIER`` (the derived 1-hour cache-write rate) and
|
|
62
|
+
``claude_usage_dict`` (the single cost-feeding usage-dict constructor).
|
|
63
|
+
|
|
64
|
+
Same shape and same justification as ``_import_stable_sum`` above:
|
|
65
|
+
``_lib_pricing`` is a PURE stdlib leaf with no sibling imports, so binding
|
|
66
|
+
it here is acyclic and leaves this kernel's purity contract (no I/O, no
|
|
67
|
+
logging, no environment reads, no SQLite) intact. In practice both loaders
|
|
68
|
+
of this file have already imported ``_lib_pricing``, so the ``sys.modules``
|
|
69
|
+
fast path is what runs; the path-load fallback exists for a bare
|
|
70
|
+
file-path load.
|
|
71
|
+
"""
|
|
72
|
+
import sys
|
|
73
|
+
if "_lib_pricing" in sys.modules:
|
|
74
|
+
m = sys.modules["_lib_pricing"]
|
|
75
|
+
return (
|
|
76
|
+
m.CACHE_WRITE_1H_MULTIPLIER,
|
|
77
|
+
m._claude_fast_multiplier,
|
|
78
|
+
m.claude_usage_dict,
|
|
79
|
+
)
|
|
80
|
+
from pathlib import Path
|
|
81
|
+
import importlib.util
|
|
82
|
+
bin_dir = Path(__file__).resolve().parent
|
|
83
|
+
if str(bin_dir) not in sys.path:
|
|
84
|
+
sys.path.insert(0, str(bin_dir))
|
|
85
|
+
spec = importlib.util.spec_from_file_location(
|
|
86
|
+
"_lib_pricing", bin_dir / "_lib_pricing.py")
|
|
87
|
+
m = importlib.util.module_from_spec(spec)
|
|
88
|
+
sys.modules["_lib_pricing"] = m
|
|
89
|
+
try:
|
|
90
|
+
spec.loader.exec_module(m)
|
|
91
|
+
except Exception:
|
|
92
|
+
sys.modules.pop("_lib_pricing", None)
|
|
93
|
+
raise
|
|
94
|
+
return (
|
|
95
|
+
m.CACHE_WRITE_1H_MULTIPLIER,
|
|
96
|
+
m._claude_fast_multiplier,
|
|
97
|
+
m.claude_usage_dict,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
(CACHE_WRITE_1H_MULTIPLIER, _claude_fast_multiplier,
|
|
102
|
+
claude_usage_dict) = _import_pricing_kernel()
|
|
103
|
+
|
|
104
|
+
|
|
59
105
|
# Anthropic's per-call >200K-tokens tier — kept in sync with bin/_lib_pricing.
|
|
60
106
|
# Callers may override via the ``tiered_threshold`` kwarg.
|
|
61
107
|
DEFAULT_TIERED_THRESHOLD = 200_000
|
|
@@ -232,13 +278,18 @@ def _compute_entry_cache_dollars(
|
|
|
232
278
|
*,
|
|
233
279
|
pricing: dict,
|
|
234
280
|
tiered_threshold: int = DEFAULT_TIERED_THRESHOLD,
|
|
281
|
+
cache_1h_tokens: int | None = None,
|
|
282
|
+
speed=None,
|
|
235
283
|
) -> tuple[float, float, float]:
|
|
236
284
|
"""Return ``(saved_usd, wasted_usd, net_usd)`` for a single entry.
|
|
237
285
|
|
|
238
286
|
``saved_usd`` = ``cache_read_tokens × (base_rate − read_rate)``
|
|
239
287
|
— what you'd have paid without caching.
|
|
240
|
-
``wasted_usd`` =
|
|
241
|
-
|
|
288
|
+
``wasted_usd`` = the cache-WRITE premium over base input, priced by TTL
|
|
289
|
+
(#195): the 1-hour portion against ``2 ×`` base input, the REMAINDER
|
|
290
|
+
against today's create rate. ``cache_1h_tokens is None`` (split
|
|
291
|
+
unknown) and ``== 0`` (all-5m) both take the verbatim pre-#195
|
|
292
|
+
expression, so this stays byte-identical for every pre-split row.
|
|
242
293
|
``net_usd`` = ``saved_usd − wasted_usd``. Positive = caching helped.
|
|
243
294
|
|
|
244
295
|
Applies Anthropic's per-call >200K-tokens tier (mirrors the
|
|
@@ -289,8 +340,29 @@ def _compute_entry_cache_dollars(
|
|
|
289
340
|
"cache_creation_input_token_cost_above_200k_tokens",
|
|
290
341
|
)
|
|
291
342
|
|
|
343
|
+
multiplier = (
|
|
344
|
+
_claude_fast_multiplier(model) if speed == "fast" else 1.0
|
|
345
|
+
)
|
|
346
|
+
if multiplier != 1.0:
|
|
347
|
+
base_for_read *= multiplier
|
|
348
|
+
read_rate *= multiplier
|
|
349
|
+
base_for_create *= multiplier
|
|
350
|
+
create_rate *= multiplier
|
|
351
|
+
|
|
292
352
|
saved = cache_read_tokens * max(0.0, base_for_read - read_rate)
|
|
293
|
-
|
|
353
|
+
# #195: the write premium is TTL-dependent. Split the creation tokens the
|
|
354
|
+
# same way _cache_create_cost does — 1h portion against the 2x rate, the
|
|
355
|
+
# REMAINDER against today's create_rate — so Wasted $/Net $ cannot disagree
|
|
356
|
+
# with the total cost the pricing kernel reports. Cache reads remain 0.1x
|
|
357
|
+
# of the effective base rate, so fast mode scales their absolute savings.
|
|
358
|
+
h = 0 if cache_1h_tokens is None else max(
|
|
359
|
+
0, min(int(cache_1h_tokens), cache_creation_tokens))
|
|
360
|
+
if h == 0:
|
|
361
|
+
wasted = cache_creation_tokens * max(0.0, create_rate - base_for_create)
|
|
362
|
+
else:
|
|
363
|
+
rate_1h = base_for_create * CACHE_WRITE_1H_MULTIPLIER
|
|
364
|
+
wasted = (h * max(0.0, rate_1h - base_for_create)
|
|
365
|
+
+ (cache_creation_tokens - h) * max(0.0, create_rate - base_for_create))
|
|
294
366
|
net = saved - wasted
|
|
295
367
|
return (saved, wasted, net)
|
|
296
368
|
|
|
@@ -369,6 +441,10 @@ def _aggregate_cache_by_day(
|
|
|
369
441
|
else:
|
|
370
442
|
saved, wasted, net = _compute_entry_cache_dollars(
|
|
371
443
|
entry.model, create_tok, read_tok, pricing=pricing,
|
|
444
|
+
# #195: the normalized flat key the ingest chokepoint writes;
|
|
445
|
+
# absent on a pre-split row -> None -> unchanged pricing.
|
|
446
|
+
cache_1h_tokens=entry.usage.get("cache_creation_1h_input_tokens"),
|
|
447
|
+
speed=entry.usage.get("speed"),
|
|
372
448
|
)
|
|
373
449
|
models = day_model_buckets.setdefault(day_key, {})
|
|
374
450
|
b = models.setdefault(entry.model, _Bucket())
|
|
@@ -522,12 +598,15 @@ def _aggregate_cache_by_session(
|
|
|
522
598
|
mb_raw.cache_read_tokens += entry.cache_read_tokens
|
|
523
599
|
mb_raw.cost += cost_calculator(
|
|
524
600
|
entry.model,
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
601
|
+
claude_usage_dict( # #195 chokepoint
|
|
602
|
+
input_tokens=entry.input_tokens,
|
|
603
|
+
output_tokens=entry.output_tokens,
|
|
604
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
605
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
606
|
+
# getattr: duck-typed entry contract (see below).
|
|
607
|
+
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
608
|
+
speed=getattr(entry, "speed", None),
|
|
609
|
+
),
|
|
531
610
|
"auto",
|
|
532
611
|
entry.cost_usd,
|
|
533
612
|
)
|
|
@@ -536,6 +615,13 @@ def _aggregate_cache_by_session(
|
|
|
536
615
|
entry.cache_creation_tokens,
|
|
537
616
|
entry.cache_read_tokens,
|
|
538
617
|
pricing=pricing,
|
|
618
|
+
# #195: getattr, not attribute access. This is a PURE kernel with
|
|
619
|
+
# a duck-typed entry contract — callers pass `_JoinedClaudeEntry`
|
|
620
|
+
# in production and lightweight stand-ins in tests, so a hard
|
|
621
|
+
# `.cache_1h_tokens` would break every stand-in. Default None ==
|
|
622
|
+
# split unknown, which prices exactly as before.
|
|
623
|
+
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
624
|
+
speed=getattr(entry, "speed", None),
|
|
539
625
|
)
|
|
540
626
|
mb_raw.saved_usd += saved
|
|
541
627
|
mb_raw.wasted_usd += wasted
|
|
@@ -813,6 +899,10 @@ def _aggregate_cache_breakdown(
|
|
|
813
899
|
getattr(e, "cache_creation_tokens", 0),
|
|
814
900
|
getattr(e, "cache_read_tokens", 0),
|
|
815
901
|
pricing=pricing,
|
|
902
|
+
# #195: getattr default None == split unknown, so an entry type
|
|
903
|
+
# that does not carry the split prices exactly as before.
|
|
904
|
+
cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
|
|
905
|
+
speed=getattr(e, "speed", None),
|
|
816
906
|
)
|
|
817
907
|
b.saved_usd += saved
|
|
818
908
|
b.wasted_usd += wasted
|
|
@@ -921,6 +1011,8 @@ def aggregate_by_day_project(
|
|
|
921
1011
|
read_tok = getattr(e, "cache_read_tokens", 0)
|
|
922
1012
|
_s, _w, net = _compute_entry_cache_dollars(
|
|
923
1013
|
model, create_tok, read_tok, pricing=pricing,
|
|
1014
|
+
cache_1h_tokens=getattr(e, "cache_1h_tokens", None), # #195
|
|
1015
|
+
speed=getattr(e, "speed", None),
|
|
924
1016
|
)
|
|
925
1017
|
nets.setdefault(k, []).append(net)
|
|
926
1018
|
t = toks.setdefault(k, [0, 0, 0])
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Codex quota-pool classification (#373).
|
|
2
|
+
|
|
3
|
+
A pure leaf module: stdlib only, no cctally imports. It lives apart from
|
|
4
|
+
``_lib_quota.py`` so ``_lib_jsonl`` need not import the quota kernel (and
|
|
5
|
+
``_lib_accounts`` through it) merely to classify a pool label, and so module
|
|
6
|
+
load order in ``bin/cctally`` is unaffected.
|
|
7
|
+
|
|
8
|
+
Spec: docs/superpowers/specs/2026-07-25-373-codex-foreign-pool-phantom-week.md
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def codex_model_scoped_quota_pool(model: object) -> str | None:
|
|
16
|
+
"""Return the native model pool when Codex documents it as separate.
|
|
17
|
+
|
|
18
|
+
GPT Codex Spark runs against its own allowance and does not consume the
|
|
19
|
+
standard Codex quota. Accepts either the sticky rollout model or the
|
|
20
|
+
native ``limit_name``; both spell the pool the same way once normalized.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(model, str):
|
|
23
|
+
return None
|
|
24
|
+
normalized = model.strip().lower()
|
|
25
|
+
return normalized if "-codex-spark" in normalized else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_model_scoped_codex_quota(logical_limit_key: object, limit_name: object) -> bool:
|
|
29
|
+
"""Whether a window sits outside account-level standard quota.
|
|
30
|
+
|
|
31
|
+
Two INDEPENDENT axes -- an unparseable key leaves the first unknown but
|
|
32
|
+
must still let the second fire. ``limit_id`` is deliberately not an axis:
|
|
33
|
+
treating an unknown id as non-standard would demote the real account quota
|
|
34
|
+
the moment the provider renamed it (#373 spec §6 Q1).
|
|
35
|
+
"""
|
|
36
|
+
if _key_has_model_pool(logical_limit_key):
|
|
37
|
+
return True
|
|
38
|
+
return codex_model_scoped_quota_pool(limit_name) is not None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _key_has_model_pool(logical_limit_key: object) -> bool:
|
|
42
|
+
if not isinstance(logical_limit_key, str):
|
|
43
|
+
return False
|
|
44
|
+
try:
|
|
45
|
+
payload = json.loads(logical_limit_key)
|
|
46
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
47
|
+
return False
|
|
48
|
+
return (
|
|
49
|
+
isinstance(payload, dict)
|
|
50
|
+
and isinstance(payload.get("modelPool"), str)
|
|
51
|
+
and bool(payload["modelPool"].strip())
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def codex_history_is_model_scoped(history, *, baseline=None) -> bool:
|
|
56
|
+
"""Classify one QuotaHistory, reading the label from an AUTHORITATIVE
|
|
57
|
+
observation rather than from ``history.identity``.
|
|
58
|
+
|
|
59
|
+
``build_history`` groups by identity equality and ``limit_id``/``limit_name``
|
|
60
|
+
are ``compare=False``, so ``history.identity`` retains whichever label was
|
|
61
|
+
seen FIRST. The authority is the baseline observation when the caller has
|
|
62
|
+
one, else the latest physical observation.
|
|
63
|
+
|
|
64
|
+
``history.identity`` is NEVER consulted for the label, not even as a
|
|
65
|
+
last-resort widening when the authority is unlabelled (#373 spec §7.1).
|
|
66
|
+
The retained identity is an artefact of iteration order, so reading it
|
|
67
|
+
would make the same two observations classify differently depending on the
|
|
68
|
+
order they arrived in — nondeterminism, not a safety net. Unlabelled
|
|
69
|
+
evidence therefore fails closed to ``False`` (standard quota), a direction
|
|
70
|
+
that can only keep a window in the account cycle and never demote a real
|
|
71
|
+
one out of it.
|
|
72
|
+
"""
|
|
73
|
+
identity = getattr(history, "identity", None)
|
|
74
|
+
if _key_has_model_pool(getattr(identity, "logical_limit_key", None)):
|
|
75
|
+
return True
|
|
76
|
+
authority = baseline
|
|
77
|
+
if authority is None:
|
|
78
|
+
physical = getattr(history, "physical_observations", ()) or ()
|
|
79
|
+
if physical:
|
|
80
|
+
authority = max(physical, key=lambda o: o.captured_at)
|
|
81
|
+
label = getattr(getattr(authority, "identity", None), "limit_name", None)
|
|
82
|
+
return codex_model_scoped_quota_pool(label) is not None
|