cctally 1.25.0 → 1.27.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/README.md +1 -1
- package/bin/_cctally_cache_report.py +13 -12
- package/bin/_cctally_dashboard.py +19 -22
- package/bin/_cctally_db.py +1 -1
- package/bin/_cctally_forecast.py +41 -35
- package/bin/_cctally_milestones.py +39 -23
- package/bin/_cctally_project.py +24 -2
- package/bin/_cctally_record.py +61 -70
- package/bin/_cctally_reporting.py +2 -1
- package/bin/_cctally_share.py +7 -6
- package/bin/_cctally_tui.py +10 -9
- package/bin/_lib_aggregators.py +3 -1
- package/bin/_lib_cache_report.py +37 -2
- package/bin/_lib_diff_kernel.py +3 -2
- package/bin/_lib_fmt.py +12 -0
- package/bin/_lib_render.py +7 -6
- package/bin/_lib_share.py +16 -4
- package/bin/_lib_share_templates.py +44 -10
- package/bin/_lib_view_models.py +9 -2
- package/bin/cctally +5 -1
- package/bin/cctally-npm-shim.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.27.0] - 2026-06-04
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **cctally now supports Python 3.11 and 3.12, not just 3.13.** The supported floor is lowered from 3.13 to 3.11 (Debian 12, Ubuntu 24.04, and other distros whose system `python3` is 3.11/3.12 can now run cctally without a newer interpreter). This was previously blocked by a one-cent floating-point rounding difference between Python versions in a rendered cost Total; rendered cost/percent totals now route through a `math.fsum`-backed exact-summation chokepoint, so every figure is byte-identical on 3.11/3.12/3.13 (verified by running the reporting suite on all three interpreters). Homebrew installs are unaffected (the formula still bundles its own modern Python). No action needed on upgrade.
|
|
12
|
+
|
|
13
|
+
## [1.26.0] - 2026-06-03
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **`cctally budget set --project` now hints the correct argument order when you put the amount after the flag.** Writing the flag-first form `cctally budget set --project 25` binds `25` to the `--project` value (argparse `nargs='?'`) and leaves the amount unset; instead of the generic "`set --project` requires an amount" error, cctally now points you at the supported `cctally budget set 25 --project` ordering. A bare numeric value that names a real directory (e.g. a repo literally called `2025`) is treated as a project path rather than a misplaced amount, so the hint never misfires on a numeric-named repo. The exit code is unchanged (2, a safe no-write failure) and valid invocations are unaffected.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **Internal refactor (no user-facing change): consolidated the per-project budget label-disambiguation and threshold-crossing arithmetic onto two shared helpers (`_project_budget_labels`, `_project_crossings`), used by the budget table, the alert firing path, the config-write reconcile, and the dashboard SSE envelope, and replaced the share-ranking hidden-`MoneyCell` hack with an explicit `ProjectCell.rank_cost` field.** The firing-path label resolution is now lazy (resolved only when a crossing actually dispatches, off the common no-dispatch tick). Output is byte-identical — the per-project budget reconcile invariants (61/61), the budget/share goldens (26/26), and the full pytest suite are unchanged; nothing to do on upgrade.
|
|
20
|
+
|
|
8
21
|
## [1.25.0] - 2026-06-03
|
|
9
22
|
|
|
10
23
|
### Added
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ If you're using `ccusage` to watch Claude Code spend, `cctally` covers the same
|
|
|
24
24
|
|
|
25
25
|
## Installation
|
|
26
26
|
|
|
27
|
-
**Requirements:** Python 3.
|
|
27
|
+
**Requirements:** Python 3.11+, macOS or Linux, Claude Code installed and run at least once.
|
|
28
28
|
|
|
29
29
|
### Homebrew (macOS / Linux)
|
|
30
30
|
|
|
@@ -19,6 +19,7 @@ from typing import Any, Literal, Optional
|
|
|
19
19
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
20
20
|
|
|
21
21
|
from _cctally_core import eprint, now_utc_iso, parse_iso_datetime, _command_as_of
|
|
22
|
+
from _lib_fmt import stable_sum
|
|
22
23
|
import _lib_cache_report as crk
|
|
23
24
|
|
|
24
25
|
|
|
@@ -467,10 +468,10 @@ def _render_cache_day_rows(
|
|
|
467
468
|
tot_cc = sum(row.cache_creation_tokens for row in rows)
|
|
468
469
|
tot_cr = sum(row.cache_read_tokens for row in rows)
|
|
469
470
|
tot_tokens = sum(row.total_tokens for row in rows)
|
|
470
|
-
tot_cost =
|
|
471
|
-
tot_saved =
|
|
472
|
-
tot_wasted =
|
|
473
|
-
tot_net =
|
|
471
|
+
tot_cost = stable_sum(row.cost for row in rows)
|
|
472
|
+
tot_saved = stable_sum(row.saved_usd for row in rows)
|
|
473
|
+
tot_wasted = stable_sum(row.wasted_usd for row in rows)
|
|
474
|
+
tot_net = stable_sum(row.net_usd for row in rows)
|
|
474
475
|
tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
|
|
475
476
|
footer_cells = [
|
|
476
477
|
("Total", _yellow),
|
|
@@ -616,10 +617,10 @@ def _render_cache_session_rows(
|
|
|
616
617
|
tot_cc = sum(r.cache_creation_tokens for r in rows)
|
|
617
618
|
tot_cr = sum(r.cache_read_tokens for r in rows)
|
|
618
619
|
tot_tokens = sum(r.total_tokens for r in rows)
|
|
619
|
-
tot_cost =
|
|
620
|
-
tot_saved =
|
|
621
|
-
tot_wasted =
|
|
622
|
-
tot_net =
|
|
620
|
+
tot_cost = stable_sum(r.cost for r in rows)
|
|
621
|
+
tot_saved = stable_sum(r.saved_usd for r in rows)
|
|
622
|
+
tot_wasted = stable_sum(r.wasted_usd for r in rows)
|
|
623
|
+
tot_net = stable_sum(r.net_usd for r in rows)
|
|
623
624
|
tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
|
|
624
625
|
|
|
625
626
|
footer_cells = [
|
|
@@ -991,13 +992,13 @@ def _emit_cache_report_json(
|
|
|
991
992
|
"cacheCreationTokens": tot_cc,
|
|
992
993
|
"cacheReadTokens": tot_cr,
|
|
993
994
|
"totalTokens": sum(r.total_tokens for r in rows),
|
|
994
|
-
"cost": round(
|
|
995
|
+
"cost": round(stable_sum(r.cost for r in rows), 6),
|
|
995
996
|
"cacheHitPercent": round(
|
|
996
997
|
crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr), 2
|
|
997
998
|
),
|
|
998
|
-
"savedUsd": round(
|
|
999
|
-
"wastedUsd": round(
|
|
1000
|
-
"netUsd": round(
|
|
999
|
+
"savedUsd": round(stable_sum(r.saved_usd for r in rows), 6),
|
|
1000
|
+
"wastedUsd": round(stable_sum(r.wasted_usd for r in rows), 6),
|
|
1001
|
+
"netUsd": round(stable_sum(r.net_usd for r in rows), 6),
|
|
1001
1002
|
},
|
|
1002
1003
|
"generatedAt": now_utc_iso(now_utc=now_utc),
|
|
1003
1004
|
}
|
|
@@ -279,6 +279,7 @@ from _lib_display_tz import (
|
|
|
279
279
|
_compute_display_block,
|
|
280
280
|
)
|
|
281
281
|
from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
|
|
282
|
+
from _lib_fmt import stable_sum
|
|
282
283
|
from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
|
|
283
284
|
from _lib_five_hour import _canonical_5h_window_key
|
|
284
285
|
from _lib_subscription_weeks import _compute_subscription_weeks
|
|
@@ -1319,7 +1320,7 @@ def _build_daily_share_panel_data(options: dict,
|
|
|
1319
1320
|
# 7 and reverse to oldest→newest so the Recap template's days[-1]
|
|
1320
1321
|
# anchor lands on today.
|
|
1321
1322
|
last_7 = list(reversed(daily[:7]))
|
|
1322
|
-
total =
|
|
1323
|
+
total = stable_sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
|
|
1323
1324
|
days: list[dict] = []
|
|
1324
1325
|
for r in last_7:
|
|
1325
1326
|
cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
@@ -1721,10 +1722,10 @@ def _build_projects_share_panel_data(options: dict,
|
|
|
1721
1722
|
wc = (tp.get("weekly_cost") or [])[-take:]
|
|
1722
1723
|
wp = (tp.get("weekly_pct") or [])[-take:]
|
|
1723
1724
|
ws = (tp.get("sessions_per_week") or [])[-take:]
|
|
1724
|
-
cost = float(
|
|
1725
|
+
cost = float(stable_sum(wc))
|
|
1725
1726
|
running_total += cost
|
|
1726
1727
|
valid_pct = [float(p) for p in wp if p is not None]
|
|
1727
|
-
attributed =
|
|
1728
|
+
attributed = stable_sum(valid_pct) if valid_pct else None
|
|
1728
1729
|
# Sum per-week distinct session counts. Slight over-count when a
|
|
1729
1730
|
# single session spans a week boundary; the envelope's per-week
|
|
1730
1731
|
# bucketing has no session-id sets to union, so this is the
|
|
@@ -1735,6 +1736,8 @@ def _build_projects_share_panel_data(options: dict,
|
|
|
1735
1736
|
"bucket_path": tp["bucket_path"],
|
|
1736
1737
|
"cost_usd": cost,
|
|
1737
1738
|
"attributed_pct": attributed,
|
|
1739
|
+
# Integer session counts — bare sum() is exact (NOT a
|
|
1740
|
+
# stable_sum float-output site; see test_stable_sum_chokepoint).
|
|
1738
1741
|
"sessions_count": int(sum(ws)),
|
|
1739
1742
|
})
|
|
1740
1743
|
rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
|
|
@@ -2193,14 +2196,14 @@ def build_cache_report_snapshot(
|
|
|
2193
2196
|
# 7-day rollup: today + 6 prior. Walk by string date; ``days_newest_first``
|
|
2194
2197
|
# is already in the right order.
|
|
2195
2198
|
seven_day_rows = days[:7]
|
|
2196
|
-
seven_day_net_usd =
|
|
2199
|
+
seven_day_net_usd = stable_sum(r.net_usd for r in seven_day_rows)
|
|
2197
2200
|
seven_day_anomaly_count = sum(
|
|
2198
2201
|
1 for r in seven_day_rows if r.anomaly_triggered
|
|
2199
2202
|
)
|
|
2200
2203
|
|
|
2201
2204
|
# 14-day counterfactual: sum(saved_usd) across the window.
|
|
2202
|
-
fourteen_day_counterfactual_usd =
|
|
2203
|
-
fourteen_day_wasted_usd =
|
|
2205
|
+
fourteen_day_counterfactual_usd = stable_sum(r.saved_usd for r in days)
|
|
2206
|
+
fourteen_day_wasted_usd = stable_sum(r.wasted_usd for r in days)
|
|
2204
2207
|
denom = fourteen_day_counterfactual_usd + abs(fourteen_day_wasted_usd)
|
|
2205
2208
|
fourteen_day_efficiency_ratio = (
|
|
2206
2209
|
(fourteen_day_counterfactual_usd / denom) if denom > 1e-9 else 0.0
|
|
@@ -3431,7 +3434,7 @@ def _build_projects_envelope(
|
|
|
3431
3434
|
})
|
|
3432
3435
|
# Stable sort: desc by total window cost, ties broken by key.
|
|
3433
3436
|
trend_projects.sort(
|
|
3434
|
-
key=lambda p: (-
|
|
3437
|
+
key=lambda p: (-stable_sum(p["weekly_cost"]), p["key"]),
|
|
3435
3438
|
)
|
|
3436
3439
|
|
|
3437
3440
|
trend_block = {
|
|
@@ -3762,7 +3765,7 @@ def _project_detail_for_window(
|
|
|
3762
3765
|
sliced = weekly_pct_arr[-take:] if take > 0 else []
|
|
3763
3766
|
wp = [p for p in sliced if p is not None]
|
|
3764
3767
|
if wp:
|
|
3765
|
-
win_pct =
|
|
3768
|
+
win_pct = stable_sum(wp)
|
|
3766
3769
|
|
|
3767
3770
|
# Best-effort cleanup of the per-call TEMP TABLE so a reused conn
|
|
3768
3771
|
# doesn't carry path state into the next drill (tests share conns;
|
|
@@ -4285,20 +4288,14 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
4285
4288
|
(limit,),
|
|
4286
4289
|
).fetchall()
|
|
4287
4290
|
c = _cctally()
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
#
|
|
4291
|
-
#
|
|
4292
|
-
#
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
pkeys = [resolve(k, "git-root", resolver_cache) for k in distinct_keys]
|
|
4297
|
-
disambig = c._project_disambiguate_labels([{"key": pk} for pk in pkeys])
|
|
4298
|
-
label_by_key = {
|
|
4299
|
-
distinct_keys[i]: disambig.get(i, pkeys[i].display_key)
|
|
4300
|
-
for i in range(len(distinct_keys))
|
|
4301
|
-
}
|
|
4291
|
+
# Collision-aware labels via the shared primitive (#130), disambiguated
|
|
4292
|
+
# across the alerted ROWS (NOT live config) to preserve the
|
|
4293
|
+
# render-from-the-snapshotted-row invariant above — so a deleted/renamed
|
|
4294
|
+
# config key still renders. This is intentionally a different feed than the
|
|
4295
|
+
# table/notification (full config); see spec §1 Goals (Codex F1).
|
|
4296
|
+
label_by_key = c._project_budget_labels(
|
|
4297
|
+
sorted({r["project_key"] for r in rows})
|
|
4298
|
+
)
|
|
4302
4299
|
out: list[dict] = []
|
|
4303
4300
|
for r in rows:
|
|
4304
4301
|
threshold = int(r["threshold"])
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2066,7 +2066,7 @@ def _is_no_such_table_error(exc: sqlite3.OperationalError) -> bool:
|
|
|
2066
2066
|
|
|
2067
2067
|
* Substring match on the lowercased message (stable for ~20 years).
|
|
2068
2068
|
* ``exc.sqlite_errorcode == SQLITE_ERROR (1)`` (Python 3.11+;
|
|
2069
|
-
cctally's floor is 3.
|
|
2069
|
+
cctally's floor is 3.11 per ``__min_python_version__``). The
|
|
2070
2070
|
``getattr(..., None) in (None, 1)`` form degrades gracefully if
|
|
2071
2071
|
the attribute is ever missing — substring-only on legacy Python.
|
|
2072
2072
|
|
package/bin/_cctally_forecast.py
CHANGED
|
@@ -1711,6 +1711,16 @@ def _resolve_project_budget_target(raw: str):
|
|
|
1711
1711
|
return key.git_root or key.bucket_path
|
|
1712
1712
|
|
|
1713
1713
|
|
|
1714
|
+
def _looks_numeric(s) -> bool:
|
|
1715
|
+
"""True iff `s` parses as a positive finite number — used to detect the
|
|
1716
|
+
`budget set --project 25` footgun (#130)."""
|
|
1717
|
+
try:
|
|
1718
|
+
v = float(s)
|
|
1719
|
+
except (TypeError, ValueError):
|
|
1720
|
+
return False
|
|
1721
|
+
return math.isfinite(v) and v > 0
|
|
1722
|
+
|
|
1723
|
+
|
|
1714
1724
|
def _cmd_budget_set_project(args: argparse.Namespace) -> int:
|
|
1715
1725
|
"""`cctally budget set AMOUNT --project[=PATH]` — write one entry into
|
|
1716
1726
|
`budget.projects`, keyed by the resolved canonical git-root. Writes the
|
|
@@ -1718,10 +1728,26 @@ def _cmd_budget_set_project(args: argparse.Namespace) -> int:
|
|
|
1718
1728
|
c = _cctally()
|
|
1719
1729
|
raw_amount = getattr(args, "amount", None)
|
|
1720
1730
|
if raw_amount is None:
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1731
|
+
proj = getattr(args, "project", None)
|
|
1732
|
+
if proj and proj != "__CWD__" and _looks_numeric(proj) and not os.path.isdir(proj):
|
|
1733
|
+
# `budget set --project 25` → argparse bound 25 to --project,
|
|
1734
|
+
# leaving amount=None (#130). A bare numeric value is almost always
|
|
1735
|
+
# the amount in the wrong slot — but NOT when it names a real
|
|
1736
|
+
# directory (e.g. a repo literally called `./2025`), which the
|
|
1737
|
+
# `not os.path.isdir(proj)` guard excludes so a numeric-named path
|
|
1738
|
+
# falls through to the generic "requires an amount" message below
|
|
1739
|
+
# instead of being misread as a misplaced amount. Point at the
|
|
1740
|
+
# right ordering.
|
|
1741
|
+
eprint(
|
|
1742
|
+
f"cctally budget: '{proj}' looks like an amount, not a "
|
|
1743
|
+
f"project path. Did you mean: cctally budget set {proj} "
|
|
1744
|
+
f"--project"
|
|
1745
|
+
)
|
|
1746
|
+
else:
|
|
1747
|
+
eprint(
|
|
1748
|
+
"cctally budget: `set --project` requires an amount, e.g. "
|
|
1749
|
+
"cctally budget set 25 --project"
|
|
1750
|
+
)
|
|
1725
1751
|
return 2
|
|
1726
1752
|
try:
|
|
1727
1753
|
amount = float(raw_amount)
|
|
@@ -2131,26 +2157,13 @@ def _build_project_budget_rows(conn, budget_cfg, now_utc):
|
|
|
2131
2157
|
last24h = c._sum_cost_by_project(recent_start, now_utc, mode="auto")
|
|
2132
2158
|
thresholds = tuple(budget_cfg["alert_thresholds"])
|
|
2133
2159
|
|
|
2134
|
-
#
|
|
2135
|
-
#
|
|
2136
|
-
#
|
|
2137
|
-
|
|
2138
|
-
# basename, so two distinct git-roots sharing a basename (e.g.
|
|
2139
|
-
# `/work/app` + `/personal/app`) would BOTH render as `app` — and in
|
|
2140
|
-
# anonymized share BOTH collapse to a single `project-1`. The primitive
|
|
2141
|
-
# suffixes the colliding rows with their parent-dir segment
|
|
2142
|
-
# ("app (work)" / "app (personal)"); non-colliding rows keep `display_key`.
|
|
2143
|
-
resolver_cache: dict = {}
|
|
2144
|
-
pkeys = [
|
|
2145
|
-
c._resolve_project_key(key, "git-root", resolver_cache)
|
|
2146
|
-
for key in budget_cfg["projects"]
|
|
2147
|
-
]
|
|
2148
|
-
disambig = c._project_disambiguate_labels(
|
|
2149
|
-
[{"key": pk} for pk in pkeys]
|
|
2150
|
-
)
|
|
2160
|
+
# Collision-aware labels via the shared primitive (#130). Same-basename
|
|
2161
|
+
# roots (/work/app + /personal/app) get a parent segment ("app (work)");
|
|
2162
|
+
# uniquely-named roots keep their bare display_key.
|
|
2163
|
+
labels = c._project_budget_labels(budget_cfg["projects"])
|
|
2151
2164
|
|
|
2152
2165
|
rows = []
|
|
2153
|
-
for
|
|
2166
|
+
for key, target in budget_cfg["projects"].items():
|
|
2154
2167
|
inputs = c.BudgetInputs(
|
|
2155
2168
|
target_usd=float(target),
|
|
2156
2169
|
spent_usd=float(week.get(key, 0.0)),
|
|
@@ -2161,7 +2174,7 @@ def _build_project_budget_rows(conn, budget_cfg, now_utc):
|
|
|
2161
2174
|
alert_thresholds=thresholds,
|
|
2162
2175
|
)
|
|
2163
2176
|
status = c.compute_budget_status(inputs)
|
|
2164
|
-
label =
|
|
2177
|
+
label = labels[key]
|
|
2165
2178
|
rows.append({
|
|
2166
2179
|
"project": label,
|
|
2167
2180
|
"project_key": key,
|
|
@@ -2256,19 +2269,12 @@ def _append_project_share_rows(snap, rows, has_projects):
|
|
|
2256
2269
|
}))
|
|
2257
2270
|
for r in rows:
|
|
2258
2271
|
verdict = r["verdict"].upper()
|
|
2259
|
-
#
|
|
2260
|
-
#
|
|
2261
|
-
#
|
|
2262
|
-
#
|
|
2263
|
-
# every project at cost=0, falling back to lexical ordering. Budget /
|
|
2264
|
-
# consumption / verdict stay in the visible `value` TextCell (NOT a
|
|
2265
|
-
# second MoneyCell — that would inflate the rank key to spent+budget).
|
|
2266
|
-
# The 2-col Metric/Value table renders only `metric` + `value`, so the
|
|
2267
|
-
# extra `spent` cell is invisible in the artifact while the Value
|
|
2268
|
-
# column keeps the full "spent / budget (pct) VERDICT" string.
|
|
2272
|
+
# Explicit rank via ProjectCell.rank_cost (#130) — spend-ranks the
|
|
2273
|
+
# anonymized labels (matching the `project` share convention) without a
|
|
2274
|
+
# hidden MoneyCell. Budget / consumption / verdict stay in the visible
|
|
2275
|
+
# `value` TextCell.
|
|
2269
2276
|
extra_rows.append(_lib_share.Row(cells={
|
|
2270
|
-
"metric": _lib_share.ProjectCell(r["project"]),
|
|
2271
|
-
"spent": _lib_share.MoneyCell(r["spent_usd"]),
|
|
2277
|
+
"metric": _lib_share.ProjectCell(r["project"], rank_cost=r["spent_usd"]),
|
|
2272
2278
|
"value": _lib_share.TextCell(
|
|
2273
2279
|
f"${r['spent_usd']:,.2f} / ${r['budget_usd']:,.2f} "
|
|
2274
2280
|
f"({r['consumption_pct']:.0f}%) {verdict}"
|
|
@@ -547,6 +547,23 @@ def _reconcile_budget_on_config_write(validated_budget):
|
|
|
547
547
|
eprint(f"[budget-milestone] reconcile on set failed: {exc}")
|
|
548
548
|
|
|
549
549
|
|
|
550
|
+
def _project_crossings(items, thresholds, by_proj):
|
|
551
|
+
"""Yield ``(project_key, threshold, spent, target, consumption_pct)`` for
|
|
552
|
+
every crossed (project, threshold) pair (#130). Shared by the firing path
|
|
553
|
+
(record-usage) and the reconcile path (config write) so they differ ONLY in
|
|
554
|
+
the dispatch tail. Pure arithmetic — no DB, no I/O. Applies the +1e-9
|
|
555
|
+
float-floor snap (CLAUDE.md gotcha) and yields thresholds in sorted order.
|
|
556
|
+
``items`` is an iterable of ``(project_key, target)`` pairs."""
|
|
557
|
+
sorted_thresholds = sorted(thresholds)
|
|
558
|
+
for project_key, target in items:
|
|
559
|
+
spent = float(by_proj.get(project_key, 0.0))
|
|
560
|
+
target = float(target)
|
|
561
|
+
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
562
|
+
for t in sorted_thresholds:
|
|
563
|
+
if consumption_pct + 1e-9 >= t:
|
|
564
|
+
yield (project_key, t, spent, target, consumption_pct)
|
|
565
|
+
|
|
566
|
+
|
|
550
567
|
def _reconcile_project_budget_milestones_on_write(
|
|
551
568
|
validated_budget, touched_projects=None
|
|
552
569
|
):
|
|
@@ -614,30 +631,29 @@ def _reconcile_project_budget_milestones_on_write(
|
|
|
614
631
|
if k in touched_projects
|
|
615
632
|
]
|
|
616
633
|
)
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
634
|
+
# Same crossing arithmetic as firing, via the shared generator
|
|
635
|
+
# (#130). Reconcile differs ONLY in the tail: UPDATE alerted_at with
|
|
636
|
+
# NO dispatch (retroactive-storm suppression). `items` already
|
|
637
|
+
# honors touched_projects filtering above.
|
|
638
|
+
for project_key, t, spent, target, consumption_pct in c._project_crossings(
|
|
639
|
+
items, thresholds, by_proj
|
|
640
|
+
):
|
|
641
|
+
insert_project_budget_milestone(
|
|
642
|
+
conn,
|
|
643
|
+
week_start_at=week_key,
|
|
644
|
+
project_key=project_key,
|
|
645
|
+
threshold=t,
|
|
646
|
+
budget_usd=target,
|
|
647
|
+
spent_usd=spent,
|
|
648
|
+
consumption_pct=consumption_pct,
|
|
649
|
+
commit=False,
|
|
650
|
+
)
|
|
651
|
+
conn.execute(
|
|
652
|
+
"UPDATE project_budget_milestones SET alerted_at = ? "
|
|
653
|
+
"WHERE week_start_at = ? AND project_key = ? "
|
|
654
|
+
" AND threshold = ? AND alerted_at IS NULL",
|
|
655
|
+
(now_utc_iso(), week_key, project_key, t),
|
|
622
656
|
)
|
|
623
|
-
for t in sorted(thresholds):
|
|
624
|
-
if consumption_pct + 1e-9 >= t:
|
|
625
|
-
insert_project_budget_milestone(
|
|
626
|
-
conn,
|
|
627
|
-
week_start_at=week_key,
|
|
628
|
-
project_key=project_key,
|
|
629
|
-
threshold=t,
|
|
630
|
-
budget_usd=target,
|
|
631
|
-
spent_usd=spent,
|
|
632
|
-
consumption_pct=consumption_pct,
|
|
633
|
-
commit=False,
|
|
634
|
-
)
|
|
635
|
-
conn.execute(
|
|
636
|
-
"UPDATE project_budget_milestones SET alerted_at = ? "
|
|
637
|
-
"WHERE week_start_at = ? AND project_key = ? "
|
|
638
|
-
" AND threshold = ? AND alerted_at IS NULL",
|
|
639
|
-
(now_utc_iso(), week_key, project_key, t),
|
|
640
|
-
)
|
|
641
657
|
conn.commit()
|
|
642
658
|
finally:
|
|
643
659
|
conn.close()
|
package/bin/_cctally_project.py
CHANGED
|
@@ -27,6 +27,7 @@ import os
|
|
|
27
27
|
import sys
|
|
28
28
|
|
|
29
29
|
from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
|
|
30
|
+
from _lib_fmt import stable_sum
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
def _cctally():
|
|
@@ -132,6 +133,27 @@ def _sum_cost_by_project(
|
|
|
132
133
|
return out
|
|
133
134
|
|
|
134
135
|
|
|
136
|
+
def _project_budget_labels(keys):
|
|
137
|
+
"""Collision-aware ``{project_key: label}`` for a set of budget project
|
|
138
|
+
keys. Single source of the resolve+disambiguate primitive used by the
|
|
139
|
+
budget table (`_build_project_budget_rows`), the alert payload
|
|
140
|
+
(`maybe_record_project_budget_milestone`), and the dashboard SSE envelope
|
|
141
|
+
(`_envelope_rows_project_budget`) — issue #130. Each caller passes its own
|
|
142
|
+
key feed; the label for a key is identical across callers only when they
|
|
143
|
+
feed the same key set (the dashboard intentionally feeds its alerted-row
|
|
144
|
+
subset). Output is order-independent (disambiguation keys off basename
|
|
145
|
+
collisions, not position)."""
|
|
146
|
+
c = _cctally()
|
|
147
|
+
keys = list(keys)
|
|
148
|
+
resolver_cache: dict = {}
|
|
149
|
+
pkeys = [c._resolve_project_key(k, "git-root", resolver_cache) for k in keys]
|
|
150
|
+
disambig = c._project_disambiguate_labels([{"key": pk} for pk in pkeys])
|
|
151
|
+
return {
|
|
152
|
+
keys[i]: disambig.get(i, pkeys[i].display_key)
|
|
153
|
+
for i in range(len(keys))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
135
157
|
def _accumulate_entry_into_bucket(
|
|
136
158
|
b: dict,
|
|
137
159
|
entry: "_JoinedClaudeEntry",
|
|
@@ -222,11 +244,11 @@ def _project_json_output(
|
|
|
222
244
|
denominator used by per-project attribution). `models[]` is included
|
|
223
245
|
per-project only when `--breakdown` is requested to avoid payload bloat.
|
|
224
246
|
"""
|
|
225
|
-
total_cost =
|
|
247
|
+
total_cost = stable_sum(r["cost_usd"] for r in rows)
|
|
226
248
|
# Aggregate used % across all weeks with snapshots in the range.
|
|
227
249
|
total_used_pct: float | None
|
|
228
250
|
if week_snapshots:
|
|
229
|
-
total_used_pct =
|
|
251
|
+
total_used_pct = stable_sum(week_snapshots.values())
|
|
230
252
|
else:
|
|
231
253
|
total_used_pct = None
|
|
232
254
|
|
package/bin/_cctally_record.py
CHANGED
|
@@ -277,12 +277,12 @@ def insert_project_budget_milestone(*args, **kwargs):
|
|
|
277
277
|
return sys.modules["cctally"].insert_project_budget_milestone(*args, **kwargs)
|
|
278
278
|
|
|
279
279
|
|
|
280
|
-
def
|
|
281
|
-
return sys.modules["cctally"].
|
|
280
|
+
def _project_budget_labels(*args, **kwargs):
|
|
281
|
+
return sys.modules["cctally"]._project_budget_labels(*args, **kwargs)
|
|
282
282
|
|
|
283
283
|
|
|
284
|
-
def
|
|
285
|
-
return sys.modules["cctally"].
|
|
284
|
+
def _project_crossings(*args, **kwargs):
|
|
285
|
+
return sys.modules["cctally"]._project_crossings(*args, **kwargs)
|
|
286
286
|
|
|
287
287
|
|
|
288
288
|
def _get_budget_config(*args, **kwargs):
|
|
@@ -912,28 +912,17 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
912
912
|
if not pending:
|
|
913
913
|
return # nothing left to cross this week → skip the cost scan
|
|
914
914
|
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
918
|
-
#
|
|
919
|
-
#
|
|
920
|
-
#
|
|
921
|
-
#
|
|
922
|
-
#
|
|
923
|
-
#
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
pkeys = [
|
|
927
|
-
_resolve_project_key(p, "git-root", resolver_cache)
|
|
928
|
-
for p in proj_keys
|
|
929
|
-
]
|
|
930
|
-
disambig = _project_disambiguate_labels(
|
|
931
|
-
[{"key": pk} for pk in pkeys]
|
|
932
|
-
)
|
|
933
|
-
label_by_key = {
|
|
934
|
-
proj_keys[i]: disambig.get(i, pkeys[i].display_key)
|
|
935
|
-
for i in range(len(proj_keys))
|
|
936
|
-
}
|
|
915
|
+
# Collision-aware labels via the shared primitive (#130) — byte-matching
|
|
916
|
+
# the display table + dashboard chip for the same key feed. A
|
|
917
|
+
# uniquely-named project keeps its bare basename in the notification;
|
|
918
|
+
# only same-basename roots (`/work/app` + `/personal/app`) get the
|
|
919
|
+
# `(parent)` segment ("app (work)" / "app (personal)"). Resolved LAZILY
|
|
920
|
+
# (just-in-time on the first genuine crossing below): the map does
|
|
921
|
+
# per-key git-root resolution but is consumed ONLY when a new crossing
|
|
922
|
+
# dispatches, and most pending ticks scan without crossing — so we skip
|
|
923
|
+
# the resolution entirely on the common no-dispatch tick. Same map,
|
|
924
|
+
# same labels; only the timing moves.
|
|
925
|
+
label_by_key = None
|
|
937
926
|
|
|
938
927
|
# ONE grouped scan over the week's session entries, bucketed by
|
|
939
928
|
# canonical git-root. skip_sync=False (self-sufficient): the global
|
|
@@ -945,55 +934,57 @@ def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
|
|
|
945
934
|
by_proj = _sum_cost_by_project(
|
|
946
935
|
week_start_at, now_utc, mode="auto", skip_sync=False
|
|
947
936
|
)
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
937
|
+
# Crossing arithmetic via the shared generator (#130). Feed ALL
|
|
938
|
+
# configured (project, threshold) pairs; dispatch is gated SOLELY by
|
|
939
|
+
# INSERT-OR-IGNORE rowcount==1 (genuine new crossing). The pending
|
|
940
|
+
# pre-probe above stays as the scan-skip optimization, NOT a write gate
|
|
941
|
+
# — already-recorded pairs get rowcount==0 here and silently skip
|
|
942
|
+
# ([Dedup mustn't gate side effects]).
|
|
943
|
+
for project_key, t, spent, target, consumption_pct in _project_crossings(
|
|
944
|
+
projects.items(), sorted_thresholds, by_proj
|
|
945
|
+
):
|
|
946
|
+
inserted = insert_project_budget_milestone(
|
|
947
|
+
conn,
|
|
948
|
+
week_start_at=week_key,
|
|
949
|
+
project_key=project_key,
|
|
950
|
+
threshold=t,
|
|
951
|
+
budget_usd=target,
|
|
952
|
+
spent_usd=spent,
|
|
953
|
+
consumption_pct=consumption_pct,
|
|
954
|
+
commit=False,
|
|
955
|
+
)
|
|
956
|
+
# Only the genuine-new-crossing winner (rowcount==1) dispatches; a
|
|
957
|
+
# racing record-usage instance OR an already-recorded pair gets
|
|
958
|
+
# rowcount==0 and skips.
|
|
959
|
+
if inserted == 1:
|
|
960
|
+
crossed_at = now_utc_iso()
|
|
961
|
+
# set-then-dispatch: alerted_at lands on the row BEFORE the
|
|
962
|
+
# Popen, sharing this transaction with the INSERT (commit=False).
|
|
963
|
+
# `alerted_at IS NULL` is write-once defense-in-depth.
|
|
964
|
+
conn.execute(
|
|
965
|
+
"UPDATE project_budget_milestones SET alerted_at = ? "
|
|
966
|
+
"WHERE week_start_at = ? AND project_key = ? "
|
|
967
|
+
" AND threshold = ? AND alerted_at IS NULL",
|
|
968
|
+
(crossed_at, week_key, project_key, t),
|
|
969
|
+
)
|
|
970
|
+
# Collision-aware label (shared primitive, #130); resolved once
|
|
971
|
+
# on the first dispatch and reused for the rest of this tick.
|
|
972
|
+
# Kept defensive fallback (F4).
|
|
973
|
+
if label_by_key is None:
|
|
974
|
+
label_by_key = _project_budget_labels(sorted(projects))
|
|
975
|
+
project_label = label_by_key.get(
|
|
976
|
+
project_key, os.path.basename(project_key) or project_key
|
|
977
|
+
)
|
|
978
|
+
pending_alerts.append(_build_alert_payload_project_budget(
|
|
979
|
+
threshold=t,
|
|
980
|
+
crossed_at_utc=crossed_at,
|
|
957
981
|
week_start_at=week_key,
|
|
982
|
+
project=project_label,
|
|
958
983
|
project_key=project_key,
|
|
959
|
-
threshold=t,
|
|
960
984
|
budget_usd=target,
|
|
961
985
|
spent_usd=spent,
|
|
962
986
|
consumption_pct=consumption_pct,
|
|
963
|
-
|
|
964
|
-
)
|
|
965
|
-
# Only the genuine-new-crossing winner (rowcount==1) dispatches;
|
|
966
|
-
# a racing record-usage instance gets rowcount==0 and skips.
|
|
967
|
-
if inserted == 1:
|
|
968
|
-
crossed_at = now_utc_iso()
|
|
969
|
-
# set-then-dispatch: alerted_at lands on the row BEFORE the
|
|
970
|
-
# Popen, sharing this transaction with the INSERT
|
|
971
|
-
# (commit=False). `alerted_at IS NULL` is write-once
|
|
972
|
-
# defense-in-depth.
|
|
973
|
-
conn.execute(
|
|
974
|
-
"UPDATE project_budget_milestones SET alerted_at = ? "
|
|
975
|
-
"WHERE week_start_at = ? AND project_key = ? "
|
|
976
|
-
" AND threshold = ? AND alerted_at IS NULL",
|
|
977
|
-
(crossed_at, week_key, project_key, t),
|
|
978
|
-
)
|
|
979
|
-
# Label is collision-aware, byte-matching the display: a
|
|
980
|
-
# uniquely-named project notifies as its bare basename,
|
|
981
|
-
# only same-basename roots get the `(parent)` segment (the
|
|
982
|
-
# `label_by_key` map built above via the shared
|
|
983
|
-
# `_project_disambiguate_labels` primitive, spec §5.3).
|
|
984
|
-
project_label = label_by_key.get(
|
|
985
|
-
project_key, os.path.basename(project_key) or project_key
|
|
986
|
-
)
|
|
987
|
-
pending_alerts.append(_build_alert_payload_project_budget(
|
|
988
|
-
threshold=t,
|
|
989
|
-
crossed_at_utc=crossed_at,
|
|
990
|
-
week_start_at=week_key,
|
|
991
|
-
project=project_label,
|
|
992
|
-
project_key=project_key,
|
|
993
|
-
budget_usd=target,
|
|
994
|
-
spent_usd=spent,
|
|
995
|
-
consumption_pct=consumption_pct,
|
|
996
|
-
))
|
|
987
|
+
))
|
|
997
988
|
# Single commit: every INSERT + its alerted_at marker durable together.
|
|
998
989
|
conn.commit()
|
|
999
990
|
except Exception as exc:
|
|
@@ -34,6 +34,7 @@ import sys
|
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
36
|
from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
|
|
37
|
+
from _lib_fmt import stable_sum
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def _cctally():
|
|
@@ -134,7 +135,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
134
135
|
if getattr(args, "instances", False):
|
|
135
136
|
groups = c._aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
|
|
136
137
|
aug = c._project_disambiguate_labels(
|
|
137
|
-
[{"key": k, "cost_usd":
|
|
138
|
+
[{"key": k, "cost_usd": stable_sum(b.cost_usd for b in bl)}
|
|
138
139
|
for k, bl in groups]
|
|
139
140
|
)
|
|
140
141
|
json_groups: list = []
|
package/bin/_cctally_share.py
CHANGED
|
@@ -19,6 +19,7 @@ import sys
|
|
|
19
19
|
|
|
20
20
|
import _lib_changelog # module-qualified: _lib_changelog._read_latest_changelog_version()
|
|
21
21
|
from _lib_display_tz import format_display_dt
|
|
22
|
+
from _lib_fmt import stable_sum
|
|
22
23
|
from _lib_render import _project_disambiguate_labels
|
|
23
24
|
|
|
24
25
|
|
|
@@ -406,7 +407,7 @@ def _build_report_snapshot(
|
|
|
406
407
|
avg_dpp = view.avg_dollars_per_pct
|
|
407
408
|
else:
|
|
408
409
|
avg_dpp = (
|
|
409
|
-
|
|
410
|
+
stable_sum(p.y_value for p in chart_pts) / len(chart_pts)
|
|
410
411
|
if chart_pts else 0.0
|
|
411
412
|
)
|
|
412
413
|
totals = (
|
|
@@ -627,7 +628,7 @@ def _build_monthly_snapshot(
|
|
|
627
628
|
_lib_share.BarChart(points=tuple(chart_pts), y_label="$")
|
|
628
629
|
if chart_pts else None
|
|
629
630
|
)
|
|
630
|
-
sum_cost =
|
|
631
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
631
632
|
avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
|
|
632
633
|
totals = (
|
|
633
634
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
@@ -811,12 +812,12 @@ def _build_weekly_snapshot(
|
|
|
811
812
|
)
|
|
812
813
|
if chart_pts else None
|
|
813
814
|
)
|
|
814
|
-
sum_cost =
|
|
815
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
815
816
|
pct_values = [
|
|
816
817
|
float(o[0]) for o in overlay
|
|
817
818
|
if o is not None and o[0] is not None
|
|
818
819
|
]
|
|
819
|
-
avg_pct = (
|
|
820
|
+
avg_pct = (stable_sum(pct_values) / len(pct_values)) if pct_values else 0.0
|
|
820
821
|
peak_pct = max(pct_values, default=0.0)
|
|
821
822
|
totals = (
|
|
822
823
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
@@ -1144,7 +1145,7 @@ def _build_project_snapshot(
|
|
|
1144
1145
|
notes = (
|
|
1145
1146
|
f"Showing top 12 in chart; table includes all {len(chart_pts)}.",
|
|
1146
1147
|
)
|
|
1147
|
-
sum_cost =
|
|
1148
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
1148
1149
|
totals = (
|
|
1149
1150
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
1150
1151
|
_lib_share.Totalled(label="Projects", value=str(len(chart_pts))),
|
|
@@ -1541,7 +1542,7 @@ def _build_session_snapshot(
|
|
|
1541
1542
|
notes = (
|
|
1542
1543
|
f"Showing top 15 in chart; table includes all {len(chart_pts)}.",
|
|
1543
1544
|
)
|
|
1544
|
-
sum_cost =
|
|
1545
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
1545
1546
|
totals = (
|
|
1546
1547
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
1547
1548
|
_lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -221,6 +221,7 @@ from _lib_display_tz import (
|
|
|
221
221
|
_compute_display_block,
|
|
222
222
|
)
|
|
223
223
|
from _lib_aggregators import _aggregate_monthly
|
|
224
|
+
from _lib_fmt import stable_sum
|
|
224
225
|
|
|
225
226
|
|
|
226
227
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
@@ -1926,11 +1927,11 @@ def _tui_build_snapshot(
|
|
|
1926
1927
|
weekly_periods = _dashboard_build_weekly_periods(
|
|
1927
1928
|
conn, now_utc, n=12, skip_sync=skip_sync
|
|
1928
1929
|
)
|
|
1929
|
-
# ``
|
|
1930
|
-
# the envelope stays byte-stable with the pre-fix ``0.0`` shape
|
|
1930
|
+
# ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
|
|
1931
|
+
# so the envelope stays byte-stable with the pre-fix ``0.0`` shape
|
|
1931
1932
|
# (the dashboard fixture goldens assert exact JSON match).
|
|
1932
|
-
weekly_total_cost_usd =
|
|
1933
|
-
|
|
1933
|
+
weekly_total_cost_usd = stable_sum(
|
|
1934
|
+
r.cost_usd for r in weekly_periods
|
|
1934
1935
|
)
|
|
1935
1936
|
weekly_total_tokens = sum(
|
|
1936
1937
|
(r.total_tokens for r in weekly_periods), 0,
|
|
@@ -1949,8 +1950,8 @@ def _tui_build_snapshot(
|
|
|
1949
1950
|
conn, now_utc, n=12, skip_sync=skip_sync,
|
|
1950
1951
|
display_tz=_build_display_tz,
|
|
1951
1952
|
)
|
|
1952
|
-
monthly_total_cost_usd =
|
|
1953
|
-
|
|
1953
|
+
monthly_total_cost_usd = stable_sum(
|
|
1954
|
+
r.cost_usd for r in monthly_periods
|
|
1954
1955
|
)
|
|
1955
1956
|
monthly_total_tokens = sum(
|
|
1956
1957
|
(r.total_tokens for r in monthly_periods), 0,
|
|
@@ -1992,8 +1993,8 @@ def _tui_build_snapshot(
|
|
|
1992
1993
|
conn, now_utc, n=30, skip_sync=skip_sync,
|
|
1993
1994
|
display_tz=_build_display_tz,
|
|
1994
1995
|
)
|
|
1995
|
-
daily_total_cost_usd =
|
|
1996
|
-
|
|
1996
|
+
daily_total_cost_usd = stable_sum(
|
|
1997
|
+
r.cost_usd for r in daily_panel
|
|
1997
1998
|
)
|
|
1998
1999
|
daily_total_tokens = sum(
|
|
1999
2000
|
(r.total_tokens for r in daily_panel), 0,
|
|
@@ -4183,7 +4184,7 @@ def _tui_modal_trend(snap, runtime, width):
|
|
|
4183
4184
|
show_age = bucket != "narrow"
|
|
4184
4185
|
# Header
|
|
4185
4186
|
valid = [h for h in history if h.dollars_per_percent is not None]
|
|
4186
|
-
avg_dpp =
|
|
4187
|
+
avg_dpp = stable_sum(h.dollars_per_percent for h in valid) / len(valid) if valid else 0.0
|
|
4187
4188
|
if len(valid) >= 2:
|
|
4188
4189
|
first_dpp = valid[0].dollars_per_percent
|
|
4189
4190
|
last_dpp = valid[-1].dollars_per_percent
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -77,6 +77,8 @@ _resolve_tz = _lib_display_tz._resolve_tz
|
|
|
77
77
|
|
|
78
78
|
_lib_subscription_weeks = _load_lib("_lib_subscription_weeks")
|
|
79
79
|
SubWeek = _lib_subscription_weeks.SubWeek
|
|
80
|
+
_lib_fmt = _load_lib("_lib_fmt")
|
|
81
|
+
stable_sum = _lib_fmt.stable_sum
|
|
80
82
|
|
|
81
83
|
|
|
82
84
|
# === Honest imports from extracted homes ===================================
|
|
@@ -260,7 +262,7 @@ def _aggregate_daily_by_project(
|
|
|
260
262
|
ranked: list[tuple[Any, list[BucketUsage], float]] = []
|
|
261
263
|
for key in order:
|
|
262
264
|
buckets = _aggregate_daily(grouped[key], mode=mode, tz=tz) # date-asc
|
|
263
|
-
total =
|
|
265
|
+
total = stable_sum(b.cost_usd for b in buckets)
|
|
264
266
|
ranked.append((key, buckets, total))
|
|
265
267
|
|
|
266
268
|
ranked.sort(key=lambda t: (-t[2], t[0].display_key))
|
package/bin/_lib_cache_report.py
CHANGED
|
@@ -21,6 +21,41 @@ from typing import Any, Callable, Iterable, Literal, Optional
|
|
|
21
21
|
from zoneinfo import ZoneInfo
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _import_stable_sum():
|
|
25
|
+
"""Resolve ``_lib_fmt.stable_sum`` — the interpreter-stable float-summation
|
|
26
|
+
chokepoint (math.fsum) used for output-bound cost totals.
|
|
27
|
+
|
|
28
|
+
This kernel is loaded by file path from both ``bin/cctally`` and the test
|
|
29
|
+
harness, where ``_lib_fmt`` (a foundational kernel) is already imported, so
|
|
30
|
+
the ``sys.modules`` fast path is what runs in practice. The path-load
|
|
31
|
+
fallback differs from ``_import_share_lib``'s: ``_lib_share`` is dep-free,
|
|
32
|
+
but ``_lib_fmt`` imports the leaf kernels ``_cctally_core`` +
|
|
33
|
+
``_lib_display_tz`` *by name*, so the fallback must put ``bin/`` on
|
|
34
|
+
``sys.path`` before executing it or those imports raise ModuleNotFoundError.
|
|
35
|
+
The leaves never import back, so this stays acyclic.
|
|
36
|
+
"""
|
|
37
|
+
import sys
|
|
38
|
+
if "_lib_fmt" in sys.modules:
|
|
39
|
+
return sys.modules["_lib_fmt"].stable_sum
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
import importlib.util
|
|
42
|
+
bin_dir = Path(__file__).resolve().parent
|
|
43
|
+
if str(bin_dir) not in sys.path:
|
|
44
|
+
sys.path.insert(0, str(bin_dir))
|
|
45
|
+
spec = importlib.util.spec_from_file_location("_lib_fmt", bin_dir / "_lib_fmt.py")
|
|
46
|
+
m = importlib.util.module_from_spec(spec)
|
|
47
|
+
sys.modules["_lib_fmt"] = m
|
|
48
|
+
try:
|
|
49
|
+
spec.loader.exec_module(m)
|
|
50
|
+
except Exception:
|
|
51
|
+
sys.modules.pop("_lib_fmt", None)
|
|
52
|
+
raise
|
|
53
|
+
return m.stable_sum
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
stable_sum = _import_stable_sum()
|
|
57
|
+
|
|
58
|
+
|
|
24
59
|
# Anthropic's per-call >200K-tokens tier — kept in sync with bin/_lib_pricing.
|
|
25
60
|
# Callers may override via the ``tiered_threshold`` kwarg.
|
|
26
61
|
DEFAULT_TIERED_THRESHOLD = 200_000
|
|
@@ -732,7 +767,7 @@ def _aggregate_cache_breakdown(
|
|
|
732
767
|
return tuple(out)
|
|
733
768
|
head = out[:top_n]
|
|
734
769
|
tail = out[top_n:]
|
|
735
|
-
other_net =
|
|
770
|
+
other_net = stable_sum(r.net_usd for r in tail)
|
|
736
771
|
# True aggregate hit % over the tail buckets — sum directly from the
|
|
737
772
|
# CacheBreakdownRow token fields (EFF-4 — avoids the previous triple
|
|
738
773
|
# walk over ``buckets.items()``).
|
|
@@ -800,7 +835,7 @@ def _aggregate_cache_breakdown_from_rows(
|
|
|
800
835
|
return tuple(out)
|
|
801
836
|
head = out[:top_n]
|
|
802
837
|
tail = out[top_n:]
|
|
803
|
-
other_net =
|
|
838
|
+
other_net = stable_sum(r.net_usd for r in tail)
|
|
804
839
|
tail_input = sum(r.input_tokens for r in tail)
|
|
805
840
|
tail_creation = sum(r.cache_creation_tokens for r in tail)
|
|
806
841
|
tail_read = sum(r.cache_read_tokens for r in tail)
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -119,6 +119,7 @@ format_display_dt = _lib_display_tz.format_display_dt
|
|
|
119
119
|
_lib_fmt = _load_lib("_lib_fmt")
|
|
120
120
|
_style_ansi = _lib_fmt._style_ansi
|
|
121
121
|
_supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
|
|
122
|
+
stable_sum = _lib_fmt.stable_sum
|
|
122
123
|
|
|
123
124
|
|
|
124
125
|
# === Honest imports from extracted homes ===================================
|
|
@@ -706,7 +707,7 @@ def _diff_resolve_used_pct(window: ParsedWindow) -> tuple:
|
|
|
706
707
|
return None, "n/a"
|
|
707
708
|
if not vals:
|
|
708
709
|
return None, "n/a"
|
|
709
|
-
return
|
|
710
|
+
return stable_sum(vals) / len(vals), "avg"
|
|
710
711
|
finally:
|
|
711
712
|
conn.close()
|
|
712
713
|
return None, "n/a"
|
|
@@ -855,7 +856,7 @@ def _sum_metric_bundles(bundles) -> "MetricBundle | None":
|
|
|
855
856
|
bundles = list(bundles)
|
|
856
857
|
if not bundles:
|
|
857
858
|
return None
|
|
858
|
-
cost =
|
|
859
|
+
cost = stable_sum(b.cost_usd for b in bundles)
|
|
859
860
|
ti = sum(b.tokens_input or 0 for b in bundles)
|
|
860
861
|
to = sum(b.tokens_output or 0 for b in bundles)
|
|
861
862
|
tcr = sum(b.tokens_cache_read or 0 for b in bundles)
|
package/bin/_lib_fmt.py
CHANGED
|
@@ -17,6 +17,7 @@ Spec: docs/superpowers/specs/2026-06-01-extract-fmt-color-table-primitives-desig
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import datetime as dt
|
|
20
|
+
import math
|
|
20
21
|
import os
|
|
21
22
|
import pathlib
|
|
22
23
|
import re
|
|
@@ -275,6 +276,17 @@ def _boxed_table(
|
|
|
275
276
|
return "\n".join(out_lines)
|
|
276
277
|
|
|
277
278
|
|
|
279
|
+
def stable_sum(values):
|
|
280
|
+
"""Exactly-rounded, interpreter-stable sum of float addends.
|
|
281
|
+
|
|
282
|
+
math.fsum (Shewchuk) returns a single correctly-rounded result that is
|
|
283
|
+
byte-identical across all CPython versions, unlike the built-in sum(),
|
|
284
|
+
which switched to Neumaier compensated summation for floats in 3.12. Use at
|
|
285
|
+
every float sum() whose result reaches byte-compared output.
|
|
286
|
+
"""
|
|
287
|
+
return math.fsum(values)
|
|
288
|
+
|
|
289
|
+
|
|
278
290
|
def _fmt_num(n: int) -> str:
|
|
279
291
|
"""Format integer with comma separators: 1234567 -> '1,234,567'."""
|
|
280
292
|
return f"{n:,}"
|
package/bin/_lib_render.py
CHANGED
|
@@ -108,6 +108,7 @@ _style_ansi = _lib_fmt._style_ansi
|
|
|
108
108
|
_fmt_num = _lib_fmt._fmt_num
|
|
109
109
|
_truncate_num = _lib_fmt._truncate_num
|
|
110
110
|
_boxed_table = _lib_fmt._boxed_table
|
|
111
|
+
stable_sum = _lib_fmt.stable_sum
|
|
111
112
|
|
|
112
113
|
|
|
113
114
|
# Module-level back-ref shims. Each shim resolves
|
|
@@ -788,7 +789,7 @@ def _bucket_totals_dict(buckets) -> dict[str, Any]:
|
|
|
788
789
|
"outputTokens": sum(b.output_tokens for b in buckets),
|
|
789
790
|
"cacheCreationTokens": sum(b.cache_creation_tokens for b in buckets),
|
|
790
791
|
"cacheReadTokens": sum(b.cache_read_tokens for b in buckets),
|
|
791
|
-
"totalCost":
|
|
792
|
+
"totalCost": stable_sum(b.cost_usd for b in buckets),
|
|
792
793
|
"totalTokens": sum(b.total_tokens for b in buckets),
|
|
793
794
|
}
|
|
794
795
|
|
|
@@ -1354,7 +1355,7 @@ def _render_bucket_table(
|
|
|
1354
1355
|
tot_cc = sum(d.cache_creation_tokens for d in footer_buckets)
|
|
1355
1356
|
tot_cr = sum(d.cache_read_tokens for d in footer_buckets)
|
|
1356
1357
|
tot_tokens = sum(d.total_tokens for d in footer_buckets)
|
|
1357
|
-
tot_cost =
|
|
1358
|
+
tot_cost = stable_sum(d.cost_usd for d in footer_buckets)
|
|
1358
1359
|
footer_cells = [
|
|
1359
1360
|
("Total", _yellow),
|
|
1360
1361
|
("", None),
|
|
@@ -1695,7 +1696,7 @@ def _render_weekly_table(
|
|
|
1695
1696
|
tot_cc = sum(d.cache_creation_tokens for d in buckets)
|
|
1696
1697
|
tot_cr = sum(d.cache_read_tokens for d in buckets)
|
|
1697
1698
|
tot_tokens = sum(d.total_tokens for d in buckets)
|
|
1698
|
-
tot_cost =
|
|
1699
|
+
tot_cost = stable_sum(d.cost_usd for d in buckets)
|
|
1699
1700
|
footer_cells = [
|
|
1700
1701
|
("Total", _yellow),
|
|
1701
1702
|
("", None),
|
|
@@ -1984,7 +1985,7 @@ def _render_codex_bucket_table(
|
|
|
1984
1985
|
tot_reasoning = sum(b.reasoning_output_tokens for b in buckets)
|
|
1985
1986
|
tot_non_cached = max(0, tot_input_inclusive - tot_cached)
|
|
1986
1987
|
tot_tokens = tot_input_inclusive + tot_output
|
|
1987
|
-
tot_cost =
|
|
1988
|
+
tot_cost = stable_sum(b.cost_usd for b in buckets)
|
|
1988
1989
|
footer_cells = [
|
|
1989
1990
|
("Total", _yellow),
|
|
1990
1991
|
("", None),
|
|
@@ -2264,7 +2265,7 @@ def _render_codex_session_table(
|
|
|
2264
2265
|
tot_reasoning = sum(s.reasoning_output_tokens for s in sessions)
|
|
2265
2266
|
tot_non_cached = max(0, tot_input_inclusive - tot_cached)
|
|
2266
2267
|
tot_tokens = tot_input_inclusive + tot_output
|
|
2267
|
-
tot_cost =
|
|
2268
|
+
tot_cost = stable_sum(s.cost_usd for s in sessions)
|
|
2268
2269
|
footer_cells = [
|
|
2269
2270
|
("Total", _yellow),
|
|
2270
2271
|
("", None), ("", None), ("", None),
|
|
@@ -2540,7 +2541,7 @@ def _render_claude_session_table(
|
|
|
2540
2541
|
tot_output = sum(s.output_tokens for s in sessions)
|
|
2541
2542
|
# Issue #104: Total Tokens footer sums all four components.
|
|
2542
2543
|
tot_tokens = sum(s.total_tokens for s in sessions)
|
|
2543
|
-
tot_cost =
|
|
2544
|
+
tot_cost = stable_sum(s.cost_usd for s in sessions)
|
|
2544
2545
|
footer_cells = [
|
|
2545
2546
|
("Total", _yellow),
|
|
2546
2547
|
("", None), ("", None), ("", None),
|
package/bin/_lib_share.py
CHANGED
|
@@ -66,8 +66,14 @@ class DeltaCell:
|
|
|
66
66
|
|
|
67
67
|
@dataclass(frozen=True)
|
|
68
68
|
class ProjectCell:
|
|
69
|
-
"""Anonymization chokepoint — scrubber rewrites the `label` field.
|
|
69
|
+
"""Anonymization chokepoint — scrubber rewrites the `label` field.
|
|
70
|
+
|
|
71
|
+
`rank_cost` (#130): an explicit spend value used by
|
|
72
|
+
`_collect_project_costs` to rank anonymized labels, replacing the old
|
|
73
|
+
hidden-MoneyCell hack. When None, ranking falls back to summing sibling
|
|
74
|
+
MoneyCells (back-compat for every non-budget construction site)."""
|
|
70
75
|
label: str
|
|
76
|
+
rank_cost: float | None = None
|
|
71
77
|
|
|
72
78
|
Cell = TextCell | MoneyCell | PercentCell | DateCell | DeltaCell | ProjectCell
|
|
73
79
|
|
|
@@ -735,7 +741,8 @@ def _render_svg_footer(snap: ShareSnapshot, *, palette: dict,
|
|
|
735
741
|
|
|
736
742
|
def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
|
|
737
743
|
"""Walk rows: for each row containing a ProjectCell, sum MoneyCell values
|
|
738
|
-
in the same row under the project label
|
|
744
|
+
in the same row under the project label — unless the ProjectCell carries an
|
|
745
|
+
explicit ``rank_cost``, which takes precedence over the MoneyCell sum (#130).
|
|
739
746
|
|
|
740
747
|
Charts also contribute via ChartPoint.project_label + y_value (when y_value
|
|
741
748
|
is in $). For consistency we union both sources; rows take precedence on
|
|
@@ -744,13 +751,16 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
|
|
|
744
751
|
for row in snap.rows:
|
|
745
752
|
proj_label: str | None = None
|
|
746
753
|
money = 0.0
|
|
754
|
+
explicit_rank: float | None = None
|
|
747
755
|
for cell in row.cells.values():
|
|
748
756
|
if isinstance(cell, ProjectCell):
|
|
749
757
|
proj_label = cell.label
|
|
758
|
+
explicit_rank = cell.rank_cost
|
|
750
759
|
elif isinstance(cell, MoneyCell):
|
|
751
760
|
money += cell.usd
|
|
752
761
|
if proj_label is not None:
|
|
753
|
-
|
|
762
|
+
contribution = explicit_rank if explicit_rank is not None else money
|
|
763
|
+
costs[proj_label] = costs.get(proj_label, 0.0) + contribution
|
|
754
764
|
|
|
755
765
|
if snap.chart is not None:
|
|
756
766
|
chart_pts: list[ChartPoint] = []
|
|
@@ -825,7 +835,9 @@ def _apply_anon_mapping(
|
|
|
825
835
|
new_cells: dict[str, Cell] = {}
|
|
826
836
|
for key, cell in row.cells.items():
|
|
827
837
|
if isinstance(cell, ProjectCell) and cell.label in mapping:
|
|
828
|
-
new_cells[key] = ProjectCell(
|
|
838
|
+
new_cells[key] = ProjectCell(
|
|
839
|
+
mapping[cell.label], rank_cost=cell.rank_cost
|
|
840
|
+
)
|
|
829
841
|
else:
|
|
830
842
|
new_cells[key] = cell
|
|
831
843
|
new_rows.append(Row(cells=new_cells))
|
|
@@ -155,6 +155,40 @@ def _import_share_lib():
|
|
|
155
155
|
_LS = _import_share_lib()
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
def _import_stable_sum():
|
|
159
|
+
"""Resolve ``_lib_fmt.stable_sum`` — the interpreter-stable float-summation
|
|
160
|
+
chokepoint (math.fsum) used for output-bound cost totals.
|
|
161
|
+
|
|
162
|
+
The ``sys.modules`` fast path is what runs in practice (``_lib_fmt`` is a
|
|
163
|
+
foundational kernel already imported by ``bin/cctally`` and the test
|
|
164
|
+
harness). The path-load fallback differs from ``_import_share_lib``'s:
|
|
165
|
+
``_lib_share`` is dep-free, but ``_lib_fmt`` imports the leaf kernels
|
|
166
|
+
``_cctally_core`` + ``_lib_display_tz`` *by name*, so the fallback must put
|
|
167
|
+
``bin/`` on ``sys.path`` before executing it or those imports raise
|
|
168
|
+
ModuleNotFoundError. The leaves never import back, so this stays acyclic.
|
|
169
|
+
"""
|
|
170
|
+
import sys
|
|
171
|
+
if "_lib_fmt" in sys.modules:
|
|
172
|
+
return sys.modules["_lib_fmt"].stable_sum
|
|
173
|
+
from pathlib import Path
|
|
174
|
+
import importlib.util
|
|
175
|
+
bin_dir = Path(__file__).resolve().parent
|
|
176
|
+
if str(bin_dir) not in sys.path:
|
|
177
|
+
sys.path.insert(0, str(bin_dir))
|
|
178
|
+
spec = importlib.util.spec_from_file_location("_lib_fmt", bin_dir / "_lib_fmt.py")
|
|
179
|
+
m = importlib.util.module_from_spec(spec)
|
|
180
|
+
sys.modules["_lib_fmt"] = m
|
|
181
|
+
try:
|
|
182
|
+
spec.loader.exec_module(m)
|
|
183
|
+
except Exception:
|
|
184
|
+
sys.modules.pop("_lib_fmt", None)
|
|
185
|
+
raise
|
|
186
|
+
return m.stable_sum
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
stable_sum = _import_stable_sum()
|
|
190
|
+
|
|
191
|
+
|
|
158
192
|
def _kpi_strip(*items: tuple[str, str]) -> tuple:
|
|
159
193
|
"""Generic KPI strip → tuple of `Totalled`."""
|
|
160
194
|
return tuple(_LS.Totalled(label=lbl, value=val) for lbl, val in items)
|
|
@@ -280,7 +314,7 @@ def _detect_residual(
|
|
|
280
314
|
`has_other_residual` in `_cross_tab_columns`.
|
|
281
315
|
"""
|
|
282
316
|
for row_total, breakdown in rows_and_breakdowns:
|
|
283
|
-
top_k_sum =
|
|
317
|
+
top_k_sum = stable_sum(float(breakdown.get(lbl, 0.0)) for lbl in top_k_labels)
|
|
284
318
|
if abs(row_total - top_k_sum) > epsilon:
|
|
285
319
|
return True
|
|
286
320
|
return False
|
|
@@ -537,7 +571,7 @@ def _build_daily_recap(*, panel_data, options):
|
|
|
537
571
|
start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
|
|
538
572
|
end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
|
|
539
573
|
end = end_anchor + _dt.timedelta(days=1)
|
|
540
|
-
sum_cost =
|
|
574
|
+
sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
|
|
541
575
|
return _LS.ShareSnapshot(
|
|
542
576
|
cmd="daily",
|
|
543
577
|
title=f"Daily — last {len(days)} day{'s' if len(days) != 1 else ''}",
|
|
@@ -591,7 +625,7 @@ def _build_monthly_recap(*, panel_data, options):
|
|
|
591
625
|
end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
|
|
592
626
|
else:
|
|
593
627
|
end = start
|
|
594
|
-
sum_cost =
|
|
628
|
+
sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
|
|
595
629
|
return _LS.ShareSnapshot(
|
|
596
630
|
cmd="monthly",
|
|
597
631
|
title=f"Monthly — last {len(months)} month{'s' if len(months) != 1 else ''}",
|
|
@@ -761,7 +795,7 @@ def _build_sessions_recap(*, panel_data, options):
|
|
|
761
795
|
sessions = panel_data.get("sessions") or []
|
|
762
796
|
cap = options.get("top_n", 15)
|
|
763
797
|
rows_iter = sessions[:cap]
|
|
764
|
-
sum_cost =
|
|
798
|
+
sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
|
|
765
799
|
starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
|
|
766
800
|
start = min(starts) if starts else _utc_now()
|
|
767
801
|
end = max(starts) if starts else start
|
|
@@ -1067,7 +1101,7 @@ def _build_daily_visual(*, panel_data, options):
|
|
|
1067
1101
|
start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
|
|
1068
1102
|
end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
|
|
1069
1103
|
end = end_anchor + _dt.timedelta(days=1)
|
|
1070
|
-
sum_cost =
|
|
1104
|
+
sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
|
|
1071
1105
|
return _LS.ShareSnapshot(
|
|
1072
1106
|
cmd="daily",
|
|
1073
1107
|
title=f"Daily visual — last {len(days)} day{'s' if len(days) != 1 else ''}",
|
|
@@ -1103,7 +1137,7 @@ def _build_daily_detail(*, panel_data, options):
|
|
|
1103
1137
|
start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
|
|
1104
1138
|
end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
|
|
1105
1139
|
end = end_anchor + _dt.timedelta(days=1)
|
|
1106
|
-
sum_cost =
|
|
1140
|
+
sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
|
|
1107
1141
|
top_n = max(int(options.get("top_n", 5)), 1)
|
|
1108
1142
|
|
|
1109
1143
|
breakdowns = [dict(d.get("projects") or {}) for d in days]
|
|
@@ -1170,7 +1204,7 @@ def _build_monthly_visual(*, panel_data, options):
|
|
|
1170
1204
|
end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
|
|
1171
1205
|
else:
|
|
1172
1206
|
end = start
|
|
1173
|
-
sum_cost =
|
|
1207
|
+
sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
|
|
1174
1208
|
return _LS.ShareSnapshot(
|
|
1175
1209
|
cmd="monthly",
|
|
1176
1210
|
title=f"Monthly visual — last {len(months)} month{'s' if len(months) != 1 else ''}",
|
|
@@ -1211,7 +1245,7 @@ def _build_monthly_detail(*, panel_data, options):
|
|
|
1211
1245
|
end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
|
|
1212
1246
|
else:
|
|
1213
1247
|
end = start
|
|
1214
|
-
sum_cost =
|
|
1248
|
+
sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
|
|
1215
1249
|
top_n = max(int(options.get("top_n", 5)), 1)
|
|
1216
1250
|
|
|
1217
1251
|
breakdowns = [dict(m.get("models") or {}) for m in months]
|
|
@@ -1476,7 +1510,7 @@ def _build_sessions_visual(*, panel_data, options):
|
|
|
1476
1510
|
sessions = panel_data.get("sessions") or []
|
|
1477
1511
|
cap = int(options.get("top_n", 8))
|
|
1478
1512
|
rows_iter = sessions[:cap]
|
|
1479
|
-
sum_cost =
|
|
1513
|
+
sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
|
|
1480
1514
|
starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
|
|
1481
1515
|
start = min(starts) if starts else _utc_now()
|
|
1482
1516
|
end = max(starts) if starts else start
|
|
@@ -1522,7 +1556,7 @@ def _build_sessions_detail(*, panel_data, options):
|
|
|
1522
1556
|
sessions = panel_data.get("sessions") or []
|
|
1523
1557
|
cap = options.get("top_n", 50)
|
|
1524
1558
|
rows_iter = sessions[:cap]
|
|
1525
|
-
sum_cost =
|
|
1559
|
+
sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
|
|
1526
1560
|
starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
|
|
1527
1561
|
start = min(starts) if starts else _utc_now()
|
|
1528
1562
|
end = max(starts) if starts else start
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -102,6 +102,13 @@ def _load_lib(name: str):
|
|
|
102
102
|
return mod
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
# ``_lib_fmt``'s only intra-repo deps are the leaf kernels ``_cctally_core`` +
|
|
106
|
+
# ``_lib_display_tz`` (neither imports back), so loading it here is acyclic.
|
|
107
|
+
# ``stable_sum`` is the interpreter-stable
|
|
108
|
+
# float-summation chokepoint (math.fsum) used for output-bound totals.
|
|
109
|
+
stable_sum = _load_lib("_lib_fmt").stable_sum
|
|
110
|
+
|
|
111
|
+
|
|
105
112
|
# === Row dataclasses (Task 2: moved verbatim from _cctally_tui.py) =========
|
|
106
113
|
# Field order, types, and defaults match the originals byte-stable.
|
|
107
114
|
|
|
@@ -928,7 +935,7 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
|
|
|
928
935
|
# least 3 samples qualify.
|
|
929
936
|
valid_dpps = [r.dollars_per_percent for r in rows
|
|
930
937
|
if r.dollars_per_percent is not None]
|
|
931
|
-
avg = (
|
|
938
|
+
avg = (stable_sum(valid_dpps) / len(valid_dpps)) if len(valid_dpps) >= 3 else None
|
|
932
939
|
|
|
933
940
|
# Issue #59 — pre-compute the 4-week-median-non-current dpp scalar
|
|
934
941
|
# the dashboard's Trend modal hero KV displays. Rule mirrors
|
|
@@ -1290,7 +1297,7 @@ def build_blocks_view_from_table_rows(
|
|
|
1290
1297
|
(``cmd_five_hour_blocks`` produces newest-first DESC).
|
|
1291
1298
|
"""
|
|
1292
1299
|
rows_seq = list(block_dicts)
|
|
1293
|
-
total_cost =
|
|
1300
|
+
total_cost = stable_sum(
|
|
1294
1301
|
float(d.get("total_cost_usd") or 0.0) for d in rows_seq
|
|
1295
1302
|
)
|
|
1296
1303
|
total_tok = sum(
|
package/bin/cctally
CHANGED
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import sys
|
|
15
15
|
|
|
16
|
-
__min_python_version__ = (3,
|
|
16
|
+
__min_python_version__ = (3, 11)
|
|
17
17
|
|
|
18
18
|
if sys.version_info < __min_python_version__:
|
|
19
19
|
print(
|
|
@@ -634,6 +634,9 @@ cmd_project = _cctally_project.cmd_project
|
|
|
634
634
|
# per-project budget display (cmd_budget) and the Task 3 firing path reach it
|
|
635
635
|
# via the cctally namespace.
|
|
636
636
|
_sum_cost_by_project = _cctally_project._sum_cost_by_project
|
|
637
|
+
# Shared per-project label disambiguation (#130). Re-exported so the budget
|
|
638
|
+
# display, firing path, and dashboard SSE envelope reach the SAME primitive.
|
|
639
|
+
_project_budget_labels = _cctally_project._project_budget_labels
|
|
637
640
|
|
|
638
641
|
# Eager re-export of bin/_cctally_pricing_check.py — `cmd_pricing_check`
|
|
639
642
|
# is invoked via the parser's `set_defaults(func=c.cmd_pricing_check)`,
|
|
@@ -2081,6 +2084,7 @@ get_milestones_for_week = _cctally_milestones.get_milestones_for_wee
|
|
|
2081
2084
|
insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
|
|
2082
2085
|
insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
|
|
2083
2086
|
insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
|
|
2087
|
+
_project_crossings = _cctally_milestones._project_crossings # record shim; milestones c. (#130 firing/reconcile shared crossing arithmetic)
|
|
2084
2088
|
insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
|
|
2085
2089
|
_projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
|
|
2086
2090
|
_reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
|
package/bin/cctally-npm-shim.js
CHANGED
|
@@ -25,7 +25,7 @@ const result = spawnSync(python, [scriptPath, ...process.argv.slice(2)], {
|
|
|
25
25
|
if (result.error) {
|
|
26
26
|
if (result.error.code === 'ENOENT') {
|
|
27
27
|
console.error(
|
|
28
|
-
`cctally: cannot find ${python}. Install Python 3.
|
|
28
|
+
`cctally: cannot find ${python}. Install Python 3.11+ ` +
|
|
29
29
|
'or set CCTALLY_PYTHON to its path.'
|
|
30
30
|
);
|
|
31
31
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|