cctally 1.101.0 → 1.103.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 +1222 -1364
- package/README.md +8 -2
- package/bin/_cctally_cache.py +31 -0
- package/bin/_cctally_dashboard.py +433 -122
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +25 -23
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_forecast.py +25 -15
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +144 -70
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_statusline.py +8 -1
- package/bin/_lib_blocks.py +12 -0
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +50 -0
- package/bin/_lib_jsonl.py +102 -5
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +97 -21
- package/bin/_lib_readme_refresh.py +26 -5
- package/bin/cctally +93 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/index-Di2hljvB.css +1 -0
- package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-D6Eb9KDn.js +0 -97
- package/dashboard/static/assets/index-i3g7g8zo.css +0 -1
package/bin/_cctally_forecast.py
CHANGED
|
@@ -82,7 +82,11 @@ def _ensure_sibling_loaded(name: str) -> None:
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
_ensure_sibling_loaded("_lib_forecast")
|
|
85
|
-
from _lib_forecast import
|
|
85
|
+
from _lib_forecast import (
|
|
86
|
+
ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast,
|
|
87
|
+
ForecastConfidenceAssessment, ForecastConfidenceCause,
|
|
88
|
+
assess_forecast_confidence,
|
|
89
|
+
)
|
|
86
90
|
|
|
87
91
|
# #279 S6 W4: the canonical None-safe UTC-Z serializer. forecast's former local
|
|
88
92
|
# _iso_z (dt-only, no None guard) collapses to this single definition; the union
|
|
@@ -485,17 +489,23 @@ def _select_dollars_per_percent(
|
|
|
485
489
|
|
|
486
490
|
|
|
487
491
|
def _assess_forecast_confidence(
|
|
488
|
-
elapsed_hours: float, p_now: float, snapshot_count: int
|
|
492
|
+
elapsed_hours: float, p_now: float, snapshot_count: int,
|
|
493
|
+
*, has_sample_ge_24h: bool = True,
|
|
489
494
|
) -> tuple[str, list[str]]:
|
|
490
|
-
"""
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
495
|
+
"""Alias onto the pure predicate in `_lib_forecast` (#620 S2 E3).
|
|
496
|
+
|
|
497
|
+
Four import paths reach this name — `bin/cctally:1600`,
|
|
498
|
+
`bin/_cctally_record.py:427-428`, `bin/_cctally_record.py:1539` and
|
|
499
|
+
`tests/test_620_confidence_wording.py:141` — so it keeps its three
|
|
500
|
+
positional parameters and its `(confidence, list_of_reasons)` return
|
|
501
|
+
shape. `has_sample_ge_24h` defaults to True, which is what makes a
|
|
502
|
+
three-argument call emit exactly the three reasons it always did.
|
|
503
|
+
"""
|
|
504
|
+
assessment = assess_forecast_confidence(
|
|
505
|
+
elapsed_hours, p_now, snapshot_count,
|
|
506
|
+
has_sample_ge_24h=has_sample_ge_24h,
|
|
507
|
+
)
|
|
508
|
+
return assessment.confidence, list(assessment.reasons)
|
|
499
509
|
|
|
500
510
|
|
|
501
511
|
def _pick_p_24h_ago(
|
|
@@ -576,12 +586,12 @@ def _load_forecast_inputs(
|
|
|
576
586
|
use_weekref_cost_cache=use_weekref_cost_cache,
|
|
577
587
|
account_key=account_key,
|
|
578
588
|
)
|
|
579
|
-
confidence, reasons = _assess_forecast_confidence(elapsed_hours, p_now, len(samples))
|
|
580
589
|
target_24h = now_utc - dt.timedelta(hours=24)
|
|
581
590
|
has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
591
|
+
confidence, reasons = _assess_forecast_confidence(
|
|
592
|
+
elapsed_hours, p_now, len(samples),
|
|
593
|
+
has_sample_ge_24h=has_sample_ge_24h,
|
|
594
|
+
)
|
|
585
595
|
|
|
586
596
|
return ForecastInputs(
|
|
587
597
|
now_utc=now_utc,
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2385,6 +2385,89 @@ def _build_diff_parser(subparsers, name, *, help_text, xref=None, fixed_source=N
|
|
|
2385
2385
|
_add_account_arg(diff_p)
|
|
2386
2386
|
diff_p.set_defaults(func=c.cmd_diff)
|
|
2387
2387
|
|
|
2388
|
+
def _build_explain_parser(subparsers, name, *, help_text, xref=None):
|
|
2389
|
+
"""Build the `explain` parser (registered via _REGISTRATION; #620 S2).
|
|
2390
|
+
|
|
2391
|
+
`explain` deliberately does NOT adopt the shareable-output surface
|
|
2392
|
+
(`--format`/`--theme`/`--output`/`--copy`/`--open`). That surface is a
|
|
2393
|
+
separate concern with its own render kernel and byte-stability gates;
|
|
2394
|
+
only `--reveal-projects` is shared, because privacy is part of what the
|
|
2395
|
+
diagnosis publishes rather than part of how it is shared.
|
|
2396
|
+
"""
|
|
2397
|
+
c = _cctally()
|
|
2398
|
+
explain_p = subparsers.add_parser(
|
|
2399
|
+
name,
|
|
2400
|
+
help=help_text,
|
|
2401
|
+
formatter_class=CLIHelpFormatter,
|
|
2402
|
+
description=textwrap.dedent(
|
|
2403
|
+
"""\
|
|
2404
|
+
Explain where locally retained usage went, over seven
|
|
2405
|
+
contributor classes. Four are accounting-native: expensive model
|
|
2406
|
+
mix, one project dominating, one session dominating, and
|
|
2407
|
+
concentrated 5-hour bursts. Three are conversation-derived:
|
|
2408
|
+
prompt-cache churn, short conversations carrying large context,
|
|
2409
|
+
and subagent fan-out.
|
|
2410
|
+
|
|
2411
|
+
Each class reports a contributor when its top subject reaches the
|
|
2412
|
+
classification floor of its named denominator, reports no
|
|
2413
|
+
contributor when it is measured and nothing reaches the floor,
|
|
2414
|
+
and withholds with a typed cause when it cannot be measured. The
|
|
2415
|
+
classes overlap, so shares do not sum to 100 percent. Each class
|
|
2416
|
+
states the rule in force, so a verdict can be reproduced rather
|
|
2417
|
+
than inferred.
|
|
2418
|
+
|
|
2419
|
+
The three conversation-derived classes read the local transcript
|
|
2420
|
+
store, so they are withheld where it is absent or unreadable.
|
|
2421
|
+
Codex subagent attribution is derived from accounting metadata
|
|
2422
|
+
and needs no transcript access.
|
|
2423
|
+
|
|
2424
|
+
Exit codes: 0 for a valid report, including a healthy one and one
|
|
2425
|
+
whose fields are withheld; 2 for selector and usage errors; 3 for
|
|
2426
|
+
a report-establishment failure.
|
|
2427
|
+
"""
|
|
2428
|
+
),
|
|
2429
|
+
)
|
|
2430
|
+
explain_p.add_argument(
|
|
2431
|
+
# Defaulted to None rather than to the token itself, so the mutual
|
|
2432
|
+
# exclusion below can tell "the user asked for a window" from "nobody
|
|
2433
|
+
# said". `cmd_explain` resolves the absent case to `this-week`, which
|
|
2434
|
+
# is what keeps the default behaviour and the --help text unchanged.
|
|
2435
|
+
"--window", default=None,
|
|
2436
|
+
help="Window token (this-week | last-week | Nw-ago | this-month | "
|
|
2437
|
+
"last-month | Nm-ago | last-Nd | prev-Nd | "
|
|
2438
|
+
"YYYY-MM-DD..YYYY-MM-DD). Default: this-week.",
|
|
2439
|
+
)
|
|
2440
|
+
# Exact instants, because date grammar cannot express the window a warning
|
|
2441
|
+
# fired against: a five-hour block starts at an instant, not on a calendar
|
|
2442
|
+
# day. This is the pair the dashboard route already accepts, and the pair
|
|
2443
|
+
# every conversation-derived next step carries, so following one reproduces
|
|
2444
|
+
# the population the row measured rather than a day that contains it.
|
|
2445
|
+
explain_p.add_argument(
|
|
2446
|
+
"--start-at", default=None, metavar="ISO",
|
|
2447
|
+
help="Exact window start instant, inclusive (ISO 8601, e.g. "
|
|
2448
|
+
"2026-08-10T00:00:00Z). Requires --end-at and cannot be "
|
|
2449
|
+
"combined with --window.",
|
|
2450
|
+
)
|
|
2451
|
+
explain_p.add_argument(
|
|
2452
|
+
"--end-at", default=None, metavar="ISO",
|
|
2453
|
+
help="Exact window end instant, EXCLUSIVE (ISO 8601). Requires "
|
|
2454
|
+
"--start-at and cannot be combined with --window.",
|
|
2455
|
+
)
|
|
2456
|
+
explain_p.add_argument("--json", dest="emit_json", action="store_true",
|
|
2457
|
+
help="Emit the stamped JSON envelope instead of the terminal report.")
|
|
2458
|
+
explain_p.add_argument(
|
|
2459
|
+
"--reveal-projects", action="store_true", dest="reveal_projects",
|
|
2460
|
+
help="Show derived project display labels instead of the default "
|
|
2461
|
+
"project-1, project-2, ... anonymization. Never shows "
|
|
2462
|
+
"filesystem paths, and never widens session identities.",
|
|
2463
|
+
)
|
|
2464
|
+
explain_p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
|
|
2465
|
+
help="Display timezone: local, utc, or IANA name. "
|
|
2466
|
+
"Overrides config display.tz for this call.")
|
|
2467
|
+
_add_source_args(explain_p, speed=True)
|
|
2468
|
+
_add_account_arg(explain_p)
|
|
2469
|
+
explain_p.set_defaults(func=c.cmd_explain)
|
|
2470
|
+
|
|
2388
2471
|
def _build_claude_parser(subparsers, name, *, help_text, xref=None):
|
|
2389
2472
|
"""Build the `claude` parser (registered via _REGISTRATION; #279 S6 W3).
|
|
2390
2473
|
|
|
@@ -3743,6 +3826,7 @@ _REGISTRATION = (
|
|
|
3743
3826
|
_Reg('alerts', _build_alerts_parser, "Manage threshold alerts", None, None),
|
|
3744
3827
|
_Reg('setup', _build_setup_parser, "Install provider hooks/handlers + symlinks", None, None),
|
|
3745
3828
|
_Reg('db', _build_db_parser, "Migration / DB management (status, skip, unskip)", None, None),
|
|
3829
|
+
_Reg('explain', _build_explain_parser, "Explain where locally retained usage went, ranked with evidence", None, None),
|
|
3746
3830
|
_Reg('doctor', _build_doctor_parser, "Diagnose data freshness and install state", None, None),
|
|
3747
3831
|
_Reg('dashboard-perf', _build_dashboard_perf_parser, "Read a running dashboard's tick cost; arm its phase trace", None, None),
|
|
3748
3832
|
_Reg('pricing-check', _build_pricing_check_parser, "Detect stale or missing embedded model pricing", None, None),
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -194,6 +194,7 @@ PROJECTION_READ_CHOKEPOINTS: "frozenset[str]" = frozenset({
|
|
|
194
194
|
"_cctally_quota.py::_orphan_unseen::quota_window_blocks",
|
|
195
195
|
"_cctally_quota.py::_orphan_unseen_scoped::quota_window_blocks",
|
|
196
196
|
"_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state",
|
|
197
|
+
"_cctally_diagnosis_sources.py::<module>::quota_window_blocks",
|
|
197
198
|
})
|
|
198
199
|
|
|
199
200
|
#: What each enumerated site does about the gate.
|
|
@@ -246,6 +247,10 @@ PROJECTION_READ_SITE_ACTIONS: "dict[str, str]" = {
|
|
|
246
247
|
"projector",
|
|
247
248
|
"_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state":
|
|
248
249
|
"projector",
|
|
250
|
+
# `cctally explain` holds its Codex block SQL as a module-level constant,
|
|
251
|
+
# so the function that executes it gates instead.
|
|
252
|
+
"_cctally_diagnosis_sources.py::<module>::quota_window_blocks":
|
|
253
|
+
"gate_at_caller",
|
|
249
254
|
}
|
|
250
255
|
|
|
251
256
|
#: For each `gate_at_caller` site, the `<file>::<function>` callers that run the
|
|
@@ -274,6 +279,15 @@ PROJECTION_GATE_CALLERS: "dict[str, tuple[str, ...]]" = {
|
|
|
274
279
|
"_cctally_tui.py::_tui_compute_dispatch_signature",
|
|
275
280
|
"_cctally_dashboard_sources.py::_codex_quota_reuse_identity",
|
|
276
281
|
),
|
|
282
|
+
# Two functions in that module execute module-level SQL against
|
|
283
|
+
# `quota_window_blocks` — the block read and the observation-horizon read —
|
|
284
|
+
# and the scanner sees both as the one `<module>` site. Both are named,
|
|
285
|
+
# because "the horizon read is safe today because of call order" is not a
|
|
286
|
+
# property this guard can check, and a reordering would not fail it.
|
|
287
|
+
"_cctally_diagnosis_sources.py::<module>::quota_window_blocks": (
|
|
288
|
+
"_cctally_diagnosis_sources.py::_read_codex_blocks",
|
|
289
|
+
"_cctally_diagnosis_sources.py::_read_store_horizon",
|
|
290
|
+
),
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
#: Dynamic-target read sites per file — a `FROM`/`JOIN` whose target is
|
package/bin/_cctally_record.py
CHANGED
|
@@ -173,8 +173,8 @@ import re
|
|
|
173
173
|
import sqlite3
|
|
174
174
|
import sys
|
|
175
175
|
import time
|
|
176
|
-
from dataclasses import dataclass
|
|
177
|
-
from typing import Any
|
|
176
|
+
from dataclasses import dataclass, field
|
|
177
|
+
from typing import Any, Iterable
|
|
178
178
|
|
|
179
179
|
|
|
180
180
|
def _cctally():
|
|
@@ -1509,11 +1509,12 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
|
|
|
1509
1509
|
binds the fired value to forecast's ``week_avg_projection_pct`` within
|
|
1510
1510
|
1e-9, so the two MUST share the formula by construction.
|
|
1511
1511
|
|
|
1512
|
-
LOW CONF mirrors the displayed forecast confidence:
|
|
1513
|
-
``_assess_forecast_confidence(elapsed_hours, p_now, len(samples)
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
projected alert
|
|
1512
|
+
LOW CONF mirrors the displayed forecast confidence: ONE call to
|
|
1513
|
+
``_assess_forecast_confidence(elapsed_hours, p_now, len(samples),
|
|
1514
|
+
has_sample_ge_24h=…)``, whose fourth trigger ``no_sample_ge_24h`` moved
|
|
1515
|
+
inside the predicate with #620 S2 E3 — so a thin early-week window that
|
|
1516
|
+
forecast renders ``LOW CONF`` never fires a projected alert, and glue no
|
|
1517
|
+
longer appends a reason of its own.
|
|
1517
1518
|
|
|
1518
1519
|
Deliberately does NOT call ``_sum_cost_for_range`` (the weekly-% projection
|
|
1519
1520
|
needs no spend; the forecast kernel's ``week_avg_projection_pct`` is also
|
|
@@ -1534,15 +1535,14 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
|
|
|
1534
1535
|
r_avg = p_now / elapsed_hours if elapsed_hours > 0 else 0.0
|
|
1535
1536
|
projected_pct = p_now + r_avg * remaining_hours
|
|
1536
1537
|
|
|
1537
|
-
# Confidence
|
|
1538
|
-
#
|
|
1538
|
+
# Confidence comes from the predicate in one call, fourth trigger
|
|
1539
|
+
# included, so this LOW CONF gate == forecast's and glue no longer
|
|
1540
|
+
# downgrades a confidence the predicate returned (#620 S2 E3).
|
|
1541
|
+
target_24h = now_utc - dt.timedelta(hours=24)
|
|
1539
1542
|
confidence, _reasons = _assess_forecast_confidence(
|
|
1540
|
-
elapsed_hours, p_now, len(samples)
|
|
1543
|
+
elapsed_hours, p_now, len(samples),
|
|
1544
|
+
has_sample_ge_24h=any(s[0] <= target_24h for s in samples),
|
|
1541
1545
|
)
|
|
1542
|
-
target_24h = now_utc - dt.timedelta(hours=24)
|
|
1543
|
-
has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
|
|
1544
|
-
if not has_sample_ge_24h:
|
|
1545
|
-
confidence = "low"
|
|
1546
1546
|
return (projected_pct, confidence == "low")
|
|
1547
1547
|
|
|
1548
1548
|
|
|
@@ -1882,6 +1882,112 @@ def maybe_record_projected_alert(
|
|
|
1882
1882
|
eprint(f"[projected-alert] payload build failed: {build_exc}")
|
|
1883
1883
|
|
|
1884
1884
|
|
|
1885
|
+
@dataclass
|
|
1886
|
+
class PricedEntry:
|
|
1887
|
+
"""One accounting entry after pricing, as the block fold consumes it.
|
|
1888
|
+
|
|
1889
|
+
The canonical priced record: the fields `fold_block_totals` reads and
|
|
1890
|
+
nothing else. `_compute_block_totals` builds these from
|
|
1891
|
+
`_JoinedClaudeEntry` rows it has already priced; the diagnosis
|
|
1892
|
+
(#620 S2) builds them from its own half-open account-scoped window.
|
|
1893
|
+
"""
|
|
1894
|
+
model: str
|
|
1895
|
+
project_path: str | None
|
|
1896
|
+
input_tokens: int
|
|
1897
|
+
output_tokens: int
|
|
1898
|
+
cache_creation_tokens: int
|
|
1899
|
+
cache_read_tokens: int
|
|
1900
|
+
cost_usd: float
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
@dataclass
|
|
1904
|
+
class BlockBucket:
|
|
1905
|
+
"""One model or project bucket inside a block's totals."""
|
|
1906
|
+
input_tokens: int = 0
|
|
1907
|
+
output_tokens: int = 0
|
|
1908
|
+
cache_create_tokens: int = 0
|
|
1909
|
+
cache_read_tokens: int = 0
|
|
1910
|
+
cost_usd: float = 0.0
|
|
1911
|
+
entry_count: int = 0
|
|
1912
|
+
|
|
1913
|
+
def as_legacy_dict(self) -> dict[str, Any]:
|
|
1914
|
+
return {
|
|
1915
|
+
"input_tokens": self.input_tokens,
|
|
1916
|
+
"output_tokens": self.output_tokens,
|
|
1917
|
+
"cache_create_tokens": self.cache_create_tokens,
|
|
1918
|
+
"cache_read_tokens": self.cache_read_tokens,
|
|
1919
|
+
"cost_usd": self.cost_usd,
|
|
1920
|
+
"entry_count": self.entry_count,
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
@dataclass
|
|
1925
|
+
class BlockTotals:
|
|
1926
|
+
"""The summed result of `fold_block_totals`."""
|
|
1927
|
+
input_tokens: int = 0
|
|
1928
|
+
output_tokens: int = 0
|
|
1929
|
+
cache_create_tokens: int = 0
|
|
1930
|
+
cache_read_tokens: int = 0
|
|
1931
|
+
cost_usd: float = 0.0
|
|
1932
|
+
entry_count: int = 0
|
|
1933
|
+
by_model: dict[str, BlockBucket] = field(default_factory=dict)
|
|
1934
|
+
by_project: dict[str, BlockBucket] = field(default_factory=dict)
|
|
1935
|
+
|
|
1936
|
+
def as_legacy_dict(self) -> dict[str, Any]:
|
|
1937
|
+
"""The exact dict `_compute_block_totals` has always returned.
|
|
1938
|
+
|
|
1939
|
+
Key order is preserved because callers and fixture builders read
|
|
1940
|
+
this dict directly, and `entry_count` is deliberately absent at the
|
|
1941
|
+
top level: it exists on the dataclass for the diagnosis, and adding
|
|
1942
|
+
it here would change a shape every existing caller sees.
|
|
1943
|
+
"""
|
|
1944
|
+
return {
|
|
1945
|
+
"input_tokens": self.input_tokens,
|
|
1946
|
+
"output_tokens": self.output_tokens,
|
|
1947
|
+
"cache_create_tokens": self.cache_create_tokens,
|
|
1948
|
+
"cache_read_tokens": self.cache_read_tokens,
|
|
1949
|
+
"cost_usd": self.cost_usd,
|
|
1950
|
+
"by_model": {k: v.as_legacy_dict() for k, v in self.by_model.items()},
|
|
1951
|
+
"by_project": {k: v.as_legacy_dict() for k, v in self.by_project.items()},
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def fold_block_totals(entries: "Iterable[PricedEntry]") -> BlockTotals:
|
|
1956
|
+
"""Sum priced entries into block totals plus model and project buckets.
|
|
1957
|
+
|
|
1958
|
+
Pure: it opens nothing, prices nothing, and reads no clock. Summation
|
|
1959
|
+
follows the iteration order of `entries`, and bucket insertion order
|
|
1960
|
+
follows first appearance, both of which the callers observe.
|
|
1961
|
+
|
|
1962
|
+
A NULL `project_path` buckets under `(unknown)` so the reconcile
|
|
1963
|
+
invariant `SUM(child.cost) == parent.total` continues to hold. The
|
|
1964
|
+
JSONL-fallback loader always populates `project_path`, so `(unknown)`
|
|
1965
|
+
only appears on the cache-backed path during the brief `session_files`
|
|
1966
|
+
lazy-backfill window.
|
|
1967
|
+
"""
|
|
1968
|
+
totals = BlockTotals()
|
|
1969
|
+
for entry in entries:
|
|
1970
|
+
totals.input_tokens += entry.input_tokens
|
|
1971
|
+
totals.output_tokens += entry.output_tokens
|
|
1972
|
+
totals.cache_create_tokens += entry.cache_creation_tokens
|
|
1973
|
+
totals.cache_read_tokens += entry.cache_read_tokens
|
|
1974
|
+
totals.cost_usd += entry.cost_usd
|
|
1975
|
+
totals.entry_count += 1
|
|
1976
|
+
|
|
1977
|
+
for key, bucket_dict in (
|
|
1978
|
+
(entry.model, totals.by_model),
|
|
1979
|
+
(entry.project_path or "(unknown)", totals.by_project),
|
|
1980
|
+
):
|
|
1981
|
+
b = bucket_dict.setdefault(key, BlockBucket())
|
|
1982
|
+
b.input_tokens += entry.input_tokens
|
|
1983
|
+
b.output_tokens += entry.output_tokens
|
|
1984
|
+
b.cache_create_tokens += entry.cache_creation_tokens
|
|
1985
|
+
b.cache_read_tokens += entry.cache_read_tokens
|
|
1986
|
+
b.cost_usd += entry.cost_usd
|
|
1987
|
+
b.entry_count += 1
|
|
1988
|
+
return totals
|
|
1989
|
+
|
|
1990
|
+
|
|
1885
1991
|
def _compute_block_totals(
|
|
1886
1992
|
block_start_at: dt.datetime,
|
|
1887
1993
|
range_end: dt.datetime,
|
|
@@ -1907,64 +2013,32 @@ def _compute_block_totals(
|
|
|
1907
2013
|
cost_usd, entry_count}]
|
|
1908
2014
|
by_project: dict[project_path_or_'(unknown)' -> same shape]
|
|
1909
2015
|
"""
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
"cache_create_tokens": 0,
|
|
1914
|
-
"cache_read_tokens": 0,
|
|
1915
|
-
"cost_usd": 0.0,
|
|
1916
|
-
"by_model": {},
|
|
1917
|
-
"by_project": {},
|
|
1918
|
-
}
|
|
1919
|
-
for entry in get_claude_session_entries(
|
|
1920
|
-
block_start_at, range_end, skip_sync=skip_sync,
|
|
1921
|
-
):
|
|
1922
|
-
usage = claude_usage_dict( # #195 chokepoint
|
|
1923
|
-
input_tokens=entry.input_tokens,
|
|
1924
|
-
output_tokens=entry.output_tokens,
|
|
1925
|
-
cache_creation_tokens=entry.cache_creation_tokens,
|
|
1926
|
-
cache_read_tokens=entry.cache_read_tokens,
|
|
1927
|
-
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
1928
|
-
speed=getattr(entry, "speed", None),
|
|
1929
|
-
)
|
|
1930
|
-
cost = _calculate_entry_cost(
|
|
1931
|
-
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
1932
|
-
)
|
|
1933
|
-
|
|
1934
|
-
totals["input_tokens"] += entry.input_tokens
|
|
1935
|
-
totals["output_tokens"] += entry.output_tokens
|
|
1936
|
-
totals["cache_create_tokens"] += entry.cache_creation_tokens
|
|
1937
|
-
totals["cache_read_tokens"] += entry.cache_read_tokens
|
|
1938
|
-
totals["cost_usd"] += cost
|
|
1939
|
-
|
|
1940
|
-
# Bucket by model and by project_path. NULL project_path → sentinel
|
|
1941
|
-
# so reconcile invariant SUM(child.cost) == parent.total holds.
|
|
1942
|
-
# Note: the JSONL-fallback path (_direct_parse_claude_session_entries)
|
|
1943
|
-
# always populates project_path = cwd (never NULL); '(unknown)' only
|
|
1944
|
-
# appears on the cache-backed path during the brief session_files
|
|
1945
|
-
# lazy-backfill window.
|
|
1946
|
-
for key, bucket_dict in (
|
|
1947
|
-
(entry.model, totals["by_model"]),
|
|
1948
|
-
(entry.project_path or "(unknown)", totals["by_project"]),
|
|
2016
|
+
def _priced():
|
|
2017
|
+
for entry in get_claude_session_entries(
|
|
2018
|
+
block_start_at, range_end, skip_sync=skip_sync,
|
|
1949
2019
|
):
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
"cost_usd": 0.0,
|
|
1958
|
-
"entry_count": 0,
|
|
1959
|
-
},
|
|
2020
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
2021
|
+
input_tokens=entry.input_tokens,
|
|
2022
|
+
output_tokens=entry.output_tokens,
|
|
2023
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
2024
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
2025
|
+
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
2026
|
+
speed=getattr(entry, "speed", None),
|
|
1960
2027
|
)
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2028
|
+
cost = _calculate_entry_cost(
|
|
2029
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
2030
|
+
)
|
|
2031
|
+
yield PricedEntry(
|
|
2032
|
+
model=entry.model,
|
|
2033
|
+
project_path=entry.project_path,
|
|
2034
|
+
input_tokens=entry.input_tokens,
|
|
2035
|
+
output_tokens=entry.output_tokens,
|
|
2036
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
2037
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
2038
|
+
cost_usd=cost,
|
|
2039
|
+
)
|
|
2040
|
+
|
|
2041
|
+
return fold_block_totals(_priced()).as_legacy_dict()
|
|
1968
2042
|
|
|
1969
2043
|
|
|
1970
2044
|
def maybe_update_five_hour_block(
|