cctally 1.106.0 → 1.108.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +147 -6
  3. package/bin/_cctally_cache.py +553 -44
  4. package/bin/_cctally_core.py +316 -151
  5. package/bin/_cctally_dashboard.py +70 -73
  6. package/bin/_cctally_dashboard_envelope.py +119 -95
  7. package/bin/_cctally_dashboard_share.py +1 -7
  8. package/bin/_cctally_db.py +6 -133
  9. package/bin/_cctally_diff.py +15 -8
  10. package/bin/_cctally_doctor.py +162 -43
  11. package/bin/_cctally_five_hour.py +6 -53
  12. package/bin/_cctally_forecast.py +73 -203
  13. package/bin/_cctally_journal.py +305 -243
  14. package/bin/_cctally_milestone_history.py +58 -141
  15. package/bin/_cctally_milestones.py +0 -72
  16. package/bin/_cctally_parser.py +15 -15
  17. package/bin/_cctally_percent_breakdown.py +292 -32
  18. package/bin/_cctally_project.py +3 -5
  19. package/bin/_cctally_quota.py +16 -30
  20. package/bin/_cctally_quota_model.py +160 -27
  21. package/bin/_cctally_record.py +631 -1377
  22. package/bin/_cctally_rederive.py +0 -2
  23. package/bin/_cctally_reporting.py +1 -19
  24. package/bin/_cctally_setup.py +272 -110
  25. package/bin/_cctally_share.py +45 -32
  26. package/bin/_cctally_statusline.py +41 -16
  27. package/bin/_cctally_tui.py +56 -233
  28. package/bin/_cctally_weekrefs.py +404 -317
  29. package/bin/_lib_aggregators.py +7 -2
  30. package/bin/_lib_alerts_payload.py +48 -1
  31. package/bin/_lib_cache_report.py +5 -2
  32. package/bin/_lib_codex_hooks.py +694 -83
  33. package/bin/_lib_credit.py +2 -9
  34. package/bin/_lib_dashboard_sources.py +4 -16
  35. package/bin/_lib_diff_kernel.py +33 -10
  36. package/bin/_lib_doctor.py +361 -24
  37. package/bin/_lib_journal.py +64 -0
  38. package/bin/_lib_meter_rate_change.py +132 -3
  39. package/bin/_lib_pricing.py +66 -5
  40. package/bin/_lib_pricing_check.py +5 -4
  41. package/bin/_lib_quota_model.py +54 -44
  42. package/bin/_lib_record.py +9 -41
  43. package/bin/_lib_rederive.py +0 -8
  44. package/bin/_lib_render.py +19 -80
  45. package/bin/_lib_snapshot_cache.py +15 -198
  46. package/bin/_lib_subscription_weeks.py +209 -135
  47. package/bin/_lib_view_models.py +86 -133
  48. package/bin/cctally +14 -10
  49. package/dashboard/static/assets/ConversationsView-BOSaBRtu.js +72 -0
  50. package/dashboard/static/assets/{DoctorModal-5rvkyksy.js → DoctorModal-CWn3U4Wl.js} +1 -1
  51. package/dashboard/static/assets/ModalRoot-SR070V6c.js +1 -0
  52. package/dashboard/static/assets/{ProjectsDrillPanel-CsR1bjJu.js → ProjectsDrillPanel-QLI9i5mZ.js} +1 -1
  53. package/dashboard/static/assets/{SourceDetailModal-BnTIwt-R.js → SourceDetailModal-pv0wlFxl.js} +1 -1
  54. package/dashboard/static/assets/{UpdateModal-D_RfssnW.js → UpdateModal-D3m8GG6V.js} +3 -3
  55. package/dashboard/static/assets/index-BHu4mxd8.css +1 -0
  56. package/dashboard/static/assets/index-CIWsbux3.js +13 -0
  57. package/dashboard/static/assets/{outlineNavigation-C7J9lhGB.js → outlineNavigation-CJvKqmLV.js} +1 -1
  58. package/dashboard/static/assets/useKeymap-ffqsS5G0.js +1 -0
  59. package/dashboard/static/dashboard.html +3 -3
  60. package/package.json +1 -3
  61. package/bin/_lib_credit_identity.py +0 -159
  62. package/bin/_lib_credit_selection.py +0 -342
  63. package/dashboard/static/assets/ConversationsView-Dst33C0n.js +0 -72
  64. package/dashboard/static/assets/ModalRoot-BJH02VzE.js +0 -1
  65. package/dashboard/static/assets/index-rzXw99Hy.js +0 -13
  66. package/dashboard/static/assets/index-u3wLfjt2.css +0 -1
  67. package/dashboard/static/assets/useKeymap-B3KCc_c7.js +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,74 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.108.0] - 2026-09-05
9
+
10
+ ### Added
11
+ - Codex reporting prices `gpt-6-astra` from OpenAI's published standard, cached-input, long-context and Fast-mode rates instead of the legacy `gpt-5` fallback.
12
+ - `cctally alerts test` gains `--axis quota` and `--axis meter-rate-change`, so you can rehearse those two notifications instead of waiting for a real crossing or rate change. `--threshold` does not apply to `meter-rate-change`.
13
+ - `cctally percent-breakdown` and `cctally report --detail` name a run of percent thresholds that one observation recorded, and print `observation_gap` where the marginal cost showed a bare `n/a`. `--json` adds `marginalCostWithheldCause`.
14
+ - On an install with more than one account for a provider, every alert toast names the account it belongs to, as the dashboard's Recent Alerts list already did.
15
+ - `cctally doctor` gains `hooks.codex_liveness_7d`, which FAILs when an enabled Codex hook has not succeeded in seven days. It reads the per-root success markers, so log rotation cannot hide a hook that stopped firing.
16
+
17
+ ### Changed
18
+ - `cctally weekly`, `cctally report` and `cctally project` render a week credited twice as three rows, one per billing cycle. A dictionary keyed on the week alone now keeps only the last cycle; key on the week and its start instant together.
19
+ - `cctally weekly`, `cctally report` and `cctally project` start the first cycle of a week whose boundary moved and was then credited at the moved boundary. The previous week keeps its own spend, which was counted into the credited week.
20
+ - Upgrading from 1.107.0 or earlier rebuilds the disposable stats index once, because a weekly reset now records the observation it came from and a rate change records its evidence. Commands report the rebuild until it finishes.
21
+ - A metering-rate change recorded before this upgrade reports no evidence rather than inventing any.
22
+ - A weekly credit is recorded at the exact second Anthropic issued it rather than the top of that hour, so a reading captured earlier in the same hour stays in the cycle it belongs to.
23
+ - A pre-credit reading captured before a second weekly credit now survives and is shown inside the earlier cycle, because each credit's cleanup is scoped to its own instant instead of reaching back past the previous credit.
24
+ - `cctally doctor` now reads Codex's own hook trust record and FAILs when the cctally handler is disabled or was never trusted in Codex `/hooks`, instead of reporting it as installed.
25
+ - `cctally doctor` warns when the Codex handler changed after Codex last recorded a trust decision about it, and when that record cannot be read at all.
26
+ - `cctally setup` refuses at exit 1 when reconciling the Codex handler would land it on a trust decision Codex recorded about a different handler. The message names the file, the slots and the fix.
27
+ - When Codex's trust record changes mid-run, the `cctally setup` refusal names each Codex hooks file it had already rewritten and the dated backup of its previous contents. `--json` adds `changed_hooks_backups`.
28
+ - `cctally doctor` states how many Codex roots are installed beside how many are enabled, so two installed-but-untrusted handlers no longer read as `0/2 root(s) enabled` alone.
29
+ - `cctally setup` recognizes a Codex handler written by a different install channel and collapses it into one canonical handler, instead of adding a second one beside it.
30
+ - `cctally setup --json` moves to `schema_version: 2`: the `installed_review_required` state is retired, and per-root `changes` becomes a per-event object with separate added, removed and unchanged counts.
31
+ - `cctally setup --dry-run` and the applied run now report the same added, removed and unchanged Codex handler counts for the same input.
32
+ - The dashboard and the TUI stop treating a Codex handler as trusted merely because it is present, and re-check that trust when `config.toml` changes, so a hook disabled while either was running no longer certifies stale conversation cost.
33
+ - A freshly installed Codex handler reports as untrusted, and `cctally doctor` FAILs, until you approve it in Codex `/hooks`. Until then the dashboard and the TUI walk every Codex session on each refresh.
34
+ - Two `cctally setup` messages read as ordinary English again: the orphaned `[hooks.state]` remedy says to remove the entry from the file by hand, and a moved trust slot says the handler would move from one key to the other.
35
+
36
+ ### Fixed
37
+ - A percent milestone crossed on a week that holds two recorded resets is listed again. It was filed against one cycle while `cctally percent-breakdown` listed another, so the crossing appeared under neither.
38
+ - Every view of a credited week picks the cycle the week is currently in, not the reset written last. `cctally diff`, the TUI's per-percent modal and the dashboard's milestone list could each name an earlier cycle.
39
+ - A weekly threshold notification names the billing cycle that crossed. On a week credited more than once it read `Week starting Jun 05` for every cycle, naming all of them at once; an uncredited week reads exactly as before.
40
+ - A second Anthropic usage reset inside one subscription week now registers. Every reset in a week shared one slot, so the first held it and later ones were discarded, leaving the reported percentage at the pre-reset high-water mark.
41
+ - A weekly reset observed while cctally is interrupted is no longer lost, and one zero reading can no longer confirm itself into a credit. The pending-reset state moved into the database and commits or rolls back with the reset it fired.
42
+ - A stale pre-credit reading that a credit removed stays removed after `cctally db rebuild --db stats`. The removal is recorded, so a rebuild no longer restores the reading and holds the reported percentage at the pre-credit high.
43
+ - An idle session no longer holds the reported weekly percentage at a stale value. It re-renders its status line from a cached rate-limit block, and that block is now ignored in full once its five-hour window has closed.
44
+ - A severe metering-rate drop now raises a red alert toast instead of the amber that made it look like a smaller drop, and the toast's rate chip matches the colour Recent Alerts already showed for the same event.
45
+ - A metering-rate change detected while the quota calibration was withheld now records which calibration was withheld and how many baseline days were missing, instead of pointing you at a fitted budget that may never have been produced.
46
+ - A metering-rate change whose notification failed and is retried later now carries that same disclosure, because it is read back from the recorded change rather than re-derived from a later run.
47
+ - A metering-rate toast now names the calibration that was withheld and why, instead of pointing you at a fitted budget that may not exist. An ordinary rate change reads exactly as it did before.
48
+ - Recent Alerts shows the evidence behind a metering-rate change: the withheld calibration, the detector's inputs, the composition provenance, and how many baseline days were withheld.
49
+ - An alert toast on a multi-account install states how to dismiss it again, on its own line below the alert's own text rather than above it, so a screen reader reads the alert before it reads how to close it.
50
+ - Alert toasts are usable from the keyboard: press Enter or Space to dismiss one, or to activate its own button and open the window it names. A focused toast states the alert before how to close it, and dismissing one restores focus.
51
+ - The dashboard's Daily panel reads `loading` while it hydrates, instead of reporting the 30-day window as `withheld` over a loading skeleton.
52
+ - The dashboard's Trend panel states its title in full on a phone. The week and cycle counts moved to the wrapping sub-line the other cards use, so the title is no longer cut off mid-word.
53
+ - The dashboard's Daily panel prints its total and peak-day amounts in full on a narrow window. Below about 400px the two summary columns stack instead of cutting the dollar figures off.
54
+ - An install upgrading from a version that predates the append-only journal no longer records a second copy of a reset it already had, so a credited week keeps its own cycles instead of gaining an extra one.
55
+
56
+ ## [1.107.0] - 2026-09-04
57
+
58
+ ### Changed
59
+ - The same-window weekly credit model introduced in 1.106.0 is reverted. A week is anchored to its billing cycle again, and a recorded credit no longer rewrites the week in place or withholds the `$/1%` figure.
60
+ - Upgrading from 1.106.0 rebuilds the disposable stats index once. On a large store that took about a minute here, and commands report that a rebuild is running until it finishes.
61
+ - `claude-fable-5-1` and `claude-mythos-5-1` each count as their own model family in the weekly quota model, instead of being pooled with the model they follow. Your quota calibration refits once on the next `cctally quota` run.
62
+ - `cctally doctor` separates the one refused-pricing-write state that restarting cctally cannot clear — a recorded pricing date it cannot read — from the ordinary one, and names the step that does clear it.
63
+ - A shared `cctally weekly` artifact labels each row by the date its cycle began, matching the terminal table and the dashboard. On a week Anthropic reset early, that label and the artifact's stated period both move forward.
64
+ - The dashboard's Projects panel counts a credited week as the two billing cycles it is, so its week grid covers fewer calendar days for the same week count.
65
+ - The `cctally weekly` and `cctally report` artifacts, the dashboard's Weekly panel footer and the Weekly modal title all count billing cycles rather than weeks, because a credited week supplies two rows. `Avg %/wk` is now `Avg %/cycle`.
66
+
67
+ ### Fixed
68
+ - `cctally weekly` renders a week credited in place as two rows, one per billing cycle, matching `cctally report`. The cycle before the credit had no row at all, and its spend was missing from the table and from `--json` totals.
69
+ - The dashboard's Weekly panel and the TUI show both cycles of a credited week from one source, so a week's two rows can no longer disagree with the panel total beside them.
70
+ - `cctally project` counts a credited week as the two billing cycles it is, so `--weeks N` spans a shorter calendar range there. `attributedUsedPercent` rises, because the cycle before the credit matches its usage snapshot again.
71
+ - A `cctally dashboard` left running across a pricing update can no longer overwrite corrected conversation cost from the table it started with. It keeps ingesting, your conversations stay listed, and `cctally doctor` says to restart it.
72
+ - `cctally doctor` reports a refused conversation-cost write from any process that hit it, including short-lived ones like the status line and `cctally daily` that never open the conversation store.
73
+ - `cctally cache-sync --rebuild` says it did nothing when it may not rewrite conversation cost, instead of reporting `0 processed` and exiting 0. It names both reasons: a stored pricing table newer than this one, or one that is not a date.
74
+ - `cctally doctor`'s refused-pricing-write warning clears once cctally is running current with the store, including after a refused `cctally cache-sync --rebuild`. That refusal previously left a warning the documented remedy could not clear.
75
+
8
76
  ## [1.106.0] - 2026-09-03
9
77
 
10
78
  ### Changed
@@ -252,9 +252,16 @@ def _alert_label_prefix(axis: str, account_key: "str | None",
252
252
  install's alert text stays byte-identical to today.
253
253
 
254
254
  Delegates the R8 gate + label precedence to the single-definition helpers
255
- ``_cctally_account.real_account_count`` / ``account_label`` (P2-CQ1) so the
256
- ">1 real account" trigger and the key->label map have exactly one home; this
257
- wrapper only opens the RO connection and stays best-effort/never-raise.
255
+ ``_cctally_account.provider_is_decorated`` / ``account_label`` (P2-CQ1) so
256
+ the ">1 real account" trigger and the key->label map have exactly one home;
257
+ this wrapper only opens the RO connection and stays best-effort/never-raise.
258
+
259
+ #702: the gate is the canonical predicate itself, negated at the call site.
260
+ This site previously inverted the underlying counter by hand
261
+ (``real_account_count(conn, vendor) <= 1``), which is a second written form
262
+ of the R8 threshold — the arrangement that lets the two spellings disagree
263
+ if the threshold ever moves. ``provider_is_decorated`` is ``> 1``, so the
264
+ suppressed branch is ``not provider_is_decorated(...)``.
258
265
 
259
266
  #697: the VENDOR comes from the payload's own ``provider`` when it carries
260
267
  one, and from ``_AXIS_VENDOR`` otherwise. The metering-rate-change family
@@ -288,7 +295,7 @@ def _alert_label_prefix(axis: str, account_key: "str | None",
288
295
  connect=lambda p: _sq.connect(f"file:{p}?mode=ro", uri=True),
289
296
  )
290
297
  try:
291
- if _cctally_account.real_account_count(conn, vendor) <= 1:
298
+ if not _cctally_account.provider_is_decorated(conn, vendor):
292
299
  return ""
293
300
  # #416 §6: population-aware, so two accounts that auto-label to
294
301
  # one email do not print the same alert prefix.
@@ -400,8 +407,27 @@ def _dispatch_alert_notification(
400
407
  # rate transition has no percentage threshold for `severity_for` to
401
408
  # map. Reading it verbatim is what keeps the three tiers meaningful
402
409
  # here; falling through would floor every transition at `info`.
410
+ #
411
+ # #750 S2: the vocabulary is RESOLVED from the kernel's own tuple
412
+ # rather than restated as a literal. A restated literal makes this the
413
+ # second written form of the same rule, which is exactly the
414
+ # arrangement #747 closed on the envelope: a fourth member added to
415
+ # `RATE_CHANGE_SEVERITIES`, to the TypeScript tuple and to the CSS
416
+ # passes every parity test and is still clamped to `info` here, so the
417
+ # OS notification's urgency and the trailing `alerts.log` column are
418
+ # wrong while the suite is green.
419
+ #
420
+ # Guarded the same way `_cctally_dashboard_envelope` guards its copy,
421
+ # and an unresolvable kernel degrades to NO clamp. This function's
422
+ # contract is that it never raises, and clamping against an empty
423
+ # vocabulary would send every correct severity to `info`.
403
424
  severity = str(payload.get("severity") or "info")
404
- if severity not in ("info", "warn", "alarm"):
425
+ try:
426
+ _severities = frozenset(
427
+ _load_lib("_lib_meter_rate_change").RATE_CHANGE_SEVERITIES)
428
+ except Exception: # noqa: BLE001
429
+ _severities = None
430
+ if _severities is not None and severity not in _severities:
405
431
  severity = "info"
406
432
  else:
407
433
  try:
@@ -484,6 +510,54 @@ def _dispatch_alert_notification(
484
510
  return status
485
511
 
486
512
 
513
+ def _synthetic_instant_days_ago(days: int) -> str:
514
+ """A timezone-aware ISO instant `days` before now (#699).
515
+
516
+ Negative goes forward, which is what a quota reset needs. Both non-registry
517
+ synthetics require real instants rather than placeholders: the rate-change
518
+ body renders an effective date, and the quota body renders a reset.
519
+ """
520
+ moment = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
521
+ return moment.isoformat().replace("+00:00", "Z")
522
+
523
+
524
+ def _test_alert_account_key(vendor: str) -> str:
525
+ """A REAL account key for `vendor`, else the vendor-wide sentinel (#699).
526
+
527
+ This is what makes the R8 `[<label>]` prefix observable from
528
+ `alerts test`: on a decorated install the synthetic carries a key the
529
+ registry can label, and on a single-account install it falls back to `*`,
530
+ which `_alert_label_prefix` suppresses — so that install's output stays
531
+ byte-identical, exactly as R8 requires.
532
+
533
+ Best-effort and never-raise, following `_alert_label_prefix`: a missing
534
+ database, an unreadable registry or an empty one all degrade to the
535
+ sentinel rather than turning a rehearsal into an error path.
536
+ """
537
+ try:
538
+ import sqlite3 as _sq
539
+ import _cctally_account
540
+ import _cctally_store
541
+ db_path = _cctally_core.DB_PATH
542
+ if not db_path.exists():
543
+ return _lib_accounts.VENDOR_WIDE
544
+ conn = _cctally_store.stats_open_guarded(
545
+ db_path,
546
+ connect=lambda p: _sq.connect(f"file:{p}?mode=ro", uri=True),
547
+ )
548
+ try:
549
+ for row in _cctally_account.load_accounts(conn, vendor):
550
+ key = str(row.get("account_key") or "")
551
+ if key and key not in (_lib_accounts.UNATTRIBUTED,
552
+ _lib_accounts.VENDOR_WIDE):
553
+ return key
554
+ finally:
555
+ conn.close()
556
+ except Exception: # noqa: BLE001
557
+ return _lib_accounts.VENDOR_WIDE
558
+ return _lib_accounts.VENDOR_WIDE
559
+
560
+
487
561
  def cmd_alerts_test(args: argparse.Namespace) -> int:
488
562
  """Send a synthetic test alert through the dispatch pipeline.
489
563
 
@@ -510,9 +584,28 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
510
584
  axis = "codex_budget"
511
585
  elif args.axis == "projected":
512
586
  axis = "projected"
587
+ elif args.axis == "quota":
588
+ axis = "quota"
589
+ elif args.axis == "meter-rate-change":
590
+ axis = "meter_rate_change"
513
591
  else:
514
592
  axis = "five_hour"
515
- threshold = int(args.threshold)
593
+ # #699: `--threshold` now defaults to None so that "supplied" is
594
+ # distinguishable from "defaulted". Every axis that takes one still gets 90
595
+ # when it is omitted, which keeps the existing surface byte-identical.
596
+ raw_threshold = getattr(args, "threshold", None)
597
+ if axis == "meter_rate_change" and raw_threshold is not None:
598
+ # Exit 2 is native-usage per docs/cli-contract.md. The message names
599
+ # BOTH the axis and the flag: a refusal that does not say what failed
600
+ # costs the reader more than one that gives no reason at all.
601
+ print(
602
+ "cctally: --axis meter-rate-change does not accept --threshold; "
603
+ "a metering-rate change has no percentage threshold and carries "
604
+ "an explicit severity instead",
605
+ file=sys.stderr,
606
+ )
607
+ return 2
608
+ threshold = 90 if raw_threshold is None else int(raw_threshold)
516
609
  # --threshold range stays [1, 100] (F5): the cap is axis-uniform with the
517
610
  # existing weekly/5h thresholds. Over-budget tiers (>100%) are a v2
518
611
  # deferral, not an oversight — see spec §2 (F5).
@@ -603,6 +696,54 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
603
696
  denominator=denominator,
604
697
  week_start_at=dt.date.today().isoformat(),
605
698
  )
699
+ elif axis == "meter_rate_change":
700
+ # #699: the family is deliberately NOT in `AXIS_REGISTRY`, so there is
701
+ # no `_build_alert_payload_*` helper for it. The synthetic descriptor
702
+ # is built HERE and passed through the real
703
+ # `_lib_meter_rate_change.alert_payload`, which is what keeps that
704
+ # kernel clock-, database- and policy-free while still rehearsing the
705
+ # exact payload production dispatches.
706
+ import _lib_meter_rate_change as _mrc
707
+ previous, new = 2_442_620.0, 1_665_096.0
708
+ effective = _synthetic_instant_days_ago(7)
709
+ payload = _mrc.alert_payload(_mrc.RateChangeTransition(
710
+ provider="claude",
711
+ account_key=_test_alert_account_key("claude"),
712
+ effective_from=effective,
713
+ previous_units_per_point=previous,
714
+ new_units_per_point=new,
715
+ # Computed by the real kernel rather than hardcoded, so a change
716
+ # to the severity boundaries is rehearsed here too. These rates
717
+ # are a 31.8% drop — the maintainer's own observed transition —
718
+ # which lands on `alarm`.
719
+ severity=_mrc.transition_severity(previous, new),
720
+ detected_at=now_utc_iso(),
721
+ # The #690 disclosure, so the rehearsal exercises the withheld
722
+ # copy rather than only the ordinary path.
723
+ withholding_status="unsupported-model-mix",
724
+ detector_input_causes='["unsupported-composition"]',
725
+ composition_provenance='["forecast-aggregate"]',
726
+ baseline_withheld_days=3,
727
+ ))
728
+ elif axis == "quota":
729
+ # #699: also outside `AXIS_REGISTRY`, but this one HAS a payload
730
+ # builder, so the synthetic goes through it unchanged. `source` is
731
+ # `codex` because that is the only vendor this family observes, and
732
+ # `_AXIS_VENDOR` maps the axis to the same vendor for the R8 lookup.
733
+ payload = _build_alert_payload_quota(
734
+ source="codex",
735
+ source_root_key="default",
736
+ logical_limit_key="weekly",
737
+ observed_slot=_synthetic_instant_days_ago(2),
738
+ window_minutes=10080,
739
+ resets_at_utc=_synthetic_instant_days_ago(-5),
740
+ threshold=threshold,
741
+ kind="weekly",
742
+ crossed_at_utc=now_utc_iso(),
743
+ qualifying_percent=float(threshold),
744
+ projected_percent=min(100.0, float(threshold) + 5.0),
745
+ account_key=_test_alert_account_key("codex"),
746
+ )
606
747
  else:
607
748
  payload = _build_alert_payload_five_hour(
608
749
  threshold=threshold,