cctally 1.61.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.
@@ -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
- out.sort(key=lambda r: abs(r.net_usd), reverse=True)
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
- out.sort(key=lambda r: abs(r.net_usd), reverse=True)
834
- if len(out) <= top_n:
835
- return tuple(out)
836
- head = out[:top_n]
837
- tail = out[top_n:]
838
- other_net = stable_sum(r.net_usd for r in tail)
839
- tail_input = sum(r.input_tokens for r in tail)
840
- tail_creation = sum(r.cache_creation_tokens for r in tail)
841
- tail_read = sum(r.cache_read_tokens for r in tail)
842
- other_pct = _compute_cache_hit_percent(tail_input, tail_creation, tail_read)
843
- head.append(CacheBreakdownRow(
844
- key=other_label, cache_hit_percent=other_pct, net_usd=other_net,
845
- input_tokens=tail_input,
846
- cache_creation_tokens=tail_creation,
847
- cache_read_tokens=tail_read,
848
- ))
849
- return tuple(head)
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 _build_cache_report(
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
- anomaly_enabled: bool = True,
895
- ) -> _CacheReportResult:
896
- """Top-level orchestrator: aggregate + classify anomalies.
897
-
898
- Returns a ``_CacheReportResult`` that both the CLI renderer and the
899
- dashboard snapshot builder consume. Pure-function no I/O, no
900
- logging, no environment reads. Callers (CLI / dashboard) own all
901
- I/O via the ``entries`` iterable + the ``cost_calculator`` /
902
- ``project_decoder`` injections.
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
- rows = _aggregate_cache_by_day(
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
- elif mode == "session":
1234
+ if mode == "session":
921
1235
  if project_decoder is None:
922
1236
  raise ValueError("session mode requires project_decoder")
923
- rows = _aggregate_cache_by_session(
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
- else:
930
- raise ValueError(f"unknown mode: {mode!r}")
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
+ )
@@ -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
- PRICING_DRIFT_ALLOWLIST: list[dict] = []
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, to match what LiteLLM will publish and
83
- # the durable post-cutover price. LiteLLM has no sonnet-5 entry yet, so the
84
- # table is ahead_of_litellm (not a drift finding).
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,