cctally 1.10.2 → 1.11.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 +22 -0
- package/bin/_cctally_cache_report.py +938 -0
- package/bin/_cctally_dashboard.py +619 -6
- package/bin/_cctally_tui.py +45 -0
- package/bin/_lib_blocks.py +4 -0
- package/bin/_lib_render.py +5 -4
- package/bin/cctally +102 -386
- package/dashboard/static/assets/index-BJ16SzRL.js +18 -0
- package/dashboard/static/assets/index-C1xH9GBW.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Cy59E7Ru.js +0 -18
- package/dashboard/static/assets/index-Dp14ELVt.css +0 -1
|
@@ -380,6 +380,81 @@ def _validate_update_check_ttl_hours_value(*args, **kwargs):
|
|
|
380
380
|
return sys.modules["cctally"]._validate_update_check_ttl_hours_value(*args, **kwargs)
|
|
381
381
|
|
|
382
382
|
|
|
383
|
+
# === Cache-report settings validator (spec 2026-05-21 §6) ================
|
|
384
|
+
# Validates the optional ``config.json:cache_report`` block. Strict in
|
|
385
|
+
# v1: only ``anomaly_threshold_pp`` is settable, must be a plain int in
|
|
386
|
+
# ``[1, 100]`` (bool / float / string rejected — bool because it's an
|
|
387
|
+
# int subclass in Python and quietly accepting ``true`` for a numeric
|
|
388
|
+
# field is exactly the trip-up ``_validate_update_check_ttl_hours_value``
|
|
389
|
+
# protects against). HTTP write path raises ``_CacheReportConfigError``
|
|
390
|
+
# → ``_handle_post_settings`` maps to HTTP 400 + ``{error, field}``
|
|
391
|
+
# (matches the existing handler convention at lines 4587-4602; spec
|
|
392
|
+
# explicitly says 400, NOT 422).
|
|
393
|
+
|
|
394
|
+
@dataclass(frozen=True)
|
|
395
|
+
class _CacheReportSettings:
|
|
396
|
+
anomaly_threshold_pp: int
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class _CacheReportConfigError(Exception):
|
|
400
|
+
"""Validation error for the ``cache_report`` config block.
|
|
401
|
+
|
|
402
|
+
``field`` carries the offending key path (``anomaly_threshold_pp`` or
|
|
403
|
+
the unknown-key name) so the JSON 400 response can surface it.
|
|
404
|
+
"""
|
|
405
|
+
def __init__(self, message: str, *, field: str | None = None):
|
|
406
|
+
super().__init__(message)
|
|
407
|
+
self.field = field
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
_CACHE_REPORT_ALLOWED_KEYS = frozenset({"anomaly_threshold_pp"})
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _validate_cache_report_settings(block: dict) -> dict:
|
|
414
|
+
"""Validate a ``cache_report`` config block.
|
|
415
|
+
|
|
416
|
+
Pure function. Raises ``_CacheReportConfigError`` on invalid input;
|
|
417
|
+
returns a dict containing ONLY the keys that were present in the
|
|
418
|
+
input (validated). Callers merge the result into the existing
|
|
419
|
+
persisted block instead of replacing it wholesale — this mirrors
|
|
420
|
+
the ``update.check`` partial-PUT pattern at
|
|
421
|
+
``_handle_post_settings`` (~line 5277) and prevents a combined save
|
|
422
|
+
that omits ``anomaly_threshold_pp`` from clobbering a previously
|
|
423
|
+
persisted user value with the default.
|
|
424
|
+
|
|
425
|
+
v1 only accepts ``anomaly_threshold_pp`` — ``anomaly_window_days``
|
|
426
|
+
stays hardcoded at 14 (spec §6.1; F10 from spec §10 tracks adding
|
|
427
|
+
a configurable baseline window along with the UI-copy work).
|
|
428
|
+
"""
|
|
429
|
+
if not isinstance(block, dict):
|
|
430
|
+
raise _CacheReportConfigError(
|
|
431
|
+
"cache_report must be an object", field="cache_report",
|
|
432
|
+
)
|
|
433
|
+
for key in block:
|
|
434
|
+
if key not in _CACHE_REPORT_ALLOWED_KEYS:
|
|
435
|
+
raise _CacheReportConfigError(
|
|
436
|
+
f"unknown key in cache_report block: {key!r}",
|
|
437
|
+
field=key,
|
|
438
|
+
)
|
|
439
|
+
validated: dict = {}
|
|
440
|
+
if "anomaly_threshold_pp" in block:
|
|
441
|
+
threshold = block["anomaly_threshold_pp"]
|
|
442
|
+
# bool is an int subclass — reject it explicitly (mirrors the
|
|
443
|
+
# update.check.ttl_hours precedent).
|
|
444
|
+
if isinstance(threshold, bool) or not isinstance(threshold, int):
|
|
445
|
+
raise _CacheReportConfigError(
|
|
446
|
+
"anomaly_threshold_pp must be an integer",
|
|
447
|
+
field="anomaly_threshold_pp",
|
|
448
|
+
)
|
|
449
|
+
if threshold < 1 or threshold > 100:
|
|
450
|
+
raise _CacheReportConfigError(
|
|
451
|
+
"anomaly_threshold_pp must be in [1, 100]",
|
|
452
|
+
field="anomaly_threshold_pp",
|
|
453
|
+
)
|
|
454
|
+
validated["anomaly_threshold_pp"] = threshold
|
|
455
|
+
return validated
|
|
456
|
+
|
|
457
|
+
|
|
383
458
|
def _config_known_value(*args, **kwargs):
|
|
384
459
|
return sys.modules["cctally"]._config_known_value(*args, **kwargs)
|
|
385
460
|
|
|
@@ -1674,6 +1749,461 @@ def _build_projects_share_panel_data(options: dict,
|
|
|
1674
1749
|
|
|
1675
1750
|
|
|
1676
1751
|
|
|
1752
|
+
# === Cache-report envelope dataclasses (spec 2026-05-21) =================
|
|
1753
|
+
# Snake_case fields are emitted verbatim into the SSE envelope so the React
|
|
1754
|
+
# store can read ``state.cache_report.<field>`` without a key-transform pass
|
|
1755
|
+
# (the envelope is intentionally snake_case end-to-end; see
|
|
1756
|
+
# ``dashboard/web/src/types/envelope.ts:189``). Built by
|
|
1757
|
+
# ``build_cache_report_snapshot()`` and shipped on the existing 5-minute
|
|
1758
|
+
# sync cadence — no separate ``/api/cache-report`` endpoint.
|
|
1759
|
+
|
|
1760
|
+
# Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
|
|
1761
|
+
CACHE_REPORT_WINDOW_DAYS = 14
|
|
1762
|
+
# Two concepts that happen to share a value today: the data window the
|
|
1763
|
+
# panel renders vs. the baseline window the anomaly classifier reads
|
|
1764
|
+
# back over. Split so F10 can lift the latter without dragging the
|
|
1765
|
+
# former along.
|
|
1766
|
+
CACHE_REPORT_ANOMALY_WINDOW_DAYS = 14
|
|
1767
|
+
|
|
1768
|
+
@dataclass(frozen=True)
|
|
1769
|
+
class CacheReportDailyRow:
|
|
1770
|
+
date: str # YYYY-MM-DD in display tz
|
|
1771
|
+
cache_hit_percent: float
|
|
1772
|
+
input_tokens: int
|
|
1773
|
+
output_tokens: int
|
|
1774
|
+
cache_creation_tokens: int
|
|
1775
|
+
cache_read_tokens: int
|
|
1776
|
+
saved_usd: float
|
|
1777
|
+
wasted_usd: float
|
|
1778
|
+
net_usd: float
|
|
1779
|
+
anomaly_triggered: bool
|
|
1780
|
+
anomaly_reasons: tuple[str, ...]
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
@dataclass(frozen=True)
|
|
1784
|
+
class CacheReportBreakdownRow:
|
|
1785
|
+
"""One row of the by-project / by-model breakdown sub-cards."""
|
|
1786
|
+
key: str
|
|
1787
|
+
cache_hit_percent: float
|
|
1788
|
+
net_usd: float
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
@dataclass(frozen=True)
|
|
1792
|
+
class CacheReportTodaySpotlight:
|
|
1793
|
+
"""Today's spotlight card: hit %, baseline-median, Δ vs baseline,
|
|
1794
|
+
cumulative net / saved / wasted, anomaly state, and the count of
|
|
1795
|
+
baseline daily rows so the React panel can gate the
|
|
1796
|
+
"Building baseline · N/5 days" insufficient-baseline state."""
|
|
1797
|
+
date: str
|
|
1798
|
+
cache_hit_percent: float
|
|
1799
|
+
baseline_median_percent: float | None
|
|
1800
|
+
delta_pp: float | None
|
|
1801
|
+
net_usd: float
|
|
1802
|
+
saved_usd: float
|
|
1803
|
+
wasted_usd: float
|
|
1804
|
+
anomaly_triggered: bool
|
|
1805
|
+
anomaly_reasons: tuple[str, ...]
|
|
1806
|
+
baseline_daily_row_count: int
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict | None":
|
|
1810
|
+
"""Serialize a ``CacheReportSnapshot`` to the SSE envelope dict.
|
|
1811
|
+
|
|
1812
|
+
Returns ``None`` when the snapshot is ``None`` (first tick before
|
|
1813
|
+
sync, or sub-build failure recorded on ``last_sync_error``). Snake-
|
|
1814
|
+
case keys throughout — the envelope is intentionally snake_case end
|
|
1815
|
+
-to-end per ``envelope.ts:189`` (no ``to_camel`` pass). Tuples are
|
|
1816
|
+
flattened to lists for JSON palatability.
|
|
1817
|
+
"""
|
|
1818
|
+
if cr is None:
|
|
1819
|
+
return None
|
|
1820
|
+
return {
|
|
1821
|
+
"window_days": cr.window_days,
|
|
1822
|
+
"anomaly_threshold_pp": cr.anomaly_threshold_pp,
|
|
1823
|
+
"anomaly_window_days": cr.anomaly_window_days,
|
|
1824
|
+
"today": {
|
|
1825
|
+
"date": cr.today.date,
|
|
1826
|
+
"cache_hit_percent": cr.today.cache_hit_percent,
|
|
1827
|
+
"baseline_median_percent": cr.today.baseline_median_percent,
|
|
1828
|
+
"delta_pp": cr.today.delta_pp,
|
|
1829
|
+
"net_usd": cr.today.net_usd,
|
|
1830
|
+
"saved_usd": cr.today.saved_usd,
|
|
1831
|
+
"wasted_usd": cr.today.wasted_usd,
|
|
1832
|
+
"anomaly_triggered": cr.today.anomaly_triggered,
|
|
1833
|
+
"anomaly_reasons": list(cr.today.anomaly_reasons),
|
|
1834
|
+
"baseline_daily_row_count": cr.today.baseline_daily_row_count,
|
|
1835
|
+
},
|
|
1836
|
+
"days": [
|
|
1837
|
+
{
|
|
1838
|
+
"date": d.date,
|
|
1839
|
+
"cache_hit_percent": d.cache_hit_percent,
|
|
1840
|
+
"input_tokens": d.input_tokens,
|
|
1841
|
+
"output_tokens": d.output_tokens,
|
|
1842
|
+
"cache_creation_tokens": d.cache_creation_tokens,
|
|
1843
|
+
"cache_read_tokens": d.cache_read_tokens,
|
|
1844
|
+
"saved_usd": d.saved_usd,
|
|
1845
|
+
"wasted_usd": d.wasted_usd,
|
|
1846
|
+
"net_usd": d.net_usd,
|
|
1847
|
+
"anomaly_triggered": d.anomaly_triggered,
|
|
1848
|
+
"anomaly_reasons": list(d.anomaly_reasons),
|
|
1849
|
+
}
|
|
1850
|
+
for d in cr.days
|
|
1851
|
+
],
|
|
1852
|
+
"by_project": [
|
|
1853
|
+
{
|
|
1854
|
+
"key": b.key,
|
|
1855
|
+
"cache_hit_percent": b.cache_hit_percent,
|
|
1856
|
+
"net_usd": b.net_usd,
|
|
1857
|
+
}
|
|
1858
|
+
for b in cr.by_project
|
|
1859
|
+
],
|
|
1860
|
+
"by_model": [
|
|
1861
|
+
{
|
|
1862
|
+
"key": b.key,
|
|
1863
|
+
"cache_hit_percent": b.cache_hit_percent,
|
|
1864
|
+
"net_usd": b.net_usd,
|
|
1865
|
+
}
|
|
1866
|
+
for b in cr.by_model
|
|
1867
|
+
],
|
|
1868
|
+
"seven_day_net_usd": cr.seven_day_net_usd,
|
|
1869
|
+
"seven_day_anomaly_count": cr.seven_day_anomaly_count,
|
|
1870
|
+
"fourteen_day_counterfactual_usd": cr.fourteen_day_counterfactual_usd,
|
|
1871
|
+
"fourteen_day_efficiency_ratio": cr.fourteen_day_efficiency_ratio,
|
|
1872
|
+
"is_empty": cr.is_empty,
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
@dataclass(frozen=True)
|
|
1877
|
+
class CacheReportSnapshot:
|
|
1878
|
+
"""The complete cache-report envelope block.
|
|
1879
|
+
|
|
1880
|
+
``days`` is newest-first, length ``≤ window_days``. ``by_project`` /
|
|
1881
|
+
``by_model`` are sorted by ``abs(net_usd)`` descending and capped at
|
|
1882
|
+
6 entries (top 5 + ``(other)``). ``window_days`` is hardcoded at 14
|
|
1883
|
+
in v1; ``anomaly_threshold_pp`` is read from
|
|
1884
|
+
``config.json:cache_report.anomaly_threshold_pp`` (default 15) via
|
|
1885
|
+
the dashboard sync thread.
|
|
1886
|
+
"""
|
|
1887
|
+
window_days: int
|
|
1888
|
+
anomaly_threshold_pp: int
|
|
1889
|
+
anomaly_window_days: int
|
|
1890
|
+
today: CacheReportTodaySpotlight
|
|
1891
|
+
days: tuple[CacheReportDailyRow, ...]
|
|
1892
|
+
by_project: tuple[CacheReportBreakdownRow, ...]
|
|
1893
|
+
by_model: tuple[CacheReportBreakdownRow, ...]
|
|
1894
|
+
seven_day_net_usd: float
|
|
1895
|
+
seven_day_anomaly_count: int
|
|
1896
|
+
fourteen_day_counterfactual_usd: float
|
|
1897
|
+
fourteen_day_efficiency_ratio: float
|
|
1898
|
+
is_empty: bool
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
# === Cache-report snapshot builder (spec 2026-05-21 §5.2) ================
|
|
1902
|
+
# Adapter from the I/O layer (``get_claude_session_entries`` +
|
|
1903
|
+
# ``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost``) into the kernel's
|
|
1904
|
+
# pure ``_build_cache_report`` orchestrator. By-project + by-model
|
|
1905
|
+
# breakdowns dedup through the kernel's ``_aggregate_cache_breakdown``
|
|
1906
|
+
# (one path, one ``<synthetic>`` filter rule) so the two axes can't
|
|
1907
|
+
# silently disagree on token totals when a session has both real and
|
|
1908
|
+
# synthetic entries on the same project.
|
|
1909
|
+
|
|
1910
|
+
def _cache_report_load_kernel():
|
|
1911
|
+
"""Lazy-load ``_cctally_cache_report`` via the cctally ``_load_sibling``
|
|
1912
|
+
bridge so monkeypatch-driven test reloads of cctally see the same
|
|
1913
|
+
kernel module instance (matches the late-load pattern used by share /
|
|
1914
|
+
doctor helpers in this file)."""
|
|
1915
|
+
return sys.modules["cctally"]._load_sibling("_cctally_cache_report")
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def build_cache_report_snapshot(
|
|
1919
|
+
*,
|
|
1920
|
+
now_utc: dt.datetime,
|
|
1921
|
+
anomaly_threshold_pp: int,
|
|
1922
|
+
anomaly_window_days: int,
|
|
1923
|
+
display_tz: "ZoneInfo | None",
|
|
1924
|
+
skip_sync: bool = False,
|
|
1925
|
+
) -> CacheReportSnapshot:
|
|
1926
|
+
"""Build the ``cache_report`` envelope field from the session-entry cache.
|
|
1927
|
+
|
|
1928
|
+
Pulls entries via ``get_claude_session_entries`` (uses the cache when
|
|
1929
|
+
warm, falls back to direct-JSONL parse on cache miss / lock
|
|
1930
|
+
contention — same chain the CLI uses). Delegates aggregation +
|
|
1931
|
+
anomaly classification to ``_cctally_cache_report._build_cache_report``;
|
|
1932
|
+
shapes the result into a frozen ``CacheReportSnapshot``.
|
|
1933
|
+
|
|
1934
|
+
``window_days`` is hardcoded at 14 in v1 (spec §6.1 hardcodes
|
|
1935
|
+
``anomaly_window_days`` too; ``anomaly_threshold_pp`` is the only
|
|
1936
|
+
user-configurable knob). F10 from spec §10 tracks making the window
|
|
1937
|
+
configurable, plus the UI-copy work it'd require.
|
|
1938
|
+
"""
|
|
1939
|
+
crk = _cache_report_load_kernel()
|
|
1940
|
+
cctally_ns = sys.modules["cctally"]
|
|
1941
|
+
|
|
1942
|
+
window_days = CACHE_REPORT_WINDOW_DAYS # v1: hardcoded per spec §6.1.
|
|
1943
|
+
since = now_utc - dt.timedelta(days=window_days)
|
|
1944
|
+
|
|
1945
|
+
entries = list(
|
|
1946
|
+
get_claude_session_entries(since, now_utc, project=None, skip_sync=skip_sync)
|
|
1947
|
+
)
|
|
1948
|
+
|
|
1949
|
+
today_iso = now_utc.astimezone(
|
|
1950
|
+
display_tz if display_tz is not None else dt.timezone.utc
|
|
1951
|
+
).strftime("%Y-%m-%d")
|
|
1952
|
+
|
|
1953
|
+
if not entries:
|
|
1954
|
+
empty_today = CacheReportTodaySpotlight(
|
|
1955
|
+
date=today_iso,
|
|
1956
|
+
cache_hit_percent=0.0,
|
|
1957
|
+
baseline_median_percent=None,
|
|
1958
|
+
delta_pp=None,
|
|
1959
|
+
net_usd=0.0, saved_usd=0.0, wasted_usd=0.0,
|
|
1960
|
+
anomaly_triggered=False,
|
|
1961
|
+
anomaly_reasons=(),
|
|
1962
|
+
baseline_daily_row_count=0,
|
|
1963
|
+
)
|
|
1964
|
+
return CacheReportSnapshot(
|
|
1965
|
+
window_days=window_days,
|
|
1966
|
+
anomaly_threshold_pp=anomaly_threshold_pp,
|
|
1967
|
+
anomaly_window_days=anomaly_window_days,
|
|
1968
|
+
today=empty_today,
|
|
1969
|
+
days=(), by_project=(), by_model=(),
|
|
1970
|
+
seven_day_net_usd=0.0,
|
|
1971
|
+
seven_day_anomaly_count=0,
|
|
1972
|
+
fourteen_day_counterfactual_usd=0.0,
|
|
1973
|
+
fourteen_day_efficiency_ratio=0.0,
|
|
1974
|
+
is_empty=True,
|
|
1975
|
+
)
|
|
1976
|
+
|
|
1977
|
+
pricing = cctally_ns.CLAUDE_MODEL_PRICING
|
|
1978
|
+
|
|
1979
|
+
# Day-mode kernel expects entries with a ``usage`` dict (matches
|
|
1980
|
+
# ``UsageEntry``). ``get_claude_session_entries`` returns flat
|
|
1981
|
+
# ``_JoinedClaudeEntry`` objects, so wrap each into the right shape
|
|
1982
|
+
# before passing to the kernel. SimpleNamespace keeps the wrapper
|
|
1983
|
+
# pure-Python and avoids a new dataclass type just for the bridge.
|
|
1984
|
+
from types import SimpleNamespace as _NS
|
|
1985
|
+
day_entries = [
|
|
1986
|
+
_NS(
|
|
1987
|
+
timestamp=e.timestamp,
|
|
1988
|
+
model=e.model,
|
|
1989
|
+
cost_usd=e.cost_usd,
|
|
1990
|
+
usage={
|
|
1991
|
+
"input_tokens": e.input_tokens,
|
|
1992
|
+
"output_tokens": e.output_tokens,
|
|
1993
|
+
"cache_creation_input_tokens": e.cache_creation_tokens,
|
|
1994
|
+
"cache_read_input_tokens": e.cache_read_tokens,
|
|
1995
|
+
},
|
|
1996
|
+
)
|
|
1997
|
+
for e in entries
|
|
1998
|
+
]
|
|
1999
|
+
|
|
2000
|
+
result = crk._build_cache_report(
|
|
2001
|
+
day_entries,
|
|
2002
|
+
now_utc=now_utc,
|
|
2003
|
+
window_days=window_days,
|
|
2004
|
+
anomaly_threshold_pp=anomaly_threshold_pp,
|
|
2005
|
+
anomaly_window_days=anomaly_window_days,
|
|
2006
|
+
display_tz=display_tz,
|
|
2007
|
+
pricing=pricing,
|
|
2008
|
+
mode="day",
|
|
2009
|
+
cost_calculator=_calculate_entry_cost,
|
|
2010
|
+
)
|
|
2011
|
+
|
|
2012
|
+
# Pick out today's row (if any) and the baseline-daily-row count for
|
|
2013
|
+
# the spotlight. The spotlight median is computed against ALL rows
|
|
2014
|
+
# except today (cross-row reference; mirrors what the panel's
|
|
2015
|
+
# "Δ vs 14d median" label means). The median itself rides back on
|
|
2016
|
+
# ``result.today_baseline_median`` (EFF-3 — kernel computes it once
|
|
2017
|
+
# alongside the anomaly classifier so we don't re-walk the same
|
|
2018
|
+
# row set here).
|
|
2019
|
+
today_row = next((r for r in result.rows if r.date == today_iso), None)
|
|
2020
|
+
other_rows = [r for r in result.rows if r.date != today_iso]
|
|
2021
|
+
baseline_median = result.today_baseline_median
|
|
2022
|
+
|
|
2023
|
+
baseline_daily_row_count = len(other_rows)
|
|
2024
|
+
|
|
2025
|
+
# ``delta_pp`` sign convention (spec §4.2): "signed; negative = today
|
|
2026
|
+
# below median" → ``delta = today − baseline``. The empty-day branch
|
|
2027
|
+
# uses today_hit_pct = 0.0 so the formula degenerates to
|
|
2028
|
+
# ``0.0 − baseline_median``, which IS what users expect (a flat-zero
|
|
2029
|
+
# today read against a healthy 60% baseline yields delta=-60pp).
|
|
2030
|
+
today_hit_pct = today_row.cache_hit_percent if today_row is not None else 0.0
|
|
2031
|
+
delta_pp = (
|
|
2032
|
+
None if baseline_median is None
|
|
2033
|
+
else today_hit_pct - baseline_median
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
if today_row is None:
|
|
2037
|
+
today_spotlight = CacheReportTodaySpotlight(
|
|
2038
|
+
date=today_iso,
|
|
2039
|
+
cache_hit_percent=0.0,
|
|
2040
|
+
baseline_median_percent=baseline_median,
|
|
2041
|
+
delta_pp=delta_pp,
|
|
2042
|
+
net_usd=0.0, saved_usd=0.0, wasted_usd=0.0,
|
|
2043
|
+
anomaly_triggered=False,
|
|
2044
|
+
anomaly_reasons=(),
|
|
2045
|
+
baseline_daily_row_count=baseline_daily_row_count,
|
|
2046
|
+
)
|
|
2047
|
+
else:
|
|
2048
|
+
today_spotlight = CacheReportTodaySpotlight(
|
|
2049
|
+
date=today_iso,
|
|
2050
|
+
cache_hit_percent=today_row.cache_hit_percent,
|
|
2051
|
+
baseline_median_percent=baseline_median,
|
|
2052
|
+
delta_pp=delta_pp,
|
|
2053
|
+
net_usd=today_row.net_usd,
|
|
2054
|
+
saved_usd=today_row.saved_usd,
|
|
2055
|
+
wasted_usd=today_row.wasted_usd,
|
|
2056
|
+
anomaly_triggered=today_row.anomaly_triggered,
|
|
2057
|
+
anomaly_reasons=tuple(today_row.anomaly_reasons),
|
|
2058
|
+
baseline_daily_row_count=baseline_daily_row_count,
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
# Daily rows — newest first, capped at ``window_days``.
|
|
2062
|
+
#
|
|
2063
|
+
# Slice cap (spec §4.2 — "length up to ``window_days``"): the kernel's
|
|
2064
|
+
# ``since = now_utc - timedelta(days=window_days)`` rolling window
|
|
2065
|
+
# straddles midnight in any non-UTC ``display_tz`` (and in fact even
|
|
2066
|
+
# in UTC, since ``now_utc - 14d`` and ``now_utc`` flank the same
|
|
2067
|
+
# wall-clock minute on different calendar dates), so the kernel can
|
|
2068
|
+
# emit ``window_days + 1`` distinct calendar-date buckets. Capping
|
|
2069
|
+
# here (and not in the kernel) keeps the kernel agnostic of the
|
|
2070
|
+
# envelope's hard ceiling while honoring the contract every TS /
|
|
2071
|
+
# React consumer relies on (the sparkline ladder is hard-sized to
|
|
2072
|
+
# ``window_days`` points). Regression:
|
|
2073
|
+
# ``test_build_cache_report_snapshot_days_bounded_by_window``.
|
|
2074
|
+
#
|
|
2075
|
+
# Synthetic-today insertion: if the trailing window has older activity
|
|
2076
|
+
# but no entries for the current display-tz day, the kernel emits a
|
|
2077
|
+
# rows[] list whose newest row is yesterday (or older). Both React
|
|
2078
|
+
# consumers (``CacheSparkline`` and ``CacheNetBars``) treat the
|
|
2079
|
+
# rightmost element of ``days`` as "Today" purely positionally
|
|
2080
|
+
# (``ordered.length - 1`` / ``isLast ? 'Today'``), so without an
|
|
2081
|
+
# explicit today bucket they would mis-label the older row as Today.
|
|
2082
|
+
# Insert a zero-valued CacheReportDailyRow at position 0 (newest)
|
|
2083
|
+
# whenever ``today_row is None``. The zero values mirror the
|
|
2084
|
+
# ``today_spotlight`` synthesized above (kept in lock-step), and
|
|
2085
|
+
# contribute 0 to ``seven_day_*`` / ``fourteen_day_*`` rollups so
|
|
2086
|
+
# the rollup math stays untouched.
|
|
2087
|
+
raw_days_newest_first = sorted(
|
|
2088
|
+
result.rows, key=lambda r: r.date or "", reverse=True,
|
|
2089
|
+
)
|
|
2090
|
+
days_newest_first: list = []
|
|
2091
|
+
if today_row is None:
|
|
2092
|
+
# Build a zero-valued synthetic today row mirroring today_spotlight.
|
|
2093
|
+
days_newest_first.append(
|
|
2094
|
+
CacheReportDailyRow(
|
|
2095
|
+
date=today_iso,
|
|
2096
|
+
cache_hit_percent=0.0,
|
|
2097
|
+
input_tokens=0,
|
|
2098
|
+
output_tokens=0,
|
|
2099
|
+
cache_creation_tokens=0,
|
|
2100
|
+
cache_read_tokens=0,
|
|
2101
|
+
saved_usd=0.0,
|
|
2102
|
+
wasted_usd=0.0,
|
|
2103
|
+
net_usd=0.0,
|
|
2104
|
+
anomaly_triggered=False,
|
|
2105
|
+
anomaly_reasons=(),
|
|
2106
|
+
)
|
|
2107
|
+
)
|
|
2108
|
+
days_newest_first.extend(
|
|
2109
|
+
CacheReportDailyRow(
|
|
2110
|
+
date=r.date or "",
|
|
2111
|
+
cache_hit_percent=r.cache_hit_percent,
|
|
2112
|
+
input_tokens=r.input_tokens,
|
|
2113
|
+
output_tokens=r.output_tokens,
|
|
2114
|
+
cache_creation_tokens=r.cache_creation_tokens,
|
|
2115
|
+
cache_read_tokens=r.cache_read_tokens,
|
|
2116
|
+
saved_usd=r.saved_usd,
|
|
2117
|
+
wasted_usd=r.wasted_usd,
|
|
2118
|
+
net_usd=r.net_usd,
|
|
2119
|
+
anomaly_triggered=r.anomaly_triggered,
|
|
2120
|
+
anomaly_reasons=tuple(r.anomaly_reasons),
|
|
2121
|
+
)
|
|
2122
|
+
for r in raw_days_newest_first
|
|
2123
|
+
)
|
|
2124
|
+
days = tuple(days_newest_first[:window_days])
|
|
2125
|
+
|
|
2126
|
+
# By-project + by-model breakdowns are window-wide aggregates (not
|
|
2127
|
+
# today-only) so the panel can surface the project / model carrying
|
|
2128
|
+
# the bulk of net savings across the trailing 14d. by-project walks
|
|
2129
|
+
# raw entries (project_path is per-entry, not on the day-model
|
|
2130
|
+
# buckets); by-model folds the per-row ``model_breakdowns`` already
|
|
2131
|
+
# produced by day-mode, which avoids re-running the tiered-pricing
|
|
2132
|
+
# math per entry. Both paths apply the same ``<synthetic>`` filter so
|
|
2133
|
+
# the axes can't silently disagree on token totals.
|
|
2134
|
+
#
|
|
2135
|
+
# Constrain both axes to the SAME calendar dates as ``days``: the
|
|
2136
|
+
# kernel's rolling window can emit ``window_days + 1`` distinct
|
|
2137
|
+
# display-tz buckets (see the slice-cap comment above), and ``days``
|
|
2138
|
+
# drops the oldest. Without the same drop here the by-project /
|
|
2139
|
+
# by-model cards would silently include the clipped 15th day and
|
|
2140
|
+
# their net totals stop reconciling against the visible table /
|
|
2141
|
+
# CacheNetBars in the modal. The filter mirrors the kernel's
|
|
2142
|
+
# bucket-key derivation (``entry.timestamp.astimezone(tz)``) so a
|
|
2143
|
+
# cache entry and its corresponding day row always agree on which
|
|
2144
|
+
# bucket they belong to.
|
|
2145
|
+
kept_dates = frozenset(r.date for r in days if r.date)
|
|
2146
|
+
bucket_tz = display_tz if display_tz is not None else dt.timezone.utc
|
|
2147
|
+
entries_in_window = [
|
|
2148
|
+
e for e in entries
|
|
2149
|
+
if e.timestamp.astimezone(bucket_tz).strftime("%Y-%m-%d") in kept_dates
|
|
2150
|
+
]
|
|
2151
|
+
rows_in_window = [r for r in result.rows if r.date in kept_dates]
|
|
2152
|
+
by_project_rows = crk._aggregate_cache_breakdown(
|
|
2153
|
+
entries_in_window,
|
|
2154
|
+
key_fn=lambda e: (getattr(e, "project_path", None) or "(unknown)"),
|
|
2155
|
+
pricing=pricing,
|
|
2156
|
+
skip_synthetic=True,
|
|
2157
|
+
)
|
|
2158
|
+
by_model_rows = crk._aggregate_cache_breakdown_from_rows(
|
|
2159
|
+
rows_in_window,
|
|
2160
|
+
skip_synthetic=True,
|
|
2161
|
+
)
|
|
2162
|
+
by_project = tuple(
|
|
2163
|
+
CacheReportBreakdownRow(
|
|
2164
|
+
key=r.key, cache_hit_percent=r.cache_hit_percent, net_usd=r.net_usd,
|
|
2165
|
+
)
|
|
2166
|
+
for r in by_project_rows
|
|
2167
|
+
)
|
|
2168
|
+
by_model = tuple(
|
|
2169
|
+
CacheReportBreakdownRow(
|
|
2170
|
+
key=r.key, cache_hit_percent=r.cache_hit_percent, net_usd=r.net_usd,
|
|
2171
|
+
)
|
|
2172
|
+
for r in by_model_rows
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
# 7-day rollup: today + 6 prior. Walk by string date; ``days_newest_first``
|
|
2176
|
+
# is already in the right order.
|
|
2177
|
+
seven_day_rows = days[:7]
|
|
2178
|
+
seven_day_net_usd = sum(r.net_usd for r in seven_day_rows)
|
|
2179
|
+
seven_day_anomaly_count = sum(
|
|
2180
|
+
1 for r in seven_day_rows if r.anomaly_triggered
|
|
2181
|
+
)
|
|
2182
|
+
|
|
2183
|
+
# 14-day counterfactual: sum(saved_usd) across the window.
|
|
2184
|
+
fourteen_day_counterfactual_usd = sum(r.saved_usd for r in days)
|
|
2185
|
+
fourteen_day_wasted_usd = sum(r.wasted_usd for r in days)
|
|
2186
|
+
denom = fourteen_day_counterfactual_usd + abs(fourteen_day_wasted_usd)
|
|
2187
|
+
fourteen_day_efficiency_ratio = (
|
|
2188
|
+
(fourteen_day_counterfactual_usd / denom) if denom > 1e-9 else 0.0
|
|
2189
|
+
)
|
|
2190
|
+
|
|
2191
|
+
return CacheReportSnapshot(
|
|
2192
|
+
window_days=window_days,
|
|
2193
|
+
anomaly_threshold_pp=anomaly_threshold_pp,
|
|
2194
|
+
anomaly_window_days=anomaly_window_days,
|
|
2195
|
+
today=today_spotlight,
|
|
2196
|
+
days=days,
|
|
2197
|
+
by_project=by_project,
|
|
2198
|
+
by_model=by_model,
|
|
2199
|
+
seven_day_net_usd=seven_day_net_usd,
|
|
2200
|
+
seven_day_anomaly_count=seven_day_anomaly_count,
|
|
2201
|
+
fourteen_day_counterfactual_usd=fourteen_day_counterfactual_usd,
|
|
2202
|
+
fourteen_day_efficiency_ratio=fourteen_day_efficiency_ratio,
|
|
2203
|
+
is_empty=False,
|
|
2204
|
+
)
|
|
2205
|
+
|
|
2206
|
+
|
|
1677
2207
|
# === Dashboard server core: _SnapshotRef + SSEHub + envelope builders =====
|
|
1678
2208
|
# Pre-extract location: bin/cctally L16265.
|
|
1679
2209
|
|
|
@@ -4237,6 +4767,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4237
4767
|
# panel-empty state in that case.
|
|
4238
4768
|
"projects": getattr(snap, "projects_envelope", None),
|
|
4239
4769
|
|
|
4770
|
+
# Cache-report panel + modal envelope block (spec
|
|
4771
|
+
# 2026-05-21-cache-report-panel-design.md §4.2). Snake_case
|
|
4772
|
+
# keys throughout — the envelope is intentionally snake_case
|
|
4773
|
+
# end-to-end (envelope.ts:189). ``None`` on first tick before
|
|
4774
|
+
# sync completes; the client renders the panel-empty state in
|
|
4775
|
+
# that case. envelope_version stays at 2 (additive optional
|
|
4776
|
+
# field, matches the update? / doctor? precedent).
|
|
4777
|
+
"cache_report": _cache_report_snapshot_to_dict(
|
|
4778
|
+
getattr(snap, "cache_report", None)
|
|
4779
|
+
),
|
|
4780
|
+
|
|
4240
4781
|
# threshold-actions T5: see prelude above for rationale.
|
|
4241
4782
|
"alerts": alerts_array,
|
|
4242
4783
|
"alerts_settings": alerts_settings,
|
|
@@ -4583,10 +5124,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4583
5124
|
"""Persist a settings update and trigger an immediate SSE broadcast.
|
|
4584
5125
|
|
|
4585
5126
|
Body shape: ``{"display"?: {"tz": "..."}, "alerts"?: {...},
|
|
4586
|
-
"update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}}
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
with 400.
|
|
5127
|
+
"update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
|
|
5128
|
+
"cache_report"?: {"anomaly_threshold_pp"?: int}}`` — every
|
|
5129
|
+
top-level key is optional; any subset may be sent together
|
|
5130
|
+
(combined save). Unknown top-level keys are rejected with 400.
|
|
4590
5131
|
|
|
4591
5132
|
Per-block validation:
|
|
4592
5133
|
* ``display.tz`` — "local", "utc", or a valid IANA zone (via
|
|
@@ -4600,6 +5141,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4600
5141
|
``[1, 720]``; 400 on out-of-range or non-int. Bool is rejected
|
|
4601
5142
|
(Python ``True`` is an int subclass, so a permissive check
|
|
4602
5143
|
would silently accept ``true`` for a numeric field).
|
|
5144
|
+
* ``cache_report.anomaly_threshold_pp`` — JSON int (NOT bool /
|
|
5145
|
+
float / string), in ``[1, 100]``; 400 with
|
|
5146
|
+
``{error, field: "anomaly_threshold_pp"}`` on out-of-range
|
|
5147
|
+
or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
|
|
5148
|
+
F10 tracks lifting that.
|
|
4603
5149
|
|
|
4604
5150
|
Atomic merged write: if all touched blocks validate, the merged
|
|
4605
5151
|
config is persisted in a single ``save_config`` call inside the
|
|
@@ -4651,7 +5197,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4651
5197
|
return
|
|
4652
5198
|
|
|
4653
5199
|
# Reject unknown top-level keys (forward-compat hygiene).
|
|
4654
|
-
allowed_top_keys = {"display", "alerts", "update"}
|
|
5200
|
+
allowed_top_keys = {"display", "alerts", "update", "cache_report"}
|
|
4655
5201
|
for k in payload.keys():
|
|
4656
5202
|
if k not in allowed_top_keys:
|
|
4657
5203
|
self._respond_json(
|
|
@@ -4664,16 +5210,49 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4664
5210
|
"display" not in payload
|
|
4665
5211
|
and "alerts" not in payload
|
|
4666
5212
|
and "update" not in payload
|
|
5213
|
+
and "cache_report" not in payload
|
|
4667
5214
|
):
|
|
4668
5215
|
self._respond_json(
|
|
4669
5216
|
400,
|
|
4670
5217
|
{"error": (
|
|
4671
5218
|
"body must contain at least one of: "
|
|
4672
|
-
"display, alerts, update"
|
|
5219
|
+
"display, alerts, update, cache_report"
|
|
4673
5220
|
)},
|
|
4674
5221
|
)
|
|
4675
5222
|
return
|
|
4676
5223
|
|
|
5224
|
+
# Pre-validate cache_report block (spec 2026-05-21 §6.2). Outside
|
|
5225
|
+
# the config_writer_lock so a 400 short-circuit doesn't take the
|
|
5226
|
+
# lock unnecessarily. Returns HTTP 400 (NOT 422) on validation
|
|
5227
|
+
# error — matches the convention every other block here uses.
|
|
5228
|
+
#
|
|
5229
|
+
# Validator returns a dict of ONLY the keys present in the input
|
|
5230
|
+
# (partial-PUT semantics, mirroring the ``update.check`` block
|
|
5231
|
+
# at ~line 5277). The handler below merges this into the
|
|
5232
|
+
# existing persisted ``cache_report`` block so a combined save
|
|
5233
|
+
# that omits ``anomaly_threshold_pp`` does not clobber the
|
|
5234
|
+
# user's persisted threshold with the default.
|
|
5235
|
+
cache_report_validated: "dict | None" = None
|
|
5236
|
+
if "cache_report" in payload:
|
|
5237
|
+
cache_report_block = payload["cache_report"]
|
|
5238
|
+
if not isinstance(cache_report_block, dict):
|
|
5239
|
+
self._respond_json(
|
|
5240
|
+
400,
|
|
5241
|
+
{"error": "cache_report must be an object",
|
|
5242
|
+
"field": "cache_report"},
|
|
5243
|
+
)
|
|
5244
|
+
return
|
|
5245
|
+
try:
|
|
5246
|
+
cache_report_validated = _validate_cache_report_settings(
|
|
5247
|
+
cache_report_block
|
|
5248
|
+
)
|
|
5249
|
+
except _CacheReportConfigError as exc:
|
|
5250
|
+
self._respond_json(
|
|
5251
|
+
400,
|
|
5252
|
+
{"error": str(exc), "field": exc.field or "cache_report"},
|
|
5253
|
+
)
|
|
5254
|
+
return
|
|
5255
|
+
|
|
4677
5256
|
# Pre-validate display block (outside the config_writer_lock so a
|
|
4678
5257
|
# 400 short-circuit doesn't take the lock unnecessarily).
|
|
4679
5258
|
display_canonical: "str | None" = None
|
|
@@ -4861,6 +5440,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4861
5440
|
merged_update["check"] = merged_check
|
|
4862
5441
|
merged["update"] = merged_update
|
|
4863
5442
|
|
|
5443
|
+
if cache_report_validated is not None:
|
|
5444
|
+
# Same hand-edited-junk guard as alerts / update: a non
|
|
5445
|
+
# -dict ``cache_report`` block in config.json should
|
|
5446
|
+
# surface as a recoverable 400, not a 500.
|
|
5447
|
+
existing_cr = merged.get("cache_report")
|
|
5448
|
+
if existing_cr is not None and not isinstance(existing_cr, dict):
|
|
5449
|
+
self._respond_json(
|
|
5450
|
+
400, {"error": "cache_report must be an object",
|
|
5451
|
+
"field": "cache_report"}
|
|
5452
|
+
)
|
|
5453
|
+
return
|
|
5454
|
+
# Partial-PUT merge: preserve keys the request didn't
|
|
5455
|
+
# touch (mirrors the update.check block at ~line 5371).
|
|
5456
|
+
# Becomes load-bearing once F10 lifts
|
|
5457
|
+
# ``anomaly_window_days`` to config — until then it
|
|
5458
|
+
# still defends a combined save (e.g. display + empty
|
|
5459
|
+
# cache_report) from clobbering ``anomaly_threshold_pp``
|
|
5460
|
+
# with the default.
|
|
5461
|
+
merged_cr = dict(existing_cr or {})
|
|
5462
|
+
merged_cr.update(cache_report_validated)
|
|
5463
|
+
merged["cache_report"] = merged_cr
|
|
5464
|
+
|
|
4864
5465
|
save_config(merged)
|
|
4865
5466
|
|
|
4866
5467
|
# Build the response: subset of touched blocks.
|
|
@@ -4884,6 +5485,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
4884
5485
|
),
|
|
4885
5486
|
}
|
|
4886
5487
|
}
|
|
5488
|
+
if cache_report_validated is not None:
|
|
5489
|
+
# Echo the full cooked block (resolved defaults included) so
|
|
5490
|
+
# the dashboard composer can repaint without a follow-up GET
|
|
5491
|
+
# — mirrors the update.check echo at ~line 5402. Read from
|
|
5492
|
+
# the merged cache_report we just wrote; fall back to the
|
|
5493
|
+
# documented default when neither the request nor the
|
|
5494
|
+
# persisted config carries an explicit value.
|
|
5495
|
+
persisted_cr = merged.get("cache_report") or {}
|
|
5496
|
+
stored_threshold = persisted_cr.get("anomaly_threshold_pp", 15)
|
|
5497
|
+
out["cache_report"] = {
|
|
5498
|
+
"anomaly_threshold_pp": stored_threshold,
|
|
5499
|
+
}
|
|
4887
5500
|
out["saved_at"] = (
|
|
4888
5501
|
dt.datetime.now(dt.timezone.utc)
|
|
4889
5502
|
.strftime("%Y-%m-%dT%H:%M:%SZ")
|