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