cctally 1.90.1 → 1.91.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 +35 -0
- package/README.md +2 -2
- package/bin/_cctally_core.py +14 -0
- package/bin/_cctally_dashboard_envelope.py +67 -12
- package/bin/_cctally_dashboard_sources.py +27 -1
- package/bin/_lib_cache_report_wire.py +8 -20
- package/bin/_lib_codex_conversation.py +108 -0
- package/bin/_lib_codex_conversation_query.py +832 -117
- package/bin/_lib_codex_reasoning_headings.py +73 -0
- package/bin/_lib_codex_segments.py +259 -0
- package/bin/_lib_dashboard_sources.py +33 -32
- package/dashboard/static/assets/index-CILAoEja.js +90 -0
- package/dashboard/static/assets/index-Dwirao3Y.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-Bar8-S1i.css +0 -1
- package/dashboard/static/assets/index-CRogVlEC.js +0 -92
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.91.0] - 2026-08-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- The Codex conversation reader now shows an indicator inside the transcript while a further page is loading, so a scroll-up or a jump that has to fetch is visibly in progress rather than silently pending. When a jump cannot reach its target the reader now says so instead of falling silent (#463).
|
|
12
|
+
- Internal (maintainer-only): the whole frontend test estate now runs inside `bin/cctally-test-all` through a new `bin/cctally-frontend-test` harness, with a two-sided golden pinning the Codex reader path on both the server envelope and the adapted client model. Every CI lane that runs the bundle now provisions it (#463).
|
|
13
|
+
- Internal (maintainer-only): the React client now runs ESLint's hook-order rule in both its normal build and the authoritative test bundle, so a conditional hook fails statically instead of surviving until a mounted component changes branches at runtime (#468).
|
|
14
|
+
- Codex reasoning now lists every heading the model actually wrote, one readable line each, instead of a single clipped line that hid the rest. Press `h` and `H` to step forward and back through them, including into a part of the conversation that has not loaded yet. Codex re-states earlier headings as a turn goes on, so a heading a turn has already shown is listed once rather than again each time (#463).
|
|
15
|
+
- Internal (maintainer-only): `bin/cctally-frontend-test` now typechecks the React client, so a TypeScript error fails the authoritative local and remote suite instead of only the CI build (#463).
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- A long Codex conversation now opens without downloading the whole thing: the server serves a bounded page and the reader loads the rest as you scroll. The heaviest conversation in a real store went from 13.2 MB in a single response to 2.5 MB (#463).
|
|
19
|
+
- A very long Codex turn is now split into several bounded reading units instead of one enormous message, so the reader can scroll it smoothly. Deep links, bookmarks and saved reading positions issued before this change still resolve (#463).
|
|
20
|
+
- A Codex message that is not the one its turn's cost is attributed to now reports no cost at all rather than a zero, so a consumer of the JSON can tell "this message carries no cost of its own" apart from "this cost nothing". No displayed figure changes: every rendering site already showed a cost only when there was a positive one (#463).
|
|
21
|
+
- Consecutive Codex messages now read as the separate messages they were, instead of being run together into one block of prose (#463).
|
|
22
|
+
- The dashboard's Codex Cache Report wire now publishes only `cached_input_percent`, reports structurally inapplicable wasted-cost and efficiency values as `null`, and identifies the coordinated contract change as source schema version 4 (#465).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Internal (maintainer-only): generated cache fixtures now store UTC timestamps in the same `+00:00` form as production, so rows exactly at a reader's upper boundary are exercised instead of being silently excluded by lexical ordering (#467).
|
|
26
|
+
- Internal (maintainer-only): the remote-test harness's synthetic lock holders now acknowledge that their lock path is gone before a fixture continues, and wrapper cleanup no longer strands an owned lock when its secondary process-start probe is transiently unavailable under load (#464).
|
|
27
|
+
- The Codex hero's transient ingest-backlog note now stays to one compact line on phone-width screens instead of wrapping into two to four lines. The full `+N sessions still loading` wording remains on wider screens, and the complete explanation remains available to assistive technology at every width (#459).
|
|
28
|
+
- Multi-account Codex quota labels now stay attached to the account they came from when two accounts share one `$CODEX_HOME` root. The dashboard no longer borrows another account's label or window duration from an otherwise-identical active quota key, while single-account dashboards keep their existing undecorated join (#437).
|
|
29
|
+
- The dashboard's All tab now keeps its combined spend and token total when one provider's quota evidence is stale. The retained actuals carry a visible `Stale quota` marker in both the hero and current-usage modal, while forward-looking projections remain paused (#359).
|
|
30
|
+
- Multi-account Codex dashboard cards now mark stale quota evidence on the account it belongs to. A fresh sibling no longer hides another account's staleness, and the stale card keeps showing its retained percentage, reset, and spend; after reset its quota fields still clear while historical spend remains (#416, #360).
|
|
31
|
+
- The dashboard's Recent Alerts card and modal now identify which account each alert belongs to and honor the selected account filter, while retaining vendor-wide crossings as `All accounts`. Simultaneous same-threshold alerts from different accounts no longer collapse into one toast, and single-account dashboards keep their previous undecorated shape (#345).
|
|
32
|
+
- The dashboard now marks incomplete Codex totals everywhere they are shown: the Combined spend hero carries the same visible `+N sessions still loading` caveat as the Codex hero, and the Codex current-cycle modal explains that its totals will rise as bounded ingest finishes. The disclosure also reaches the Codex section embedded in the All-sources modal, while account-scoped stale-cycle notes keep their existing scope (#456).
|
|
33
|
+
- A deep link, bookmark or saved reading position pointing into a long Codex turn now loads the message it names. Because the reader's outline omits some turns from its navigation list, any position inside one of those turns resolved to nothing at all and the reader stayed on the last page it had, showing no error. On the heaviest conversation in a real store this affected every position inside a split turn. The same omission applies to Claude conversations for a position inside a system or lifecycle turn, and those now load as well (#463).
|
|
34
|
+
- Find in a Codex conversation now reports and reaches every match. Matches inside a long turn, past its first reading unit, were dropped from the result list — on the heaviest conversation in a real store one query reported 2 matches where there are 16 — and none of the dropped ones could be navigated to (#463).
|
|
35
|
+
- Paging up through a Codex conversation now returns the page immediately before your cursor. It previously returned the conversation's opening messages instead, which made scrolling up appear to jump to the beginning (#463).
|
|
36
|
+
- `cctally transcript export` and the dashboard export no longer repeat each Codex reasoning heading, so an exported Codex transcript reads once through rather than twice (#463).
|
|
37
|
+
- The transcript paging indicator no longer sits under the "↓ N new" pill on a narrow screen (#463).
|
|
38
|
+
- A long Codex reasoning heading is now readable in full: it wraps instead of being cut off mid-sentence with no way to see the rest (#463).
|
|
39
|
+
- A Codex message that opened a code block and never closed it no longer swallows the messages that follow it into that code block (#463).
|
|
40
|
+
- Internal (maintainer-only): the issue-triage tracker now keeps its reconciler ledger in a collapsed disclosure, matching the managed issue comments instead of rendering raw machine-state JSON below the human execution plan (#475).
|
|
41
|
+
- Concurrent first-time commands no longer misclassify a freshly initialized `stats.db` as a database written by a newer cctally. A waiting opener now rechecks the index epoch after acquiring the initialization lock, so it accepts the winner's completed index instead of sending epoch `1006` through the frozen legacy migration dispatcher.
|
|
42
|
+
|
|
8
43
|
## [1.90.1] - 2026-08-02
|
|
9
44
|
|
|
10
45
|
### Fixed
|
package/README.md
CHANGED
|
@@ -30,9 +30,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
|
|
|
30
30
|
</p>
|
|
31
31
|
|
|
32
32
|
<!-- cctally:latest-stable:begin -->
|
|
33
|
-
**Latest stable: v1.
|
|
33
|
+
**Latest stable: v1.90.1** (2026-08-02)
|
|
34
34
|
|
|
35
|
-
-
|
|
35
|
+
- Internal (maintainer-only): the public release snapshot no longer ships the issue-triage pytest modules without the private workflow implementation they import. Those tests now stay behind the same mirror boundary as their `.agent-workflows` dependencies, preventing collection failures across the public Python matrix.
|
|
36
36
|
<!-- cctally:latest-stable:end -->
|
|
37
37
|
|
|
38
38
|
## Quick start
|
package/bin/_cctally_core.py
CHANGED
|
@@ -1648,6 +1648,20 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1648
1648
|
# at `_uv == STATS_INDEX_EPOCH` above and never reaches here, so the hot
|
|
1649
1649
|
# path takes no exclusive lock at all.
|
|
1650
1650
|
with _cctally_store.stats_open_time_guard(live=_target_path is None):
|
|
1651
|
+
# Another opener can win the exclusive guard after this connection's
|
|
1652
|
+
# pre-lock epoch read, initialize/cut over the index, and stamp the
|
|
1653
|
+
# current epoch while this opener waits. Recheck under the guard before
|
|
1654
|
+
# acting on that stale legacy/fresh decision; otherwise the loser enters
|
|
1655
|
+
# the frozen migration dispatcher with user_version=1006 and reports a
|
|
1656
|
+
# false downgrade against legacy head 13.
|
|
1657
|
+
if _epoch_engaged and conn.execute(
|
|
1658
|
+
"PRAGMA user_version"
|
|
1659
|
+
).fetchone()[0] == STATS_INDEX_EPOCH:
|
|
1660
|
+
if _target_path is None:
|
|
1661
|
+
_reconcile_durable_applied_migration_errors(
|
|
1662
|
+
conn, _STATS_MIGRATIONS, "stats.db"
|
|
1663
|
+
)
|
|
1664
|
+
return conn
|
|
1651
1665
|
_fixups_current = _cctally_store.stats_open_fixups_current(conn)
|
|
1652
1666
|
conn.execute(
|
|
1653
1667
|
"""
|
|
@@ -41,6 +41,7 @@ import bisect
|
|
|
41
41
|
import datetime as dt
|
|
42
42
|
import importlib.util as _ilu
|
|
43
43
|
import os
|
|
44
|
+
import sqlite3
|
|
44
45
|
import sys
|
|
45
46
|
from collections.abc import Mapping
|
|
46
47
|
from zoneinfo import ZoneInfo
|
|
@@ -329,7 +330,40 @@ def _select_current_block_for_envelope(
|
|
|
329
330
|
# ``FROM`` clause so the table name lives in the registry, not inlined here.
|
|
330
331
|
|
|
331
332
|
|
|
332
|
-
def
|
|
333
|
+
def _alert_account_resolver(conn: sqlite3.Connection):
|
|
334
|
+
"""Build one snapshot-scoped #345 resolver without per-row DB reads."""
|
|
335
|
+
import _cctally_account
|
|
336
|
+
|
|
337
|
+
decorated = {
|
|
338
|
+
provider: _cctally_account.provider_is_decorated(conn, provider)
|
|
339
|
+
for provider in ("claude", "codex")
|
|
340
|
+
}
|
|
341
|
+
labels = {
|
|
342
|
+
provider: _cctally_account.display_label_map(conn, provider)
|
|
343
|
+
for provider, enabled in decorated.items()
|
|
344
|
+
if enabled
|
|
345
|
+
}
|
|
346
|
+
for provider_labels in labels.values():
|
|
347
|
+
provider_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
|
|
348
|
+
|
|
349
|
+
def fields(provider: str, account_key: object) -> dict[str, str]:
|
|
350
|
+
if not decorated.get(provider, False):
|
|
351
|
+
return {}
|
|
352
|
+
key = str(account_key or "*")
|
|
353
|
+
label = labels[provider].get(key)
|
|
354
|
+
if label is None:
|
|
355
|
+
label = _cctally_account.account_label(conn, key)
|
|
356
|
+
return {
|
|
357
|
+
"accountKey": key,
|
|
358
|
+
"accountLabel": label,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return fields
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _envelope_rows_weekly(
|
|
365
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
366
|
+
) -> list[dict]:
|
|
333
367
|
# ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
|
|
334
368
|
# across pre-credit (0) and post-credit (event.id) cohorts, both
|
|
335
369
|
# of which can be alerted. The envelope id must include the
|
|
@@ -340,7 +374,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
340
374
|
rows = conn.execute(
|
|
341
375
|
f"""
|
|
342
376
|
SELECT week_start_date, percent_threshold, captured_at_utc,
|
|
343
|
-
alerted_at, cumulative_cost_usd, reset_event_id
|
|
377
|
+
alerted_at, cumulative_cost_usd, reset_event_id, account_key
|
|
344
378
|
FROM {descriptor.milestone_table}
|
|
345
379
|
WHERE alerted_at IS NOT NULL
|
|
346
380
|
ORDER BY alerted_at DESC
|
|
@@ -360,6 +394,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
360
394
|
"severity": severity_for(threshold),
|
|
361
395
|
"crossed_at": r["captured_at_utc"],
|
|
362
396
|
"alerted_at": r["alerted_at"],
|
|
397
|
+
**account_fields("claude", r["account_key"]),
|
|
363
398
|
"context": {
|
|
364
399
|
"week_start_date": r["week_start_date"],
|
|
365
400
|
"cumulative_cost_usd": cumulative,
|
|
@@ -377,7 +412,9 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
|
377
412
|
return out
|
|
378
413
|
|
|
379
414
|
|
|
380
|
-
def _envelope_rows_five_hour(
|
|
415
|
+
def _envelope_rows_five_hour(
|
|
416
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
417
|
+
) -> list[dict]:
|
|
381
418
|
# Site F (spec §3.2 bucket C / §3.3): widen the row identity to
|
|
382
419
|
# include ``reset_event_id`` so post-credit (seg=event.id) crossings
|
|
383
420
|
# of the same (window_key, threshold) don't collide with pre-credit
|
|
@@ -387,10 +424,12 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
387
424
|
rows = conn.execute(
|
|
388
425
|
f"""
|
|
389
426
|
SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
|
|
390
|
-
m.alerted_at, m.block_cost_usd, m.reset_event_id,
|
|
427
|
+
m.alerted_at, m.block_cost_usd, m.reset_event_id, m.account_key,
|
|
391
428
|
b.block_start_at
|
|
392
429
|
FROM {descriptor.milestone_table} m
|
|
393
|
-
LEFT JOIN five_hour_blocks b
|
|
430
|
+
LEFT JOIN five_hour_blocks b
|
|
431
|
+
ON b.five_hour_window_key = m.five_hour_window_key
|
|
432
|
+
AND b.account_key = m.account_key
|
|
394
433
|
WHERE m.alerted_at IS NOT NULL
|
|
395
434
|
ORDER BY m.alerted_at DESC
|
|
396
435
|
LIMIT ?
|
|
@@ -410,6 +449,7 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
410
449
|
"severity": severity_for(threshold),
|
|
411
450
|
"crossed_at": r["captured_at_utc"],
|
|
412
451
|
"alerted_at": r["alerted_at"],
|
|
452
|
+
**account_fields("claude", r["account_key"]),
|
|
413
453
|
"context": {
|
|
414
454
|
"five_hour_window_key": int(r["five_hour_window_key"]),
|
|
415
455
|
"block_start_at": r["block_start_at"] or "",
|
|
@@ -420,7 +460,9 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
420
460
|
return out
|
|
421
461
|
|
|
422
462
|
|
|
423
|
-
def _envelope_rows_budget_family(
|
|
463
|
+
def _envelope_rows_budget_family(
|
|
464
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
465
|
+
) -> list[dict]:
|
|
424
466
|
# Unified vendor-tagged budget axis (#143). ONE mapper backs BOTH the
|
|
425
467
|
# ``budget`` (``vendor='claude'``, issue #19) and ``codex_budget``
|
|
426
468
|
# (``vendor='codex'``, calendar-period-codex-budgets spec §6) axes —
|
|
@@ -461,7 +503,7 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
|
|
|
461
503
|
SELECT period_start_at,
|
|
462
504
|
COALESCE(period, ?) AS period,
|
|
463
505
|
threshold, crossed_at_utc, alerted_at,
|
|
464
|
-
budget_usd, spent_usd, consumption_pct
|
|
506
|
+
budget_usd, spent_usd, consumption_pct, account_key
|
|
465
507
|
FROM {descriptor.milestone_table}
|
|
466
508
|
WHERE vendor = ? AND alerted_at IS NOT NULL
|
|
467
509
|
ORDER BY alerted_at DESC
|
|
@@ -498,12 +540,15 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
|
|
|
498
540
|
"severity": severity_for(threshold),
|
|
499
541
|
"crossed_at": r["crossed_at_utc"],
|
|
500
542
|
"alerted_at": r["alerted_at"],
|
|
543
|
+
**account_fields(str(vendor), r["account_key"]),
|
|
501
544
|
"context": ctx,
|
|
502
545
|
})
|
|
503
546
|
return out
|
|
504
547
|
|
|
505
548
|
|
|
506
|
-
def _envelope_rows_projected(
|
|
549
|
+
def _envelope_rows_projected(
|
|
550
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
551
|
+
) -> list[dict]:
|
|
507
552
|
# Fourth axis (issue #121): projected-pace threshold crossings. Like
|
|
508
553
|
# budget, projected alerts re-anchor ``week_start_at`` on a mid-week
|
|
509
554
|
# reset, so there is NO ``reset_event_id`` segment — the new window gets
|
|
@@ -524,7 +569,7 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
524
569
|
SELECT week_start_at,
|
|
525
570
|
COALESCE(period, 'subscription-week') AS period,
|
|
526
571
|
metric, threshold, projected_value,
|
|
527
|
-
denominator, crossed_at_utc, alerted_at
|
|
572
|
+
denominator, crossed_at_utc, alerted_at, account_key
|
|
528
573
|
FROM {descriptor.milestone_table}
|
|
529
574
|
WHERE alerted_at IS NOT NULL
|
|
530
575
|
ORDER BY alerted_at DESC
|
|
@@ -547,6 +592,10 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
547
592
|
"severity": severity_for(threshold),
|
|
548
593
|
"crossed_at": r["crossed_at_utc"],
|
|
549
594
|
"alerted_at": r["alerted_at"],
|
|
595
|
+
**account_fields(
|
|
596
|
+
"codex" if metric == "codex_budget_usd" else "claude",
|
|
597
|
+
r["account_key"],
|
|
598
|
+
),
|
|
550
599
|
"context": {
|
|
551
600
|
"week_start_at": r["week_start_at"],
|
|
552
601
|
"metric": metric,
|
|
@@ -557,7 +606,9 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
557
606
|
return out
|
|
558
607
|
|
|
559
608
|
|
|
560
|
-
def _envelope_rows_project_budget(
|
|
609
|
+
def _envelope_rows_project_budget(
|
|
610
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
611
|
+
) -> list[dict]:
|
|
561
612
|
# Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
|
|
562
613
|
# crossings. Like the global budget axis, project-budget alerts re-anchor
|
|
563
614
|
# ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
|
|
@@ -575,7 +626,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
575
626
|
rows = conn.execute(
|
|
576
627
|
f"""
|
|
577
628
|
SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
|
|
578
|
-
consumption_pct, crossed_at_utc, alerted_at
|
|
629
|
+
consumption_pct, crossed_at_utc, alerted_at, account_key
|
|
579
630
|
FROM {descriptor.milestone_table}
|
|
580
631
|
WHERE alerted_at IS NOT NULL
|
|
581
632
|
ORDER BY alerted_at DESC
|
|
@@ -605,6 +656,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
|
|
|
605
656
|
"severity": severity_for(threshold),
|
|
606
657
|
"crossed_at": r["crossed_at_utc"],
|
|
607
658
|
"alerted_at": r["alerted_at"],
|
|
659
|
+
**account_fields("claude", r["account_key"]),
|
|
608
660
|
"context": {
|
|
609
661
|
"week_start_at": r["week_start_at"],
|
|
610
662
|
"project": label_by_key.get(project_key, project_key),
|
|
@@ -795,12 +847,15 @@ def _build_alerts_envelope_array(
|
|
|
795
847
|
c = sys.modules["cctally"]
|
|
796
848
|
registry = c.AXIS_REGISTRY
|
|
797
849
|
severity_for = c.severity_for
|
|
850
|
+
account_fields = _alert_account_resolver(conn)
|
|
798
851
|
out: list[dict] = []
|
|
799
852
|
for descriptor in registry:
|
|
800
853
|
mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
|
|
801
854
|
if mapper is None: # pragma: no cover - registry/mapper drift guard
|
|
802
855
|
continue
|
|
803
|
-
out.extend(mapper(
|
|
856
|
+
out.extend(mapper(
|
|
857
|
+
conn, descriptor, limit, severity_for, account_fields,
|
|
858
|
+
))
|
|
804
859
|
|
|
805
860
|
# Python's list.sort is stable. When two alerts share the same
|
|
806
861
|
# `alerted_at` ISO string (rare; multiple axes firing within the same
|
|
@@ -2422,9 +2422,29 @@ def _alerts_wire(
|
|
|
2422
2422
|
so it stays visible under focus and the client labels it as vendor-wide.
|
|
2423
2423
|
"""
|
|
2424
2424
|
rows: list[dict[str, object]] = []
|
|
2425
|
+
if decorated:
|
|
2426
|
+
import _cctally_account
|
|
2427
|
+
|
|
2428
|
+
account_labels = _cctally_account.display_label_map(stats_conn, "codex")
|
|
2429
|
+
account_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
|
|
2430
|
+
else:
|
|
2431
|
+
account_labels = {}
|
|
2425
2432
|
|
|
2426
2433
|
def _account(value: object) -> dict[str, object]:
|
|
2427
|
-
|
|
2434
|
+
if not decorated:
|
|
2435
|
+
return {}
|
|
2436
|
+
|
|
2437
|
+
key = str(value or _CODEX_VENDOR_WIDE_ACCOUNT)
|
|
2438
|
+
label = account_labels.get(key)
|
|
2439
|
+
if label is None:
|
|
2440
|
+
label = _cctally_account.account_label(stats_conn, key)
|
|
2441
|
+
return {
|
|
2442
|
+
# Retained as the internal account-scope selector used by the
|
|
2443
|
+
# source-state builder; the camel fields are the public #345 wire.
|
|
2444
|
+
"account_key": key,
|
|
2445
|
+
"accountKey": key,
|
|
2446
|
+
"accountLabel": label,
|
|
2447
|
+
}
|
|
2428
2448
|
|
|
2429
2449
|
try:
|
|
2430
2450
|
for period, threshold, consumption_pct, crossed_at, account_key in stats_conn.execute(
|
|
@@ -2880,6 +2900,12 @@ def _codex_accounts_wire(
|
|
|
2880
2900
|
}
|
|
2881
2901
|
if is_unattributed:
|
|
2882
2902
|
card["unattributed"] = True
|
|
2903
|
+
elif cyc is not None and cyc.evidence_stale:
|
|
2904
|
+
# #360 / #416 closeout: freshness belongs to the account whose
|
|
2905
|
+
# resolved cycle supplied the card. The aggregate hero marker
|
|
2906
|
+
# cannot speak for a fresh sibling, and staleness is disclosure
|
|
2907
|
+
# only — the retained percentage, reset and spend remain useful.
|
|
2908
|
+
card["cycleFreshness"] = "stale"
|
|
2883
2909
|
accounts_wire.append(card)
|
|
2884
2910
|
if cyc is not None and not is_unattributed:
|
|
2885
2911
|
hero_cycles_wire.append({
|
|
@@ -6,7 +6,7 @@ shared key set, which is how the Codex empty return and its populated
|
|
|
6
6
|
return drifted apart (#443 F18).
|
|
7
7
|
|
|
8
8
|
Provider parameterization resolves exactly four things and nothing else:
|
|
9
|
-
the percent key
|
|
9
|
+
the percent key, whether the not-applicable metadata is emitted, the
|
|
10
10
|
applicable predicate set, and the reason text. Everything else is
|
|
11
11
|
provider-independent by construction.
|
|
12
12
|
|
|
@@ -31,15 +31,8 @@ ALL_PREDICATES = tuple(
|
|
|
31
31
|
|
|
32
32
|
# Codex figures that are structurally absent rather than unmeasured.
|
|
33
33
|
# OpenAI charges no cache-write premium, so there is nothing to waste and
|
|
34
|
-
# therefore no ratio of saved to wasted.
|
|
35
|
-
#
|
|
36
|
-
# Math.round on the second); this map is the authoritative signal a
|
|
37
|
-
# current client renders from.
|
|
38
|
-
#
|
|
39
|
-
# Switching these to None, removing the transitional `cache_hit_percent`
|
|
40
|
-
# in _percent, and bumping SOURCE_SCHEMA_VERSION to 4 are ONE release's
|
|
41
|
-
# work, tracked in cctally-dev#465. Doing any of them alone reintroduces
|
|
42
|
-
# the stale-tab crash this retention exists to avoid.
|
|
34
|
+
# therefore no ratio of saved to wasted. Their values are None on the Codex
|
|
35
|
+
# wire; this map remains the authoritative user-facing reason.
|
|
43
36
|
CODEX_NOT_APPLICABLE = {
|
|
44
37
|
"wasted_usd": "OpenAI charges no cache-write premium, so Codex has no wasted-cache figure.",
|
|
45
38
|
"fourteen_day_efficiency_ratio": "Efficiency compares saved against wasted, and Codex has no wasted-cache figure.",
|
|
@@ -82,16 +75,10 @@ def filter_inapplicable(provider, row):
|
|
|
82
75
|
|
|
83
76
|
|
|
84
77
|
def _percent(provider, row):
|
|
85
|
-
"""Emit the percent key
|
|
86
|
-
|
|
87
|
-
Codex dual-publishes: `cached_input_percent` is authoritative and
|
|
88
|
-
`cache_hit_percent` carries the identical value for one release, so a
|
|
89
|
-
tab that spans a `cctally update` keeps reading a true number instead
|
|
90
|
-
of `Math.floor(NaN)`.
|
|
91
|
-
"""
|
|
78
|
+
"""Emit the provider-authoritative percent key for one row mapping."""
|
|
92
79
|
value = row["cache_hit_percent"]
|
|
93
80
|
if provider == "codex":
|
|
94
|
-
return {"cached_input_percent": value
|
|
81
|
+
return {"cached_input_percent": value}
|
|
95
82
|
return {"cache_hit_percent": value}
|
|
96
83
|
|
|
97
84
|
|
|
@@ -104,7 +91,7 @@ def _today_block(provider, today):
|
|
|
104
91
|
"delta_pp": today["delta_pp"],
|
|
105
92
|
"net_usd": today["net_usd"],
|
|
106
93
|
"saved_usd": today["saved_usd"],
|
|
107
|
-
"wasted_usd": today["wasted_usd"],
|
|
94
|
+
"wasted_usd": None if provider == "codex" else today["wasted_usd"],
|
|
108
95
|
"anomaly_triggered": today["anomaly_triggered"],
|
|
109
96
|
"anomaly_reasons": list(today["anomaly_reasons"]),
|
|
110
97
|
"baseline_daily_row_count": today["baseline_daily_row_count"],
|
|
@@ -124,7 +111,7 @@ def _day_block(provider, d):
|
|
|
124
111
|
"cache_creation_tokens": d["cache_creation_tokens"],
|
|
125
112
|
"cache_read_tokens": d["cache_read_tokens"],
|
|
126
113
|
"saved_usd": d["saved_usd"],
|
|
127
|
-
"wasted_usd": d["wasted_usd"],
|
|
114
|
+
"wasted_usd": None if provider == "codex" else d["wasted_usd"],
|
|
128
115
|
"net_usd": d["net_usd"],
|
|
129
116
|
"anomaly_triggered": d["anomaly_triggered"],
|
|
130
117
|
"anomaly_reasons": list(d["anomaly_reasons"]),
|
|
@@ -171,6 +158,7 @@ def build_cache_report_wire(
|
|
|
171
158
|
"is_empty": is_empty,
|
|
172
159
|
}
|
|
173
160
|
if provider == "codex":
|
|
161
|
+
out["fourteen_day_efficiency_ratio"] = None
|
|
174
162
|
out["not_applicable"] = dict(CODEX_NOT_APPLICABLE)
|
|
175
163
|
out["anomaly_predicates"] = list(CODEX_PREDICATES)
|
|
176
164
|
# The caller counted anomalies BEFORE inapplicable predicates were
|
|
@@ -1498,6 +1498,38 @@ def normalize_codex_events(
|
|
|
1498
1498
|
# ── mirror pairing + canonical items (§5.3 / §5.2) ────────────────────────────
|
|
1499
1499
|
|
|
1500
1500
|
|
|
1501
|
+
_REASONING_WS_RE = re.compile(r"\s+")
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _reasoning_projected_text(row: CodexNormalizedRow) -> str:
|
|
1505
|
+
"""Normalized projected text of one reasoning row, or ``""``.
|
|
1506
|
+
|
|
1507
|
+
Read from the STORED projection in ``detail_json``, never from
|
|
1508
|
+
``search_thinking``: that column is capped at 16,000 characters and a
|
|
1509
|
+
response item stores ``summary + "\\n" + body`` in it, so it cannot
|
|
1510
|
+
distinguish a title from a title-plus-body.
|
|
1511
|
+
|
|
1512
|
+
Normalization drops the bold wrapper and collapses whitespace, because the
|
|
1513
|
+
two extraction paths disagree about both. A ``response_item`` aggregate
|
|
1514
|
+
keeps its parts' ``**`` wrappers inside one ``summary`` string, while an
|
|
1515
|
+
``event_msg`` part whose text matches the title pattern has already had its
|
|
1516
|
+
wrapper stripped into ``title``.
|
|
1517
|
+
"""
|
|
1518
|
+
if not row.detail_json:
|
|
1519
|
+
return ""
|
|
1520
|
+
try:
|
|
1521
|
+
detail = json.loads(row.detail_json)
|
|
1522
|
+
except (json.JSONDecodeError, TypeError):
|
|
1523
|
+
return ""
|
|
1524
|
+
reasoning = detail.get("reasoning") if isinstance(detail, dict) else None
|
|
1525
|
+
if not isinstance(reasoning, dict):
|
|
1526
|
+
return ""
|
|
1527
|
+
head = reasoning.get("title") or reasoning.get("summary") or ""
|
|
1528
|
+
body = reasoning.get("body") or ""
|
|
1529
|
+
joined = "\n".join(part for part in (head, body) if part)
|
|
1530
|
+
return _REASONING_WS_RE.sub(" ", joined.replace("**", "")).strip()
|
|
1531
|
+
|
|
1532
|
+
|
|
1501
1533
|
def _pair_mirrors_impl(
|
|
1502
1534
|
rows: list[CodexNormalizedRow],
|
|
1503
1535
|
) -> tuple[set[int], dict[int, int]]:
|
|
@@ -1544,6 +1576,82 @@ def _pair_mirrors_impl(
|
|
|
1544
1576
|
paired_response.add(prev_idx)
|
|
1545
1577
|
last_prose_by_kind[row.kind] = j
|
|
1546
1578
|
|
|
1579
|
+
# Reasoning containment (#463 S1 / F5). Exact-digest pairing structurally
|
|
1580
|
+
# cannot collapse the general reasoning case: a response_item reasoning
|
|
1581
|
+
# row's identity is `"\n".join([summary, body])` while an event_msg
|
|
1582
|
+
# agent_reasoning row's is the raw single string, and the two coincide only
|
|
1583
|
+
# when the body is empty and the raw text equals the summary exactly. The
|
|
1584
|
+
# real relation is containment — one aggregate carries the parts that each
|
|
1585
|
+
# arrived earlier as their own event.
|
|
1586
|
+
#
|
|
1587
|
+
# Measured against the production store on 2026-08-02: of 17,149 turned
|
|
1588
|
+
# event_msg reasoning rows, 4,883 already pair by exact digest and ALL
|
|
1589
|
+
# 12,266 that survive are contained in exactly one same-turn aggregate, with
|
|
1590
|
+
# no orphans and nothing present-but-not-contained.
|
|
1591
|
+
#
|
|
1592
|
+
# This runs BESIDE the exact-digest passes rather than replacing them, so no
|
|
1593
|
+
# other mirror kind's behaviour changes. Three properties make it safe, and
|
|
1594
|
+
# each answers a concrete way a simpler rule would delete real content:
|
|
1595
|
+
#
|
|
1596
|
+
# * Per-aggregate, never concatenated. Concatenating a turn's aggregates
|
|
1597
|
+
# admits a match spanning the boundary between two unrelated ones, which
|
|
1598
|
+
# would suppress a part no aggregate actually contains.
|
|
1599
|
+
# * Multiset, not set. An aggregate covers each occurrence of a part once.
|
|
1600
|
+
# Two identical events must not both fold onto an aggregate that carries
|
|
1601
|
+
# that text once — and an aggregate already claimed by the exact-digest
|
|
1602
|
+
# pass above has that occurrence spent, which is why `used` is seeded
|
|
1603
|
+
# from `partners`.
|
|
1604
|
+
# * An explicit partner. `pair_mirror_partners` is the contract search and
|
|
1605
|
+
# find rely on to map a suppressed row to its canonical survivor, so a
|
|
1606
|
+
# containment match names WHICH aggregate owns it — something a
|
|
1607
|
+
# concatenated match could not do.
|
|
1608
|
+
#
|
|
1609
|
+
# A turn with no aggregate loses nothing, and a part genuinely contained in
|
|
1610
|
+
# none is retained.
|
|
1611
|
+
reasoning_turns: dict[str, dict[str, list[int]]] = {}
|
|
1612
|
+
for i, row in enumerate(rows):
|
|
1613
|
+
if row.kind != "reasoning" or row.turn_id is None:
|
|
1614
|
+
continue
|
|
1615
|
+
group = reasoning_turns.setdefault(row.turn_id, {"R": [], "E": []})
|
|
1616
|
+
group["E" if row.record_family == "event_msg" else "R"].append(i)
|
|
1617
|
+
for group in reasoning_turns.values():
|
|
1618
|
+
if not group["R"]:
|
|
1619
|
+
continue
|
|
1620
|
+
aggregates = [(index, _reasoning_projected_text(rows[index]), {})
|
|
1621
|
+
for index in group["R"]]
|
|
1622
|
+
# An aggregate the exact-digest pass already paired has spent one
|
|
1623
|
+
# occurrence of its own text; seed that so the two passes share one
|
|
1624
|
+
# budget instead of each spending it independently.
|
|
1625
|
+
spent = {index for index in partners.values()}
|
|
1626
|
+
for index, text, used in aggregates:
|
|
1627
|
+
if index in spent and text:
|
|
1628
|
+
used[text] = used.get(text, 0) + 1
|
|
1629
|
+
for event_index in group["E"]:
|
|
1630
|
+
if event_index in suppressed:
|
|
1631
|
+
continue
|
|
1632
|
+
part = _reasoning_projected_text(rows[event_index])
|
|
1633
|
+
if not part:
|
|
1634
|
+
continue
|
|
1635
|
+
# Physical order decides, so the earliest aggregate that still has
|
|
1636
|
+
# capacity for this part owns it. An aggregate whose whole text IS
|
|
1637
|
+
# the part is preferred over one that merely contains it, because it
|
|
1638
|
+
# is the more accurate survivor for a search hit to land on.
|
|
1639
|
+
owner = None
|
|
1640
|
+
for index, text, used in aggregates:
|
|
1641
|
+
if text.count(part) <= used.get(part, 0):
|
|
1642
|
+
continue
|
|
1643
|
+
if owner is None:
|
|
1644
|
+
owner = (index, text, used)
|
|
1645
|
+
if text == part:
|
|
1646
|
+
owner = (index, text, used)
|
|
1647
|
+
break
|
|
1648
|
+
if owner is None:
|
|
1649
|
+
continue
|
|
1650
|
+
index, _text, used = owner
|
|
1651
|
+
used[part] = used.get(part, 0) + 1
|
|
1652
|
+
suppressed.add(event_index)
|
|
1653
|
+
partners[event_index] = index
|
|
1654
|
+
|
|
1547
1655
|
return suppressed, partners
|
|
1548
1656
|
|
|
1549
1657
|
|