cctally 1.82.0 → 1.83.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 +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
|
@@ -21,7 +21,7 @@ from datetime import datetime as _datetime, timezone as _timezone
|
|
|
21
21
|
# Public surface (Plan 2): shipped in the npm tarball + brew formula + public
|
|
22
22
|
# mirror — imported by the dashboard's conversation endpoints at runtime.
|
|
23
23
|
|
|
24
|
-
from _lib_pricing import _calculate_entry_cost, _chip_for_model
|
|
24
|
+
from _lib_pricing import _calculate_entry_cost, _chip_for_model, claude_usage_dict
|
|
25
25
|
# #178: the on-demand load-full re-read helper re-stringifies a raw tool_result
|
|
26
26
|
# content block the same way the parser does at ingest — reuse the parser's
|
|
27
27
|
# _stringify so the full (un-capped) result text matches the cached/capped one.
|
|
@@ -325,6 +325,51 @@ def _session_first_prompt_titles_map(conn, session_ids):
|
|
|
325
325
|
return titles
|
|
326
326
|
|
|
327
327
|
|
|
328
|
+
def _session_rollup_titles_map(conn, session_ids):
|
|
329
|
+
"""{sid: title} from the STORED ``conversation_sessions.title`` rollup — the
|
|
330
|
+
first-prompt title #302 materializes so the hot read paths never re-run
|
|
331
|
+
``_session_first_prompt_titles_map``'s windowed ``conversation_messages``
|
|
332
|
+
scan. One PK lookup per id.
|
|
333
|
+
|
|
334
|
+
Falsey titles are DROPPED (a rollup row exists for every indexed session,
|
|
335
|
+
titled or not), so an untitled session is simply absent. Tolerates the table
|
|
336
|
+
being absent (pre-migration / rebuilding store) by returning {}."""
|
|
337
|
+
if not session_ids:
|
|
338
|
+
return {}
|
|
339
|
+
out = {}
|
|
340
|
+
try:
|
|
341
|
+
ph = ",".join("?" for _ in session_ids)
|
|
342
|
+
for sid, title in conn.execute(
|
|
343
|
+
f"SELECT session_id, title FROM conversation_sessions "
|
|
344
|
+
f"WHERE session_id IN ({ph})", tuple(session_ids)
|
|
345
|
+
).fetchall():
|
|
346
|
+
if title:
|
|
347
|
+
out[sid] = title
|
|
348
|
+
except sqlite3.OperationalError:
|
|
349
|
+
pass # table absent -> {} (caller degrades to no title)
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def session_titles_indexed_map(conn, session_ids):
|
|
354
|
+
"""{sid: title} composed from INDEXED reads only — the truthy AI title wins,
|
|
355
|
+
else the stored rollup title. Same precedence as ``_session_titles_map``,
|
|
356
|
+
and identical values whenever the rollup is current (#302: the stored title
|
|
357
|
+
IS the first-prompt title), but it NEVER falls back to the windowed
|
|
358
|
+
``conversation_messages`` scan.
|
|
359
|
+
|
|
360
|
+
That bound is the contract: this is what a latency-sensitive, fail-soft
|
|
361
|
+
caller (the dashboard Sessions panel, which reads the transcript store over
|
|
362
|
+
a bounded read-only connection) may run per tick. A session missing from
|
|
363
|
+
both indexes is simply absent — the caller renders its em-dash fallback and
|
|
364
|
+
the next rollup recompute fills it in."""
|
|
365
|
+
if not session_ids:
|
|
366
|
+
return {}
|
|
367
|
+
titles = dict(_session_ai_titles_map(conn, session_ids))
|
|
368
|
+
for sid, t in _session_rollup_titles_map(conn, session_ids).items():
|
|
369
|
+
titles.setdefault(sid, t) # AI title (truthy) wins; else the rollup
|
|
370
|
+
return titles
|
|
371
|
+
|
|
372
|
+
|
|
328
373
|
def _session_titles_map(conn, session_ids):
|
|
329
374
|
"""{sid: title} — the TRUTHY AI title wins, else the first-prompt title.
|
|
330
375
|
Contract UNCHANGED (the live/degraded rail branch still calls this as-is); now
|
|
@@ -501,16 +546,22 @@ def _iso_ms(ts):
|
|
|
501
546
|
return None
|
|
502
547
|
|
|
503
548
|
|
|
504
|
-
def _entry_cost(
|
|
549
|
+
def _entry_cost(
|
|
550
|
+
model, inp, out, cc, cr, cost_usd_raw, *, cc_1h, speed
|
|
551
|
+
) -> float:
|
|
505
552
|
"""Cost for one session_entries row via the shared pricing helper. Tokens →
|
|
506
553
|
the helper's usage dict. cost_usd_raw is passed as the optional override the
|
|
507
|
-
helper already understands (it is often NULL — never the primary source).
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
554
|
+
helper already understands (it is often NULL — never the primary source).
|
|
555
|
+
|
|
556
|
+
`cc_1h` (#195) is a REQUIRED keyword, inherited verbatim from
|
|
557
|
+
`claude_usage_dict`: this is a thin shim over the builder, so a default here
|
|
558
|
+
would re-open the exact hazard the builder's required keyword closes — a
|
|
559
|
+
caller that forgets the split does not raise, it silently prices every
|
|
560
|
+
1-hour cache write at the 5-minute rate. Pass an explicit None for a
|
|
561
|
+
genuinely unknown split; that reads as a declaration at the call site."""
|
|
562
|
+
usage = claude_usage_dict(
|
|
563
|
+
input_tokens=inp, output_tokens=out, cache_creation_tokens=cc,
|
|
564
|
+
cache_read_tokens=cr, cache_1h_tokens=cc_1h, speed=speed)
|
|
514
565
|
return _calculate_entry_cost(model or "", usage, cost_usd=cost_usd_raw)
|
|
515
566
|
|
|
516
567
|
|
|
@@ -531,26 +582,49 @@ _CACHE_FAILURE_CACHE_FLOOR = 20_000 # prior cache must be meaningful to "lo
|
|
|
531
582
|
_CACHE_FAILURE_CREATE_FLOOR = 20_000 # the re-creation must be substantial / real cost
|
|
532
583
|
|
|
533
584
|
|
|
534
|
-
def _cache_failure_wasted_usd(model, lost):
|
|
585
|
+
def _cache_failure_wasted_usd(model, lost, *, speed):
|
|
535
586
|
"""Marginal extra paid by re-creating `lost` previously-cached tokens at the
|
|
536
587
|
cache-WRITE rate instead of reading them at the cache-READ rate. Reuses the
|
|
537
588
|
pricing chokepoint `_calculate_entry_cost` (zero on unknown models — the
|
|
538
589
|
helper emits its own one-shot stderr warning, never raises). NEVER summed into
|
|
539
|
-
any cost-snapshot / budget / reconciled figure — a display-only estimate.
|
|
540
|
-
|
|
541
|
-
|
|
590
|
+
any cost-snapshot / budget / reconciled figure — a display-only estimate.
|
|
591
|
+
The lost-prefix subset has no authoritative mapping to the source row's
|
|
592
|
+
5m/1h write buckets, so this preserves the existing 5m estimate while
|
|
593
|
+
applying the retained effective speed tier."""
|
|
594
|
+
write = _calculate_entry_cost(
|
|
595
|
+
model or "",
|
|
596
|
+
claude_usage_dict(
|
|
597
|
+
cache_1h_tokens=None, speed=speed, cache_creation_tokens=lost
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
read = _calculate_entry_cost(
|
|
601
|
+
model or "",
|
|
602
|
+
claude_usage_dict(
|
|
603
|
+
cache_1h_tokens=None, speed=speed, cache_read_tokens=lost
|
|
604
|
+
),
|
|
605
|
+
)
|
|
542
606
|
return write - read
|
|
543
607
|
|
|
544
608
|
|
|
545
|
-
def _cache_read_saved_usd(model, cache_read):
|
|
609
|
+
def _cache_read_saved_usd(model, cache_read, *, speed):
|
|
546
610
|
"""Marginal USD the cache SAVED this turn: the `cache_read` prefix priced at
|
|
547
611
|
the full input rate minus its actual cache-READ rate. Display-only (same
|
|
548
612
|
caveat as `_cache_failure_wasted_usd`): NEVER summed into a cost-snapshot /
|
|
549
613
|
budget / reconciled figure. `input_tokens` and `cache_read_input_tokens` are
|
|
550
614
|
independent keys in the Claude `_calculate_entry_cost` (no subset
|
|
551
615
|
subtraction), so passing each alone yields the two rates cleanly."""
|
|
552
|
-
full = _calculate_entry_cost(
|
|
553
|
-
|
|
616
|
+
full = _calculate_entry_cost(
|
|
617
|
+
model or "",
|
|
618
|
+
claude_usage_dict(
|
|
619
|
+
cache_1h_tokens=None, speed=speed, input_tokens=cache_read
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
read = _calculate_entry_cost(
|
|
623
|
+
model or "",
|
|
624
|
+
claude_usage_dict(
|
|
625
|
+
cache_1h_tokens=None, speed=speed, cache_read_tokens=cache_read
|
|
626
|
+
),
|
|
627
|
+
)
|
|
554
628
|
return full - read
|
|
555
629
|
|
|
556
630
|
|
|
@@ -561,21 +635,24 @@ def _cache_read_saved_usd(model, cache_read):
|
|
|
561
635
|
# a document-ordered list of these and feed it to the ONE predicate below — the
|
|
562
636
|
# rule is implemented exactly once (U1, #217 S1).
|
|
563
637
|
class _CFEvent:
|
|
564
|
-
__slots__ = ("compaction", "key", "cc", "cr", "model")
|
|
638
|
+
__slots__ = ("compaction", "key", "cc", "cr", "model", "speed")
|
|
565
639
|
|
|
566
|
-
def __init__(
|
|
640
|
+
def __init__(
|
|
641
|
+
self, *, compaction=False, key=None, cc=0, cr=0, model=None, speed=None
|
|
642
|
+
):
|
|
567
643
|
self.compaction = compaction
|
|
568
644
|
self.key = key
|
|
569
645
|
self.cc = cc
|
|
570
646
|
self.cr = cr
|
|
571
647
|
self.model = model
|
|
648
|
+
self.speed = speed
|
|
572
649
|
|
|
573
650
|
|
|
574
651
|
def _iter_cache_failures(events):
|
|
575
652
|
"""The single cache-failure rule (spec §1), as a generator over a
|
|
576
653
|
document-ordered ``_CFEvent`` stream. Yields ``(index, prev_cached, lost,
|
|
577
|
-
model)`` for each FLAGGED assistant event — ``index`` is the event's
|
|
578
|
-
in ``events`` so a caller can map a flag back to its source item.
|
|
654
|
+
model, speed)`` for each FLAGGED assistant event — ``index`` is the event's
|
|
655
|
+
position in ``events`` so a caller can map a flag back to its source item.
|
|
579
656
|
|
|
580
657
|
Maintains a running-max of ``cache_read`` keyed by the event's ``key``
|
|
581
658
|
(``(subagent_key, model)`` — ``None`` subagent_key = main session). The key
|
|
@@ -615,7 +692,7 @@ def _iter_cache_failures(events):
|
|
|
615
692
|
and total > 0
|
|
616
693
|
and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
|
|
617
694
|
lost = min(cc, max(0, rm - cr))
|
|
618
|
-
yield (i, rm, lost, ev.model)
|
|
695
|
+
yield (i, rm, lost, ev.model, ev.speed)
|
|
619
696
|
running_max[ev.key] = max(rm, cr)
|
|
620
697
|
|
|
621
698
|
|
|
@@ -650,7 +727,8 @@ def _cache_failure_events_from_items(items):
|
|
|
650
727
|
key=(it.get("subagent_key"), it.get("model")),
|
|
651
728
|
cc=tok.get("cache_creation", 0) or 0,
|
|
652
729
|
cr=tok.get("cache_read", 0) or 0,
|
|
653
|
-
model=it.get("model")
|
|
730
|
+
model=it.get("model"),
|
|
731
|
+
speed=it.get("_speed")))
|
|
654
732
|
sources.append(it)
|
|
655
733
|
return events, sources
|
|
656
734
|
|
|
@@ -674,11 +752,13 @@ def _stamp_cache_failures(items):
|
|
|
674
752
|
est_wasted_usd = write(lost) - read(lost)
|
|
675
753
|
"""
|
|
676
754
|
events, sources = _cache_failure_events_from_items(items)
|
|
677
|
-
for idx, prev_cached, lost, model in _iter_cache_failures(events):
|
|
755
|
+
for idx, prev_cached, lost, model, speed in _iter_cache_failures(events):
|
|
678
756
|
sources[idx]["cache_failure"] = {
|
|
679
757
|
"tokens_recreated": lost,
|
|
680
758
|
"prev_cached": prev_cached,
|
|
681
|
-
"est_wasted_usd": _cache_failure_wasted_usd(
|
|
759
|
+
"est_wasted_usd": _cache_failure_wasted_usd(
|
|
760
|
+
model, lost, speed=speed
|
|
761
|
+
),
|
|
682
762
|
}
|
|
683
763
|
|
|
684
764
|
|
|
@@ -856,10 +936,15 @@ def _turn_costs_for_keys(conn, keys):
|
|
|
856
936
|
cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
|
|
857
937
|
params = [v for pair in chunk for v in pair]
|
|
858
938
|
sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
|
|
859
|
-
"cache_create_tokens, cache_read_tokens, cost_usd_raw "
|
|
939
|
+
"cache_create_tokens, cache_read_tokens, cost_usd_raw, "
|
|
940
|
+
"cache_create_1h_tokens, speed "
|
|
860
941
|
"FROM session_entries WHERE " + cond)
|
|
861
|
-
for m, r, model, inp, out, cc, cr, raw in conn.execute(
|
|
862
|
-
|
|
942
|
+
for m, r, model, inp, out, cc, cr, raw, cc1h, speed in conn.execute(
|
|
943
|
+
sql, params
|
|
944
|
+
):
|
|
945
|
+
costs[(m, r)] = _entry_cost(
|
|
946
|
+
model, inp, out, cc, cr, raw, cc_1h=cc1h, speed=speed
|
|
947
|
+
)
|
|
863
948
|
return costs
|
|
864
949
|
|
|
865
950
|
|
|
@@ -1494,7 +1579,7 @@ def _turn_cost_map(conn, turn_keys):
|
|
|
1494
1579
|
|
|
1495
1580
|
|
|
1496
1581
|
def _turn_usage_map(conn, turn_keys):
|
|
1497
|
-
"""{(msg_id, req_id): {"
|
|
1582
|
+
"""{(msg_id, req_id): {tokens..., "speed"}} for
|
|
1498
1583
|
the given non-null turn keys, read from the SAME deduped session_entries row
|
|
1499
1584
|
cost is computed from (#177). This is a SEPARATE sibling of _turn_cost_map —
|
|
1500
1585
|
that one returns a float and is also consumed by the search path
|
|
@@ -1512,11 +1597,12 @@ def _turn_usage_map(conn, turn_keys):
|
|
|
1512
1597
|
cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
|
|
1513
1598
|
params = [v for pair in chunk for v in pair]
|
|
1514
1599
|
sql = ("SELECT msg_id, req_id, input_tokens, output_tokens, "
|
|
1515
|
-
"cache_create_tokens, cache_read_tokens "
|
|
1600
|
+
"cache_create_tokens, cache_read_tokens, speed "
|
|
1516
1601
|
"FROM session_entries WHERE " + cond)
|
|
1517
|
-
for m, r, inp, out, cc, cr in conn.execute(sql, params):
|
|
1602
|
+
for m, r, inp, out, cc, cr, speed in conn.execute(sql, params):
|
|
1518
1603
|
usage[(m, r)] = {"input": inp or 0, "output": out or 0,
|
|
1519
|
-
"cache_creation": cc or 0, "cache_read": cr or 0
|
|
1604
|
+
"cache_creation": cc or 0, "cache_read": cr or 0,
|
|
1605
|
+
"speed": speed}
|
|
1520
1606
|
return usage
|
|
1521
1607
|
|
|
1522
1608
|
|
|
@@ -1936,7 +2022,11 @@ def _assemble_session(conn, session_id):
|
|
|
1936
2022
|
# key has no session_entries row (omitted, not zero-filled).
|
|
1937
2023
|
tok = usage.get((it["_msg_id"], it["_req_id"]))
|
|
1938
2024
|
if tok is not None:
|
|
1939
|
-
|
|
2025
|
+
# `speed` is an internal pricing input, not part of the public
|
|
2026
|
+
# token-count object. Keep it alongside the assembled item for
|
|
2027
|
+
# cache financials and strip it from reader page copies.
|
|
2028
|
+
it["tokens"] = {k: v for k, v in tok.items() if k != "speed"}
|
|
2029
|
+
it["_speed"] = tok.get("speed")
|
|
1940
2030
|
del it["_msg_id"]
|
|
1941
2031
|
del it["_req_id"]
|
|
1942
2032
|
it.pop("_has_prose", None)
|
|
@@ -2231,6 +2321,7 @@ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
|
|
|
2231
2321
|
patched = []
|
|
2232
2322
|
for it in page:
|
|
2233
2323
|
nit = dict(it)
|
|
2324
|
+
nit.pop("_speed", None)
|
|
2234
2325
|
nit["anchor"] = {**it["anchor"], "session_id": session_id}
|
|
2235
2326
|
if nit.get("text"):
|
|
2236
2327
|
nit["text"] = _strip_ansi(nit["text"])
|
|
@@ -2377,7 +2468,9 @@ def get_conversation_outline(conn, session_id):
|
|
|
2377
2468
|
tokens[k] += tok.get(k, 0)
|
|
2378
2469
|
cr_tokens = tok.get("cache_read", 0) or 0
|
|
2379
2470
|
if cr_tokens > 0:
|
|
2380
|
-
cache_saved += _cache_read_saved_usd(
|
|
2471
|
+
cache_saved += _cache_read_saved_usd(
|
|
2472
|
+
it.get("model"), cr_tokens, speed=it.get("_speed")
|
|
2473
|
+
)
|
|
2381
2474
|
# Copy the cache-failure marker onto the OutlineTurn exactly where
|
|
2382
2475
|
# tokens is copied (assistant-only, rides the same source row) and
|
|
2383
2476
|
# accumulate the session-level aggregate (spec §2).
|
|
@@ -17,6 +17,7 @@ PhysicalSource = Literal["claude", "codex"]
|
|
|
17
17
|
DashboardSelection = Literal["claude", "codex", "all"]
|
|
18
18
|
Availability = Literal["ok", "empty", "partial", "unavailable"]
|
|
19
19
|
Freshness = Literal["fresh", "stale"]
|
|
20
|
+
FreshnessDomain = Literal["hero", "quota", "sessions"]
|
|
20
21
|
CapabilityStatus = Literal[
|
|
21
22
|
"supported", "derived", "unavailable", "deferred", "not_applicable",
|
|
22
23
|
]
|
|
@@ -24,6 +25,7 @@ CapabilityStatus = Literal[
|
|
|
24
25
|
SOURCE_SCHEMA_VERSION = 1
|
|
25
26
|
DEFAULT_SOURCE = "claude"
|
|
26
27
|
SOURCE_ORDER = ("claude", "codex", "all")
|
|
28
|
+
SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
|
|
27
29
|
|
|
28
30
|
_PHYSICAL_SOURCES = frozenset(("claude", "codex"))
|
|
29
31
|
_SELECTIONS = frozenset(SOURCE_ORDER)
|
|
@@ -109,10 +111,19 @@ class SourceDashboardState:
|
|
|
109
111
|
last_success_at: dt.datetime | None
|
|
110
112
|
capabilities: Mapping[str, CapabilityRecord]
|
|
111
113
|
data: Mapping[str, object] | None
|
|
114
|
+
# Domain freshness is orthogonal to provider-generation coherence. Legacy
|
|
115
|
+
# constructors may omit it; they deterministically inherit the provider
|
|
116
|
+
# value for every known domain.
|
|
117
|
+
domain_freshness: Mapping[str, Freshness] | None = None
|
|
112
118
|
# Immutable, server-only facts used to advance an idle presentation clock.
|
|
113
119
|
# They are deliberately separate from ``data`` so no internal accounting
|
|
114
120
|
# evidence becomes part of the public source-envelope contract.
|
|
115
121
|
clock_data: Mapping[str, object] | None = None
|
|
122
|
+
# Request-gated transcript content. This mapping is frozen with the source
|
|
123
|
+
# generation but is deliberately outside ``data``: source serialization
|
|
124
|
+
# publishes only ``data``, then the HTTP/SSE envelope layer injects a label
|
|
125
|
+
# into its request-local copies when that request's transcript gate is open.
|
|
126
|
+
private_session_labels: Mapping[str, str] | None = None
|
|
116
127
|
|
|
117
128
|
def __post_init__(self) -> None:
|
|
118
129
|
validate_dashboard_selection(self.source)
|
|
@@ -120,6 +131,16 @@ class SourceDashboardState:
|
|
|
120
131
|
raise ValueError("unsupported availability")
|
|
121
132
|
if self.freshness not in _FRESHNESS:
|
|
122
133
|
raise ValueError("unsupported freshness")
|
|
134
|
+
domain_freshness = (
|
|
135
|
+
{domain: self.freshness for domain in SOURCE_FRESHNESS_DOMAINS}
|
|
136
|
+
if self.domain_freshness is None else dict(self.domain_freshness)
|
|
137
|
+
)
|
|
138
|
+
if set(domain_freshness) != set(SOURCE_FRESHNESS_DOMAINS):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"domain freshness must contain exactly hero, quota, and sessions"
|
|
141
|
+
)
|
|
142
|
+
if any(value not in _FRESHNESS for value in domain_freshness.values()):
|
|
143
|
+
raise ValueError("unsupported domain freshness")
|
|
123
144
|
if not isinstance(self.data_version, str):
|
|
124
145
|
raise ValueError("data_version must be a string")
|
|
125
146
|
if self.availability != "unavailable":
|
|
@@ -141,10 +162,20 @@ class SourceDashboardState:
|
|
|
141
162
|
raise ValueError("capabilities must contain CapabilityRecord values")
|
|
142
163
|
object.__setattr__(self, "warnings", warnings)
|
|
143
164
|
object.__setattr__(self, "capabilities", _freeze(capabilities))
|
|
165
|
+
object.__setattr__(self, "domain_freshness", _freeze(domain_freshness))
|
|
144
166
|
if self.data is not None:
|
|
145
167
|
object.__setattr__(self, "data", _freeze(self.data))
|
|
146
168
|
if self.clock_data is not None:
|
|
147
169
|
object.__setattr__(self, "clock_data", _freeze(self.clock_data))
|
|
170
|
+
if self.private_session_labels is not None:
|
|
171
|
+
private_session_labels = {
|
|
172
|
+
_nonempty_string(key, "private session label key"):
|
|
173
|
+
_nonempty_string(value, "private session label")
|
|
174
|
+
for key, value in self.private_session_labels.items()
|
|
175
|
+
}
|
|
176
|
+
object.__setattr__(
|
|
177
|
+
self, "private_session_labels", _freeze(private_session_labels),
|
|
178
|
+
)
|
|
148
179
|
|
|
149
180
|
|
|
150
181
|
@dataclass(frozen=True)
|
|
@@ -246,7 +277,11 @@ def degrade_source_state(
|
|
|
246
277
|
last_success_at=prior.last_success_at,
|
|
247
278
|
capabilities=prior.capabilities,
|
|
248
279
|
data=prior.data,
|
|
280
|
+
domain_freshness={
|
|
281
|
+
domain: "stale" for domain in SOURCE_FRESHNESS_DOMAINS
|
|
282
|
+
},
|
|
249
283
|
clock_data=prior.clock_data,
|
|
284
|
+
private_session_labels=prior.private_session_labels,
|
|
250
285
|
)
|
|
251
286
|
|
|
252
287
|
|
|
@@ -267,9 +302,28 @@ def unavailable_source_state(
|
|
|
267
302
|
last_success_at=None,
|
|
268
303
|
capabilities={},
|
|
269
304
|
data=None,
|
|
305
|
+
domain_freshness={
|
|
306
|
+
domain: "stale" for domain in SOURCE_FRESHNESS_DOMAINS
|
|
307
|
+
},
|
|
270
308
|
)
|
|
271
309
|
|
|
272
310
|
|
|
311
|
+
def source_domain_freshness(
|
|
312
|
+
state: SourceDashboardState,
|
|
313
|
+
domain: FreshnessDomain,
|
|
314
|
+
) -> Freshness:
|
|
315
|
+
"""Return one domain value with the frozen legacy-provider fallback."""
|
|
316
|
+
if domain not in SOURCE_FRESHNESS_DOMAINS:
|
|
317
|
+
raise ValueError("unsupported freshness domain")
|
|
318
|
+
mapping = getattr(state, "domain_freshness", None)
|
|
319
|
+
if isinstance(mapping, Mapping):
|
|
320
|
+
value = mapping.get(domain)
|
|
321
|
+
if value in _FRESHNESS:
|
|
322
|
+
return value
|
|
323
|
+
provider = getattr(state, "freshness", "stale")
|
|
324
|
+
return provider if provider in _FRESHNESS else "stale"
|
|
325
|
+
|
|
326
|
+
|
|
273
327
|
def _coherent_provider(state: SourceDashboardState) -> bool:
|
|
274
328
|
return (
|
|
275
329
|
state.availability in ("ok", "empty", "partial")
|
|
@@ -297,12 +351,43 @@ def reuse_coherent_source_state(
|
|
|
297
351
|
return prior if _coherent_provider(prior) and prior.data_version == data_version else None
|
|
298
352
|
|
|
299
353
|
|
|
354
|
+
def _hero_cycle_is_stale(state: SourceDashboardState) -> bool:
|
|
355
|
+
"""Whether a provider's hero is bounded by STALE quota evidence (#350 §3.4).
|
|
356
|
+
|
|
357
|
+
``hero.cycle_freshness`` is additive and OMITTED while the cycle is fresh, so
|
|
358
|
+
this is false for every provider and every generation that predates #350.
|
|
359
|
+
"""
|
|
360
|
+
if source_domain_freshness(state, "hero") == "stale":
|
|
361
|
+
return True
|
|
362
|
+
if not isinstance(state.data, Mapping):
|
|
363
|
+
return False
|
|
364
|
+
hero = state.data.get("hero")
|
|
365
|
+
return isinstance(hero, Mapping) and hero.get("cycle_freshness") == "stale"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _stale_cycle_providers(
|
|
369
|
+
claude: SourceDashboardState,
|
|
370
|
+
codex: SourceDashboardState,
|
|
371
|
+
) -> tuple[str, ...]:
|
|
372
|
+
return tuple(
|
|
373
|
+
label for label, state in (("Claude", claude), ("Codex", codex))
|
|
374
|
+
if _hero_cycle_is_stale(state)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
300
378
|
def _combined_metrics(
|
|
301
379
|
claude: SourceDashboardState,
|
|
302
380
|
codex: SourceDashboardState,
|
|
303
381
|
) -> Mapping[str, object] | None:
|
|
304
382
|
if not (_coherent_provider(claude) and _coherent_provider(codex)):
|
|
305
383
|
return None
|
|
384
|
+
# #350 spec §3.5: a stale-cycle hero is NOT combinable. Retaining it would
|
|
385
|
+
# let a sum over stale evidence be published while `compose_all_state` marks
|
|
386
|
+
# the result fresh — and the All hero carries no Snapshot row or staleness
|
|
387
|
+
# marker at all, so the staleness would be silently undisclosed. The
|
|
388
|
+
# combined NUMBER therefore behaves exactly as it does today.
|
|
389
|
+
if _stale_cycle_providers(claude, codex):
|
|
390
|
+
return None
|
|
306
391
|
for state in (claude, codex):
|
|
307
392
|
hero_capability = state.capabilities.get("hero")
|
|
308
393
|
if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
|
|
@@ -366,7 +451,24 @@ def compose_all_state(
|
|
|
366
451
|
raise ValueError("all composition requires Claude and Codex provider states")
|
|
367
452
|
combined = _combined_metrics(claude, codex)
|
|
368
453
|
providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
|
|
454
|
+
# #350 spec §3.5: "All behaves exactly as today" is true only of the combined
|
|
455
|
+
# NUMBER. Under §3.4 both providers stay coherent, so All now publishes
|
|
456
|
+
# partial/fresh with no provider warning to explain it — the status chip
|
|
457
|
+
# would fall through to a generic `degraded` and the All hero fallback would
|
|
458
|
+
# claim a provider is degraded while both provider envelopes say otherwise.
|
|
459
|
+
# This All-LOCAL warning states the real reason without touching either
|
|
460
|
+
# provider envelope. It is emitted only when the providers are otherwise
|
|
461
|
+
# coherent; an incoherent provider already publishes its own reason.
|
|
462
|
+
all_local_warnings: tuple[SourceDashboardWarning, ...] = ()
|
|
369
463
|
if providers_coherent:
|
|
464
|
+
stale_cycle_providers = _stale_cycle_providers(claude, codex)
|
|
465
|
+
if stale_cycle_providers:
|
|
466
|
+
all_local_warnings = (SourceDashboardWarning(
|
|
467
|
+
"combined_totals_withheld",
|
|
468
|
+
f"{' and '.join(stale_cycle_providers)} quota evidence is stale, "
|
|
469
|
+
"so combined totals are withheld.",
|
|
470
|
+
"hero",
|
|
471
|
+
),)
|
|
370
472
|
availability: Availability = (
|
|
371
473
|
"partial"
|
|
372
474
|
if combined is None or "partial" in (claude.availability, codex.availability)
|
|
@@ -383,7 +485,15 @@ def compose_all_state(
|
|
|
383
485
|
version_material = json.dumps(
|
|
384
486
|
[
|
|
385
487
|
claude.data_version, claude.availability, claude.freshness,
|
|
488
|
+
[
|
|
489
|
+
source_domain_freshness(claude, domain)
|
|
490
|
+
for domain in SOURCE_FRESHNESS_DOMAINS
|
|
491
|
+
],
|
|
386
492
|
codex.data_version, codex.availability, codex.freshness,
|
|
493
|
+
[
|
|
494
|
+
source_domain_freshness(codex, domain)
|
|
495
|
+
for domain in SOURCE_FRESHNESS_DOMAINS
|
|
496
|
+
],
|
|
387
497
|
combined is not None,
|
|
388
498
|
],
|
|
389
499
|
separators=(",", ":"),
|
|
@@ -395,7 +505,11 @@ def compose_all_state(
|
|
|
395
505
|
source="all",
|
|
396
506
|
availability=availability,
|
|
397
507
|
freshness=freshness,
|
|
398
|
-
warnings
|
|
508
|
+
# All-LOCAL warnings lead: `warningForSource` on the client falls back to
|
|
509
|
+
# the FIRST warning, so a merely-partial provider warning (e.g.
|
|
510
|
+
# `codex_metadata_incomplete`) would otherwise pre-empt the chip label
|
|
511
|
+
# and hide the real reason combined totals are withheld.
|
|
512
|
+
warnings=tuple((*all_local_warnings, *claude.warnings, *codex.warnings)),
|
|
399
513
|
data_version=data_version,
|
|
400
514
|
last_success_at=last_success_at,
|
|
401
515
|
capabilities={
|
|
@@ -412,6 +526,17 @@ def compose_all_state(
|
|
|
412
526
|
"codex": codex.data,
|
|
413
527
|
},
|
|
414
528
|
},
|
|
529
|
+
domain_freshness={
|
|
530
|
+
domain: (
|
|
531
|
+
"fresh"
|
|
532
|
+
if all(
|
|
533
|
+
source_domain_freshness(state, domain) == "fresh"
|
|
534
|
+
for state in (claude, codex)
|
|
535
|
+
)
|
|
536
|
+
else "stale"
|
|
537
|
+
)
|
|
538
|
+
for domain in SOURCE_FRESHNESS_DOMAINS
|
|
539
|
+
},
|
|
415
540
|
)
|
|
416
541
|
|
|
417
542
|
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -110,6 +110,8 @@ def _load_lib(name: str):
|
|
|
110
110
|
|
|
111
111
|
_lib_pricing = _load_lib("_lib_pricing")
|
|
112
112
|
_calculate_entry_cost = _lib_pricing._calculate_entry_cost
|
|
113
|
+
# #195: the single construction point for every cost-feeding usage dict.
|
|
114
|
+
claude_usage_dict = _lib_pricing.claude_usage_dict
|
|
113
115
|
|
|
114
116
|
_lib_display_tz = _load_lib("_lib_display_tz")
|
|
115
117
|
_resolve_tz = _lib_display_tz._resolve_tz
|
|
@@ -512,12 +514,14 @@ def _diff_aggregate_overall(
|
|
|
512
514
|
continue
|
|
513
515
|
cost += _calculate_entry_cost(
|
|
514
516
|
e.model,
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
517
|
+
claude_usage_dict( # #195 chokepoint
|
|
518
|
+
input_tokens=e.input_tokens,
|
|
519
|
+
output_tokens=e.output_tokens,
|
|
520
|
+
cache_creation_tokens=e.cache_creation_tokens,
|
|
521
|
+
cache_read_tokens=e.cache_read_tokens,
|
|
522
|
+
cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
|
|
523
|
+
speed=getattr(e, "speed", None),
|
|
524
|
+
),
|
|
521
525
|
mode="auto",
|
|
522
526
|
cost_usd=e.cost_usd,
|
|
523
527
|
)
|
|
@@ -552,9 +556,12 @@ def _diff_aggregate_models(
|
|
|
552
556
|
})
|
|
553
557
|
b["cost"] += _calculate_entry_cost(
|
|
554
558
|
e.model,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
559
|
+
claude_usage_dict( # #195 chokepoint
|
|
560
|
+
input_tokens=e.input_tokens, output_tokens=e.output_tokens,
|
|
561
|
+
cache_creation_tokens=e.cache_creation_tokens,
|
|
562
|
+
cache_read_tokens=e.cache_read_tokens,
|
|
563
|
+
cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
|
|
564
|
+
speed=getattr(e, "speed", None)),
|
|
558
565
|
mode="auto", cost_usd=e.cost_usd,
|
|
559
566
|
)
|
|
560
567
|
b["ti"] += e.input_tokens
|
|
@@ -593,9 +600,12 @@ def _diff_aggregate_projects(
|
|
|
593
600
|
})
|
|
594
601
|
b["cost"] += _calculate_entry_cost(
|
|
595
602
|
e.model,
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
603
|
+
claude_usage_dict( # #195 chokepoint
|
|
604
|
+
input_tokens=e.input_tokens, output_tokens=e.output_tokens,
|
|
605
|
+
cache_creation_tokens=e.cache_creation_tokens,
|
|
606
|
+
cache_read_tokens=e.cache_read_tokens,
|
|
607
|
+
cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
|
|
608
|
+
speed=getattr(e, "speed", None)),
|
|
599
609
|
mode="auto", cost_usd=e.cost_usd,
|
|
600
610
|
)
|
|
601
611
|
b["ti"] += e.input_tokens
|
|
@@ -639,9 +649,12 @@ def _diff_aggregate_cache(
|
|
|
639
649
|
continue
|
|
640
650
|
cost += _calculate_entry_cost(
|
|
641
651
|
e.model,
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
652
|
+
claude_usage_dict( # #195 chokepoint
|
|
653
|
+
input_tokens=e.input_tokens, output_tokens=e.output_tokens,
|
|
654
|
+
cache_creation_tokens=e.cache_creation_tokens,
|
|
655
|
+
cache_read_tokens=e.cache_read_tokens,
|
|
656
|
+
cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
|
|
657
|
+
speed=getattr(e, "speed", None)),
|
|
645
658
|
mode="auto", cost_usd=e.cost_usd,
|
|
646
659
|
)
|
|
647
660
|
tcr += e.cache_read_tokens
|