cctally 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/bin/_cctally_cache.py +479 -54
- package/bin/_cctally_core.py +8 -0
- package/bin/_cctally_dashboard.py +1467 -234
- package/bin/_cctally_db.py +27 -1
- package/bin/_cctally_doctor.py +2 -0
- package/bin/_cctally_forecast.py +24 -2
- package/bin/_cctally_parser.py +28 -2
- package/bin/_cctally_tui.py +708 -16
- package/bin/_cctally_update.py +49 -9
- package/bin/_cctally_weekrefs.py +101 -2
- package/bin/_lib_aggregators.py +98 -71
- package/bin/_lib_cache_report.py +455 -67
- package/bin/_lib_doctor.py +10 -0
- package/bin/_lib_pricing.py +31 -4
- package/bin/_lib_snapshot_cache.py +1797 -0
- package/bin/_lib_view_models.py +107 -12
- package/bin/cctally +57 -0
- package/dashboard/static/assets/index-0jzYm75p.css +1 -0
- package/dashboard/static/assets/{index-DQH6UPc_.js → index-D_Ylyqsf.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-uGwZSssU.css +0 -1
package/bin/_lib_cache_report.py
CHANGED
|
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import datetime as dt
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
|
-
from typing import Any, Callable, Iterable, Literal, Optional
|
|
20
|
+
from typing import Any, Callable, Iterable, Literal, NamedTuple, Optional
|
|
21
21
|
from zoneinfo import ZoneInfo
|
|
22
22
|
|
|
23
23
|
|
|
@@ -175,6 +175,23 @@ class _Bucket:
|
|
|
175
175
|
net_usd: float = 0.0
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class _ProjectPartial:
|
|
180
|
+
"""One (day, project) net-$/token partial — a frozen cache primitive (#272 §4).
|
|
181
|
+
|
|
182
|
+
Produced by ``aggregate_by_day_project`` and combined across days by
|
|
183
|
+
``combine_day_project_partials``. ``net_usd`` is a ``stable_sum`` over
|
|
184
|
+
that (day, project) group's per-entry nets (order-independent); the
|
|
185
|
+
token components are associative int sums. Frozen so a cached day
|
|
186
|
+
(§5) can hold a tuple of these without any risk of tick-to-tick
|
|
187
|
+
mutation (F7).
|
|
188
|
+
"""
|
|
189
|
+
net_usd: float
|
|
190
|
+
input_tokens: int
|
|
191
|
+
cache_creation_tokens: int
|
|
192
|
+
cache_read_tokens: int
|
|
193
|
+
|
|
194
|
+
|
|
178
195
|
def _compute_cache_hit_percent(
|
|
179
196
|
input_tokens: int,
|
|
180
197
|
cache_creation_tokens: int,
|
|
@@ -696,6 +713,41 @@ def _classify_anomalies(
|
|
|
696
713
|
# Window-wide breakdown aggregator (by-project / by-model dedup)
|
|
697
714
|
# ---------------------------------------------------------------------------
|
|
698
715
|
|
|
716
|
+
def _finalize_breakdown_rows(
|
|
717
|
+
rows: list["CacheBreakdownRow"],
|
|
718
|
+
*,
|
|
719
|
+
top_n: int = 5,
|
|
720
|
+
other_label: str = "(other)",
|
|
721
|
+
) -> tuple["CacheBreakdownRow", ...]:
|
|
722
|
+
"""Sort by ``abs(net_usd)`` desc, keep ``top_n``, fold the tail into ``(other)``.
|
|
723
|
+
|
|
724
|
+
Shared by ``_aggregate_cache_breakdown``,
|
|
725
|
+
``_aggregate_cache_breakdown_from_rows``, and
|
|
726
|
+
``combine_day_project_partials`` (#272 §4) so the sort / top-N /
|
|
727
|
+
``(other)`` contract lives in one place. Byte-identical to the prior
|
|
728
|
+
inline tail: the ``(other)`` net is a ``stable_sum`` over the tail's
|
|
729
|
+
per-row nets and its ``cache_hit_percent`` is the true aggregate over
|
|
730
|
+
the tail's summed token components (EFF-4).
|
|
731
|
+
"""
|
|
732
|
+
rows.sort(key=lambda r: abs(r.net_usd), reverse=True)
|
|
733
|
+
if len(rows) <= top_n:
|
|
734
|
+
return tuple(rows)
|
|
735
|
+
head = rows[:top_n]
|
|
736
|
+
tail = rows[top_n:]
|
|
737
|
+
other_net = stable_sum(r.net_usd for r in tail)
|
|
738
|
+
tail_input = sum(r.input_tokens for r in tail)
|
|
739
|
+
tail_creation = sum(r.cache_creation_tokens for r in tail)
|
|
740
|
+
tail_read = sum(r.cache_read_tokens for r in tail)
|
|
741
|
+
other_pct = _compute_cache_hit_percent(tail_input, tail_creation, tail_read)
|
|
742
|
+
head.append(CacheBreakdownRow(
|
|
743
|
+
key=other_label, cache_hit_percent=other_pct, net_usd=other_net,
|
|
744
|
+
input_tokens=tail_input,
|
|
745
|
+
cache_creation_tokens=tail_creation,
|
|
746
|
+
cache_read_tokens=tail_read,
|
|
747
|
+
))
|
|
748
|
+
return tuple(head)
|
|
749
|
+
|
|
750
|
+
|
|
699
751
|
def _aggregate_cache_breakdown(
|
|
700
752
|
entries: Iterable,
|
|
701
753
|
*,
|
|
@@ -762,26 +814,7 @@ def _aggregate_cache_breakdown(
|
|
|
762
814
|
cache_creation_tokens=b.cache_creation_tokens,
|
|
763
815
|
cache_read_tokens=b.cache_read_tokens,
|
|
764
816
|
))
|
|
765
|
-
|
|
766
|
-
if len(out) <= top_n:
|
|
767
|
-
return tuple(out)
|
|
768
|
-
head = out[:top_n]
|
|
769
|
-
tail = out[top_n:]
|
|
770
|
-
other_net = stable_sum(r.net_usd for r in tail)
|
|
771
|
-
# True aggregate hit % over the tail buckets — sum directly from the
|
|
772
|
-
# CacheBreakdownRow token fields (EFF-4 — avoids the previous triple
|
|
773
|
-
# walk over ``buckets.items()``).
|
|
774
|
-
tail_input = sum(r.input_tokens for r in tail)
|
|
775
|
-
tail_creation = sum(r.cache_creation_tokens for r in tail)
|
|
776
|
-
tail_read = sum(r.cache_read_tokens for r in tail)
|
|
777
|
-
other_pct = _compute_cache_hit_percent(tail_input, tail_creation, tail_read)
|
|
778
|
-
head.append(CacheBreakdownRow(
|
|
779
|
-
key=other_label, cache_hit_percent=other_pct, net_usd=other_net,
|
|
780
|
-
input_tokens=tail_input,
|
|
781
|
-
cache_creation_tokens=tail_creation,
|
|
782
|
-
cache_read_tokens=tail_read,
|
|
783
|
-
))
|
|
784
|
-
return tuple(head)
|
|
817
|
+
return _finalize_breakdown_rows(out, top_n=top_n, other_label=other_label)
|
|
785
818
|
|
|
786
819
|
|
|
787
820
|
def _aggregate_cache_breakdown_from_rows(
|
|
@@ -830,23 +863,317 @@ def _aggregate_cache_breakdown_from_rows(
|
|
|
830
863
|
cache_creation_tokens=b.cache_creation_tokens,
|
|
831
864
|
cache_read_tokens=b.cache_read_tokens,
|
|
832
865
|
))
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
866
|
+
return _finalize_breakdown_rows(out, top_n=top_n, other_label=other_label)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# ---------------------------------------------------------------------------
|
|
870
|
+
# Two-level by_project fold (grouping-invariant; dashboard-only) — #272 §4
|
|
871
|
+
# ---------------------------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
def aggregate_by_day_project(
|
|
874
|
+
entries: "Iterable",
|
|
875
|
+
*,
|
|
876
|
+
display_tz: "ZoneInfo | None",
|
|
877
|
+
pricing: dict,
|
|
878
|
+
skip_synthetic: bool = True,
|
|
879
|
+
) -> "dict[str, dict[str, _ProjectPartial]]":
|
|
880
|
+
"""Per-(display-tz day, project) net-$/token partials (#272 §4).
|
|
881
|
+
|
|
882
|
+
Groups RAW entries (each carrying ``.project_path`` / ``.model`` /
|
|
883
|
+
``.cache_*`` / ``.input_tokens`` / ``.timestamp``) by (day, project).
|
|
884
|
+
The per-(day, project) ``net_usd`` is a ``stable_sum`` over that
|
|
885
|
+
group's per-entry nets so it is order-independent
|
|
886
|
+
(``get_claude_session_entries`` orders by ``timestamp_utc`` only, with
|
|
887
|
+
no ``id`` tiebreak). Token components are associative int sums. Drops
|
|
888
|
+
``<synthetic>``-model entries when ``skip_synthetic`` (matches the
|
|
889
|
+
by_project contract; the day-row ``model_breakdowns`` deliberately keep
|
|
890
|
+
``<synthetic>``). Day key uses ``_resolve_bucket_tz(display_tz)`` —
|
|
891
|
+
identical to ``_aggregate_cache_by_day``, so keys align with
|
|
892
|
+
``CacheRow.date`` / the builder's ``kept_dates``.
|
|
893
|
+
"""
|
|
894
|
+
tz = _resolve_bucket_tz(display_tz)
|
|
895
|
+
nets: dict[tuple[str, str], list[float]] = {}
|
|
896
|
+
toks: dict[tuple[str, str], list[int]] = {} # [input, creation, read]
|
|
897
|
+
for e in entries:
|
|
898
|
+
model = getattr(e, "model", "")
|
|
899
|
+
if skip_synthetic and model == "<synthetic>":
|
|
900
|
+
continue
|
|
901
|
+
day_key = e.timestamp.astimezone(tz).strftime("%Y-%m-%d")
|
|
902
|
+
proj = getattr(e, "project_path", None) or "(unknown)"
|
|
903
|
+
k = (day_key, proj)
|
|
904
|
+
create_tok = getattr(e, "cache_creation_tokens", 0)
|
|
905
|
+
read_tok = getattr(e, "cache_read_tokens", 0)
|
|
906
|
+
_s, _w, net = _compute_entry_cache_dollars(
|
|
907
|
+
model, create_tok, read_tok, pricing=pricing,
|
|
908
|
+
)
|
|
909
|
+
nets.setdefault(k, []).append(net)
|
|
910
|
+
t = toks.setdefault(k, [0, 0, 0])
|
|
911
|
+
t[0] += getattr(e, "input_tokens", 0)
|
|
912
|
+
t[1] += create_tok
|
|
913
|
+
t[2] += read_tok
|
|
914
|
+
out: dict[str, dict[str, _ProjectPartial]] = {}
|
|
915
|
+
for (day_key, proj), net_list in nets.items():
|
|
916
|
+
t = toks[(day_key, proj)]
|
|
917
|
+
out.setdefault(day_key, {})[proj] = _ProjectPartial(
|
|
918
|
+
net_usd=stable_sum(net_list),
|
|
919
|
+
input_tokens=t[0],
|
|
920
|
+
cache_creation_tokens=t[1],
|
|
921
|
+
cache_read_tokens=t[2],
|
|
922
|
+
)
|
|
923
|
+
return out
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def combine_day_project_partials(
|
|
927
|
+
partials_by_date: "dict[str, dict[str, _ProjectPartial]]",
|
|
928
|
+
*,
|
|
929
|
+
top_n: int = 5,
|
|
930
|
+
other_label: str = "(other)",
|
|
931
|
+
) -> tuple["CacheBreakdownRow", ...]:
|
|
932
|
+
"""Combine per-(day, project) partials into the window by_project rows (#272 §4).
|
|
933
|
+
|
|
934
|
+
Per project: ``stable_sum`` its day-partials' ``net_usd`` across the
|
|
935
|
+
given dates (byte-identical whether the dates come from a fresh
|
|
936
|
+
full-window fold or a per-day cache), int-sum its token components,
|
|
937
|
+
compute ``cache_hit_percent`` once, then the shared top-N / ``(other)``
|
|
938
|
+
fold. Dates are iterated in sorted order so the by_project map's
|
|
939
|
+
insertion order is deterministic (warm == cold); with order-independent
|
|
940
|
+
partials this only affects exact-``abs(net)`` tie ordering, which the
|
|
941
|
+
stable sort then resolves identically on both paths.
|
|
942
|
+
"""
|
|
943
|
+
net_lists: dict[str, list[float]] = {}
|
|
944
|
+
tok: dict[str, list[int]] = {} # [input, creation, read]
|
|
945
|
+
for day_key in sorted(partials_by_date):
|
|
946
|
+
for proj, p in partials_by_date[day_key].items():
|
|
947
|
+
net_lists.setdefault(proj, []).append(p.net_usd)
|
|
948
|
+
t = tok.setdefault(proj, [0, 0, 0])
|
|
949
|
+
t[0] += p.input_tokens
|
|
950
|
+
t[1] += p.cache_creation_tokens
|
|
951
|
+
t[2] += p.cache_read_tokens
|
|
952
|
+
rows: list[CacheBreakdownRow] = []
|
|
953
|
+
for proj, nets in net_lists.items():
|
|
954
|
+
t = tok[proj]
|
|
955
|
+
rows.append(CacheBreakdownRow(
|
|
956
|
+
key=proj,
|
|
957
|
+
cache_hit_percent=_compute_cache_hit_percent(t[0], t[1], t[2]),
|
|
958
|
+
net_usd=stable_sum(nets),
|
|
959
|
+
input_tokens=t[0],
|
|
960
|
+
cache_creation_tokens=t[1],
|
|
961
|
+
cache_read_tokens=t[2],
|
|
962
|
+
))
|
|
963
|
+
return _finalize_breakdown_rows(rows, top_n=top_n, other_label=other_label)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
# ---------------------------------------------------------------------------
|
|
967
|
+
# Frozen per-day cached unit + build / reconstruct helpers (#272 §5)
|
|
968
|
+
# ---------------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
class _FrozenModelBreakdown(NamedTuple):
|
|
971
|
+
"""Immutable mirror of ``CacheModelBreakdown`` (#272 §5, Codex-4).
|
|
972
|
+
|
|
973
|
+
``CacheModelBreakdown`` is a *mutable* dataclass; a cached day must hold
|
|
974
|
+
its per-model children as frozen primitives so nothing reachable from a
|
|
975
|
+
published ``DataSnapshot`` is ever mutated tick-to-tick (F7). Field names
|
|
976
|
+
and order mirror ``CacheModelBreakdown`` EXACTLY (``:79``) so
|
|
977
|
+
``reconstruct_cache_row`` can rebuild an equal mutable child by keyword.
|
|
978
|
+
"""
|
|
979
|
+
model_name: str
|
|
980
|
+
input_tokens: int
|
|
981
|
+
output_tokens: int
|
|
982
|
+
cache_creation_tokens: int
|
|
983
|
+
cache_read_tokens: int
|
|
984
|
+
cache_hit_percent: float
|
|
985
|
+
cost: float
|
|
986
|
+
saved_usd: float
|
|
987
|
+
wasted_usd: float
|
|
988
|
+
net_usd: float
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@dataclass(frozen=True)
|
|
992
|
+
class CachedCacheReportDay:
|
|
993
|
+
"""One CLOSED day's raw aggregate + per-project partials, frozen (#272 §5).
|
|
994
|
+
|
|
995
|
+
Pre-classification: it carries NO ``anomaly_*`` state — those are
|
|
996
|
+
populated on a freshly reconstructed mutable ``CacheRow`` each tick
|
|
997
|
+
(``reconstruct_cache_row``), so nothing cached is ever mutated (F7).
|
|
998
|
+
|
|
999
|
+
Fields are the non-anomaly day-mode ``CacheRow`` scalars (``:113``) —
|
|
1000
|
+
``cache_hit_percent`` is a snapshot of the row's derived property (the
|
|
1001
|
+
reconstructed row recomputes it from tokens, so it is informational
|
|
1002
|
+
here) — plus:
|
|
1003
|
+
- ``model_breakdowns``: a tuple of ``_FrozenModelBreakdown`` KEEPING
|
|
1004
|
+
``<synthetic>`` (the day-row contract; by_model applies its own
|
|
1005
|
+
``skip_synthetic`` at fold time).
|
|
1006
|
+
- ``project_partials``: a tuple of ``(project_key, _ProjectPartial)``
|
|
1007
|
+
pairs (``skip_synthetic=True`` — the by_project restitch input, §4).
|
|
1008
|
+
|
|
1009
|
+
Day-mode only: the session-mode ``CacheRow`` fields (``session_id`` /
|
|
1010
|
+
``project_path`` / ``last_activity`` / ``source_paths``) are never
|
|
1011
|
+
populated by ``_aggregate_cache_by_day`` (they stay at their defaults),
|
|
1012
|
+
so they are not stored; ``reconstruct_cache_row`` leaves them at the
|
|
1013
|
+
``CacheRow`` defaults, which the round-trip equality test confirms.
|
|
1014
|
+
|
|
1015
|
+
``is_empty`` is a trailing sentinel flag (default ``False``): a genuinely
|
|
1016
|
+
activity-free CLOSED day never appears in ``build_cached_days`` output
|
|
1017
|
+
(that only emits days with entries), so the builder stores an
|
|
1018
|
+
``empty_cached_day`` sentinel for it — registering the quiet day as a
|
|
1019
|
+
cache HIT (mirroring ``projects_env_week_put`` storing a computed-empty
|
|
1020
|
+
week) so a weekend/vacation gap day can't permanently defeat ``have_all``.
|
|
1021
|
+
The warm restitch SKIPS ``is_empty`` days entirely: they contribute no
|
|
1022
|
+
``CacheRow`` and no by_project partials, so a sentinel is byte-identical
|
|
1023
|
+
to from-scratch (which never emits a row for a day with no entries).
|
|
1024
|
+
"""
|
|
1025
|
+
date: str
|
|
1026
|
+
cache_hit_percent: float
|
|
1027
|
+
input_tokens: int
|
|
1028
|
+
output_tokens: int
|
|
1029
|
+
cache_creation_tokens: int
|
|
1030
|
+
cache_read_tokens: int
|
|
1031
|
+
cost: float
|
|
1032
|
+
saved_usd: float
|
|
1033
|
+
wasted_usd: float
|
|
1034
|
+
net_usd: float
|
|
1035
|
+
model_breakdowns: tuple # tuple[_FrozenModelBreakdown, ...]
|
|
1036
|
+
project_partials: tuple # tuple[tuple[str, _ProjectPartial], ...]
|
|
1037
|
+
is_empty: bool = False # sentinel for an activity-free CLOSED day (§P1 fix)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def empty_cached_day(date: str) -> "CachedCacheReportDay":
|
|
1041
|
+
"""A sentinel ``CachedCacheReportDay`` for an activity-free CLOSED date (#272 P1).
|
|
1042
|
+
|
|
1043
|
+
An all-zero unit with empty ``model_breakdowns`` / ``project_partials``
|
|
1044
|
+
tuples and ``is_empty=True``. The builder stores one of these for every
|
|
1045
|
+
needed closed date the per-day fold produced no row for, so
|
|
1046
|
+
``have_all`` (which gates the warm today-only-fetch path) sees a quiet day
|
|
1047
|
+
as present instead of a perpetual miss — the exact hit-registration
|
|
1048
|
+
``projects_env_week_put`` does for a computed-empty week. The warm
|
|
1049
|
+
reconstruct / by_project restitch skip ``is_empty`` days, so the sentinel
|
|
1050
|
+
is byte-identical to from-scratch.
|
|
1051
|
+
"""
|
|
1052
|
+
return CachedCacheReportDay(
|
|
1053
|
+
date=date,
|
|
1054
|
+
cache_hit_percent=0.0,
|
|
1055
|
+
input_tokens=0,
|
|
1056
|
+
output_tokens=0,
|
|
1057
|
+
cache_creation_tokens=0,
|
|
1058
|
+
cache_read_tokens=0,
|
|
1059
|
+
cost=0.0,
|
|
1060
|
+
saved_usd=0.0,
|
|
1061
|
+
wasted_usd=0.0,
|
|
1062
|
+
net_usd=0.0,
|
|
1063
|
+
model_breakdowns=(),
|
|
1064
|
+
project_partials=(),
|
|
1065
|
+
is_empty=True,
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def build_cached_days(
|
|
1070
|
+
day_entries: Iterable,
|
|
1071
|
+
raw_entries: Iterable,
|
|
1072
|
+
*,
|
|
1073
|
+
display_tz: ZoneInfo | None,
|
|
1074
|
+
pricing: dict,
|
|
1075
|
+
cost_calculator: Callable[[str, dict, str, Optional[float]], float],
|
|
1076
|
+
) -> "dict[str, CachedCacheReportDay]":
|
|
1077
|
+
"""Per-day frozen aggregates + project partials for the whole input (#272 §5).
|
|
1078
|
+
|
|
1079
|
+
Runs ``_aggregate_cache_by_day(day_entries, ...)`` for the day rows and
|
|
1080
|
+
``aggregate_by_day_project(raw_entries, ...)`` for the per-project
|
|
1081
|
+
partials, then zips them per display-tz date into a
|
|
1082
|
+
``CachedCacheReportDay``. Both aggregators bucket via
|
|
1083
|
+
``_resolve_bucket_tz(display_tz)``, so their date keys align.
|
|
1084
|
+
|
|
1085
|
+
``day_entries`` are the ``SimpleNamespace`` wrappers (``.usage`` dict,
|
|
1086
|
+
no ``.project_path``); ``raw_entries`` are the raw joined entries
|
|
1087
|
+
(flat ``.project_path`` / ``.cache_*`` / ``.input_tokens``). The day
|
|
1088
|
+
rows are stored pre-classification (no ``anomaly_*``); the caller
|
|
1089
|
+
reconstructs mutable rows and classifies fresh each tick.
|
|
1090
|
+
"""
|
|
1091
|
+
rows = _aggregate_cache_by_day(
|
|
1092
|
+
day_entries,
|
|
1093
|
+
display_tz=display_tz, pricing=pricing,
|
|
1094
|
+
cost_calculator=cost_calculator,
|
|
1095
|
+
)
|
|
1096
|
+
partials = aggregate_by_day_project(
|
|
1097
|
+
raw_entries, display_tz=display_tz, pricing=pricing, skip_synthetic=True,
|
|
1098
|
+
)
|
|
1099
|
+
out: dict[str, CachedCacheReportDay] = {}
|
|
1100
|
+
for r in rows:
|
|
1101
|
+
mbs = tuple(
|
|
1102
|
+
_FrozenModelBreakdown(
|
|
1103
|
+
model_name=mb.model_name,
|
|
1104
|
+
input_tokens=mb.input_tokens,
|
|
1105
|
+
output_tokens=mb.output_tokens,
|
|
1106
|
+
cache_creation_tokens=mb.cache_creation_tokens,
|
|
1107
|
+
cache_read_tokens=mb.cache_read_tokens,
|
|
1108
|
+
cache_hit_percent=mb.cache_hit_percent,
|
|
1109
|
+
cost=mb.cost,
|
|
1110
|
+
saved_usd=mb.saved_usd,
|
|
1111
|
+
wasted_usd=mb.wasted_usd,
|
|
1112
|
+
net_usd=mb.net_usd,
|
|
1113
|
+
)
|
|
1114
|
+
for mb in r.model_breakdowns
|
|
1115
|
+
)
|
|
1116
|
+
pp = tuple(sorted((partials.get(r.date, {}) or {}).items()))
|
|
1117
|
+
out[r.date] = CachedCacheReportDay(
|
|
1118
|
+
date=r.date,
|
|
1119
|
+
cache_hit_percent=r.cache_hit_percent,
|
|
1120
|
+
input_tokens=r.input_tokens,
|
|
1121
|
+
output_tokens=r.output_tokens,
|
|
1122
|
+
cache_creation_tokens=r.cache_creation_tokens,
|
|
1123
|
+
cache_read_tokens=r.cache_read_tokens,
|
|
1124
|
+
cost=r.cost,
|
|
1125
|
+
saved_usd=r.saved_usd,
|
|
1126
|
+
wasted_usd=r.wasted_usd,
|
|
1127
|
+
net_usd=r.net_usd,
|
|
1128
|
+
model_breakdowns=mbs,
|
|
1129
|
+
project_partials=pp,
|
|
1130
|
+
)
|
|
1131
|
+
return out
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def reconstruct_cache_row(cached: "CachedCacheReportDay") -> CacheRow:
|
|
1135
|
+
"""A fresh MUTABLE ``CacheRow`` from a frozen cached day (#272 §5, F7).
|
|
1136
|
+
|
|
1137
|
+
Rebuilds a brand-new mutable ``CacheRow`` (+ mutable
|
|
1138
|
+
``CacheModelBreakdown`` children) equal to the original
|
|
1139
|
+
``_aggregate_cache_by_day`` row modulo the anomaly fields, which are
|
|
1140
|
+
cleared so ``_classify_anomalies`` can repopulate them each tick over a
|
|
1141
|
+
row it fully owns. NOTHING reachable from ``cached`` is aliased — the
|
|
1142
|
+
frozen unit is never mutated.
|
|
1143
|
+
|
|
1144
|
+
``cache_hit_percent`` is a read-only *property* on ``CacheRow`` (derived
|
|
1145
|
+
from the token counters), so it is NOT assigned here; setting the tokens
|
|
1146
|
+
makes the property recompute the stored value. The session-mode fields
|
|
1147
|
+
(``session_id`` / ``project_path`` / ``last_activity`` / ``source_paths``)
|
|
1148
|
+
stay at the ``CacheRow`` defaults — day-mode rows never carry them.
|
|
1149
|
+
"""
|
|
1150
|
+
row = CacheRow(date=cached.date)
|
|
1151
|
+
row.input_tokens = cached.input_tokens
|
|
1152
|
+
row.output_tokens = cached.output_tokens
|
|
1153
|
+
row.cache_creation_tokens = cached.cache_creation_tokens
|
|
1154
|
+
row.cache_read_tokens = cached.cache_read_tokens
|
|
1155
|
+
row.cost = cached.cost
|
|
1156
|
+
row.saved_usd = cached.saved_usd
|
|
1157
|
+
row.wasted_usd = cached.wasted_usd
|
|
1158
|
+
row.net_usd = cached.net_usd
|
|
1159
|
+
row.model_breakdowns = [
|
|
1160
|
+
CacheModelBreakdown(
|
|
1161
|
+
model_name=m.model_name,
|
|
1162
|
+
input_tokens=m.input_tokens,
|
|
1163
|
+
output_tokens=m.output_tokens,
|
|
1164
|
+
cache_creation_tokens=m.cache_creation_tokens,
|
|
1165
|
+
cache_read_tokens=m.cache_read_tokens,
|
|
1166
|
+
cache_hit_percent=m.cache_hit_percent,
|
|
1167
|
+
cost=m.cost,
|
|
1168
|
+
saved_usd=m.saved_usd,
|
|
1169
|
+
wasted_usd=m.wasted_usd,
|
|
1170
|
+
net_usd=m.net_usd,
|
|
1171
|
+
)
|
|
1172
|
+
for m in cached.model_breakdowns
|
|
1173
|
+
]
|
|
1174
|
+
row.anomaly_reasons = []
|
|
1175
|
+
row.anomaly_triggered = False
|
|
1176
|
+
return row
|
|
850
1177
|
|
|
851
1178
|
|
|
852
1179
|
# ---------------------------------------------------------------------------
|
|
@@ -879,56 +1206,64 @@ class _CacheReportResult:
|
|
|
879
1206
|
today_baseline_median: float | None = None
|
|
880
1207
|
|
|
881
1208
|
|
|
882
|
-
def
|
|
1209
|
+
def _aggregate_cache_report_rows(
|
|
883
1210
|
entries: Iterable,
|
|
884
1211
|
*,
|
|
885
|
-
now_utc: dt.datetime,
|
|
886
|
-
window_days: int,
|
|
887
|
-
anomaly_threshold_pp: int,
|
|
888
|
-
anomaly_window_days: int,
|
|
889
1212
|
display_tz: ZoneInfo | None,
|
|
890
1213
|
pricing: dict,
|
|
891
1214
|
cost_calculator: Callable[[str, dict, str, Optional[float]], float],
|
|
892
1215
|
mode: Literal["day", "session"] = "day",
|
|
893
1216
|
project_decoder: Callable[[str], str] | None = None,
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
``
|
|
903
|
-
|
|
904
|
-
``mode="day"`` buckets entries by display-tz calendar date;
|
|
905
|
-
``mode="session"`` buckets by Claude ``sessionId`` (resume-merged
|
|
906
|
-
across JSONL files). Session mode requires ``project_decoder`` (the
|
|
907
|
-
CLI passes its ``_decode_escaped_cwd``-backed shim); day mode
|
|
908
|
-
ignores it.
|
|
909
|
-
|
|
910
|
-
The ``since`` window for both modes is ``now_utc − window_days``;
|
|
911
|
-
the kernel trusts callers to pre-filter via their own query
|
|
912
|
-
(``get_entries`` / ``get_claude_session_entries``).
|
|
1217
|
+
) -> list[CacheRow]:
|
|
1218
|
+
"""The aggregate half of ``_build_cache_report`` (#272 §6).
|
|
1219
|
+
|
|
1220
|
+
Buckets ``entries`` into ``CacheRow``s — by display-tz calendar date
|
|
1221
|
+
(``mode="day"``) or by Claude ``sessionId`` (``mode="session"``,
|
|
1222
|
+
resume-merged; requires ``project_decoder``). Split out so the
|
|
1223
|
+
dashboard cache path (§6) can run the aggregate over a mix of
|
|
1224
|
+
cached + freshly-fetched entries and then classify separately, while
|
|
1225
|
+
the classify phase (``classify_and_summarize``) re-runs fresh each
|
|
1226
|
+
tick. Byte-identical to the inline aggregate it replaced.
|
|
913
1227
|
"""
|
|
914
1228
|
if mode == "day":
|
|
915
|
-
|
|
1229
|
+
return _aggregate_cache_by_day(
|
|
916
1230
|
entries,
|
|
917
1231
|
display_tz=display_tz, pricing=pricing,
|
|
918
1232
|
cost_calculator=cost_calculator,
|
|
919
1233
|
)
|
|
920
|
-
|
|
1234
|
+
if mode == "session":
|
|
921
1235
|
if project_decoder is None:
|
|
922
1236
|
raise ValueError("session mode requires project_decoder")
|
|
923
|
-
|
|
1237
|
+
return _aggregate_cache_by_session(
|
|
924
1238
|
entries,
|
|
925
1239
|
pricing=pricing,
|
|
926
1240
|
cost_calculator=cost_calculator,
|
|
927
1241
|
project_decoder=project_decoder,
|
|
928
1242
|
).rows
|
|
929
|
-
|
|
930
|
-
|
|
1243
|
+
raise ValueError(f"unknown mode: {mode!r}")
|
|
1244
|
+
|
|
931
1245
|
|
|
1246
|
+
def classify_and_summarize(
|
|
1247
|
+
rows: list[CacheRow],
|
|
1248
|
+
*,
|
|
1249
|
+
now_utc: dt.datetime,
|
|
1250
|
+
window_days: int,
|
|
1251
|
+
anomaly_threshold_pp: int,
|
|
1252
|
+
anomaly_window_days: int,
|
|
1253
|
+
display_tz: ZoneInfo | None,
|
|
1254
|
+
mode: Literal["day", "session"] = "day",
|
|
1255
|
+
anomaly_enabled: bool = True,
|
|
1256
|
+
) -> _CacheReportResult:
|
|
1257
|
+
"""The classify + summarize half of ``_build_cache_report`` (#272 §6).
|
|
1258
|
+
|
|
1259
|
+
Mutates ``rows`` in place (``_classify_anomalies``), computes the
|
|
1260
|
+
day-mode ``today_baseline_median`` (EFF-3), and packages the
|
|
1261
|
+
``_CacheReportResult``. Split out so the dashboard cache path (§6)
|
|
1262
|
+
can re-run this cross-row pass every tick over freshly reconstructed
|
|
1263
|
+
rows (the cached day aggregates carry no anomaly state), while the
|
|
1264
|
+
per-day aggregates are served from cache. Byte-identical to the
|
|
1265
|
+
inline classify half it replaced.
|
|
1266
|
+
"""
|
|
932
1267
|
_classify_anomalies(
|
|
933
1268
|
rows,
|
|
934
1269
|
threshold_pp=anomaly_threshold_pp,
|
|
@@ -971,3 +1306,56 @@ def _build_cache_report(
|
|
|
971
1306
|
display_tz_key=display_tz.key if display_tz is not None else None,
|
|
972
1307
|
today_baseline_median=today_baseline_median,
|
|
973
1308
|
)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _build_cache_report(
|
|
1312
|
+
entries: Iterable,
|
|
1313
|
+
*,
|
|
1314
|
+
now_utc: dt.datetime,
|
|
1315
|
+
window_days: int,
|
|
1316
|
+
anomaly_threshold_pp: int,
|
|
1317
|
+
anomaly_window_days: int,
|
|
1318
|
+
display_tz: ZoneInfo | None,
|
|
1319
|
+
pricing: dict,
|
|
1320
|
+
cost_calculator: Callable[[str, dict, str, Optional[float]], float],
|
|
1321
|
+
mode: Literal["day", "session"] = "day",
|
|
1322
|
+
project_decoder: Callable[[str], str] | None = None,
|
|
1323
|
+
anomaly_enabled: bool = True,
|
|
1324
|
+
) -> _CacheReportResult:
|
|
1325
|
+
"""Top-level orchestrator: aggregate + classify anomalies.
|
|
1326
|
+
|
|
1327
|
+
Returns a ``_CacheReportResult`` that both the CLI renderer and the
|
|
1328
|
+
dashboard snapshot builder consume. Pure-function — no I/O, no
|
|
1329
|
+
logging, no environment reads. Callers (CLI / dashboard) own all
|
|
1330
|
+
I/O via the ``entries`` iterable + the ``cost_calculator`` /
|
|
1331
|
+
``project_decoder`` injections.
|
|
1332
|
+
|
|
1333
|
+
``mode="day"`` buckets entries by display-tz calendar date;
|
|
1334
|
+
``mode="session"`` buckets by Claude ``sessionId`` (resume-merged
|
|
1335
|
+
across JSONL files). Session mode requires ``project_decoder`` (the
|
|
1336
|
+
CLI passes its ``_decode_escaped_cwd``-backed shim); day mode
|
|
1337
|
+
ignores it.
|
|
1338
|
+
|
|
1339
|
+
The ``since`` window for both modes is ``now_utc − window_days``;
|
|
1340
|
+
the kernel trusts callers to pre-filter via their own query
|
|
1341
|
+
(``get_entries`` / ``get_claude_session_entries``). Composed (#272
|
|
1342
|
+
§6) from ``_aggregate_cache_report_rows`` + ``classify_and_summarize``
|
|
1343
|
+
— the CLI keeps this one-shot orchestrator; the dashboard cache path
|
|
1344
|
+
calls the two halves directly so it can memoize the aggregate.
|
|
1345
|
+
"""
|
|
1346
|
+
rows = _aggregate_cache_report_rows(
|
|
1347
|
+
entries,
|
|
1348
|
+
display_tz=display_tz, pricing=pricing,
|
|
1349
|
+
cost_calculator=cost_calculator,
|
|
1350
|
+
mode=mode, project_decoder=project_decoder,
|
|
1351
|
+
)
|
|
1352
|
+
return classify_and_summarize(
|
|
1353
|
+
rows,
|
|
1354
|
+
now_utc=now_utc,
|
|
1355
|
+
window_days=window_days,
|
|
1356
|
+
anomaly_threshold_pp=anomaly_threshold_pp,
|
|
1357
|
+
anomaly_window_days=anomaly_window_days,
|
|
1358
|
+
display_tz=display_tz,
|
|
1359
|
+
mode=mode,
|
|
1360
|
+
anomaly_enabled=anomaly_enabled,
|
|
1361
|
+
)
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -164,6 +164,11 @@ class DoctorState:
|
|
|
164
164
|
conv_sessions_rollup_count: Optional[int] = None
|
|
165
165
|
conv_messages_distinct_sessions: Optional[int] = None
|
|
166
166
|
conv_rollup_sync_in_progress: bool = False
|
|
167
|
+
# Preview channel (CCTALLY_CHANNEL=preview): "preview" when the binary runs
|
|
168
|
+
# under the preview channel, else "prod". Populated by `doctor_gather_state`
|
|
169
|
+
# from the env; surfaced in the install.mode check. Defaulted (placed last)
|
|
170
|
+
# so existing constructors stay valid and the prod path is unchanged.
|
|
171
|
+
channel: str = "prod"
|
|
167
172
|
|
|
168
173
|
|
|
169
174
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -359,6 +364,10 @@ def _check_install_dev_mode(s: DoctorState) -> CheckResult:
|
|
|
359
364
|
summary = "DEV (git checkout, custom data dir via CCTALLY_DATA_DIR)"
|
|
360
365
|
else:
|
|
361
366
|
summary = "installed"
|
|
367
|
+
# Preview channel (CCTALLY_CHANNEL=preview): note the channel in the
|
|
368
|
+
# summary. Gated → prod (channel="prod") summary is unchanged.
|
|
369
|
+
if s.channel == "preview":
|
|
370
|
+
summary += " · channel: preview"
|
|
362
371
|
return CheckResult(
|
|
363
372
|
id="install.mode", title="Mode",
|
|
364
373
|
severity="ok", summary=summary, remediation=None,
|
|
@@ -366,6 +375,7 @@ def _check_install_dev_mode(s: DoctorState) -> CheckResult:
|
|
|
366
375
|
"dev_mode": s.dev_mode,
|
|
367
376
|
"is_dev_checkout": s.is_dev_checkout,
|
|
368
377
|
"app_dir": s.app_dir,
|
|
378
|
+
"channel": s.channel,
|
|
369
379
|
},
|
|
370
380
|
)
|
|
371
381
|
|
package/bin/_lib_pricing.py
CHANGED
|
@@ -67,7 +67,30 @@ LITELLM_PRICES_URL = (
|
|
|
67
67
|
# or an intentionally-omitted in-scope model ({"model","reason"} — no field).
|
|
68
68
|
# Guarded by `stale_allowlist_entries` (tests/test_pricing_check.py): an entry
|
|
69
69
|
# that no longer corresponds to a real divergence fails the suite.
|
|
70
|
-
|
|
70
|
+
#
|
|
71
|
+
# claude-sonnet-5 (#274): LiteLLM tracks the $2/$10-per-MTok *introductory* rate
|
|
72
|
+
# (in effect through 2026-08-31); we deliberately embed the durable *standard*
|
|
73
|
+
# $3/$15 rate because the table is date-blind and the promo expires soon (see
|
|
74
|
+
# the CLAUDE_MODEL_PRICING note below). The non-vacuity guard forces these four
|
|
75
|
+
# entries out once LiteLLM reverts to the standard rate post-cutover.
|
|
76
|
+
PRICING_DRIFT_ALLOWLIST: list[dict] = [
|
|
77
|
+
{
|
|
78
|
+
"model": "claude-sonnet-5",
|
|
79
|
+
"field": field,
|
|
80
|
+
"reason": (
|
|
81
|
+
"LiteLLM tracks the claude-sonnet-5 introductory rate "
|
|
82
|
+
"($2/$10 per MTok, through 2026-08-31); we deliberately embed the "
|
|
83
|
+
"durable standard $3/$15 rate (the table is date-blind). Remove "
|
|
84
|
+
"once LiteLLM reverts post-cutover (#274)."
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
for field in (
|
|
88
|
+
"input_cost_per_token",
|
|
89
|
+
"output_cost_per_token",
|
|
90
|
+
"cache_creation_input_token_cost",
|
|
91
|
+
"cache_read_input_token_cost",
|
|
92
|
+
)
|
|
93
|
+
]
|
|
71
94
|
|
|
72
95
|
# Anthropic API pricing snapshot:
|
|
73
96
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
@@ -79,9 +102,13 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = []
|
|
|
79
102
|
# 2026-07-01: added claude-sonnet-5 ($3/$15 per MTok; 1M context, flat-rate
|
|
80
103
|
# across the full window — no long-context premium, same shape as
|
|
81
104
|
# claude-sonnet-4-6). Embedded the STANDARD rate, not the $2/$10 introductory
|
|
82
|
-
# rate in effect through 2026-08-31,
|
|
83
|
-
# the durable post-cutover price.
|
|
84
|
-
#
|
|
105
|
+
# rate in effect through 2026-08-31, because the table is date-blind and
|
|
106
|
+
# $3/$15 is the durable post-cutover price.
|
|
107
|
+
# 2026-07-06 (#274): LiteLLM published a sonnet-5 entry at the $2/$10
|
|
108
|
+
# introductory rate, so the deliberate standard-rate choice now surfaces as
|
|
109
|
+
# value_drift on all four cost fields. Suppressed via PRICING_DRIFT_ALLOWLIST
|
|
110
|
+
# above (the non-vacuity guard forces removal once LiteLLM reverts to the
|
|
111
|
+
# standard rate after 2026-08-31).
|
|
85
112
|
CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
|
|
86
113
|
"claude-3-5-haiku-20241022": {
|
|
87
114
|
"input_cost_per_token": 8e-07,
|