cctally 1.80.3 → 1.81.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 (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +459 -59
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +55 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_quota.py +81 -4
  41. package/bin/_lib_render.py +19 -5
  42. package/bin/_lib_share.py +25 -0
  43. package/bin/_lib_snapshot_cache.py +8 -0
  44. package/bin/_lib_subscription_weeks.py +16 -2
  45. package/bin/_lib_view_models.py +18 -9
  46. package/bin/cctally +43 -4
  47. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  48. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  49. package/dashboard/static/dashboard.html +2 -2
  50. package/package.json +4 -1
  51. package/dashboard/static/assets/index-B3y14l1o.js +0 -92
  52. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -56,12 +56,18 @@ def _resolve_block_selector(
56
56
  *,
57
57
  block_start: str | None,
58
58
  ago: int | None,
59
+ account_key: "str | None" = None,
59
60
  ) -> dict | None:
60
61
  """Resolve a five-hour-breakdown selector to one ``five_hour_blocks`` row.
61
62
 
62
63
  Returns a dict-mapped ``sqlite3.Row`` (or ``None`` if no block matches).
63
64
  Raises ``ValueError`` on conflicting / malformed input.
64
65
 
66
+ ``account_key`` (#341, spec §3): ``None`` = merged / byte-stable; a real key /
67
+ ``unattributed`` scopes selection to that account's blocks — required because
68
+ ``five_hour_window_key`` is shared across accounts (one physical window can
69
+ own one block per account).
70
+
65
71
  Selector rules (spec §3.1):
66
72
  * Both ``None`` -> most-recent block (highest ``block_start_at``).
67
73
  * ``ago=N`` -> the (N+1)-th most-recent block; ``N=0`` == default.
@@ -74,6 +80,8 @@ def _resolve_block_selector(
74
80
  (cannot derive a unique canonical 5h key from a date alone).
75
81
  """
76
82
  _c = _cctally()
83
+ _acct_pred = "" if account_key is None else " AND account_key = ?"
84
+ _acct_p: tuple = () if account_key is None else (account_key,)
77
85
  if block_start is not None and ago is not None:
78
86
  raise ValueError(
79
87
  "--block-start and --ago are mutually exclusive"
@@ -95,8 +103,9 @@ def _resolve_block_selector(
95
103
  resets_epoch = int(parsed.timestamp()) + 5 * 3600
96
104
  key = _c._canonical_5h_window_key(resets_epoch)
97
105
  row = conn.execute(
98
- "SELECT * FROM five_hour_blocks WHERE five_hour_window_key = ?",
99
- (key,),
106
+ "SELECT * FROM five_hour_blocks WHERE five_hour_window_key = ?"
107
+ + _acct_pred,
108
+ (key,) + _acct_p,
100
109
  ).fetchone()
101
110
  return dict(row) if row else None
102
111
 
@@ -105,12 +114,10 @@ def _resolve_block_selector(
105
114
  if offset < 0:
106
115
  raise ValueError(f"--ago must be non-negative (got {ago})")
107
116
  row = conn.execute(
108
- """
109
- SELECT * FROM five_hour_blocks
110
- ORDER BY block_start_at DESC, id DESC
111
- LIMIT 1 OFFSET ?
112
- """,
113
- (offset,),
117
+ "SELECT * FROM five_hour_blocks"
118
+ + (" WHERE account_key = ?" if account_key is not None else "")
119
+ + " ORDER BY block_start_at DESC, id DESC LIMIT 1 OFFSET ?",
120
+ _acct_p + (offset,),
114
121
  ).fetchone()
115
122
  return dict(row) if row else None
116
123
 
@@ -913,7 +920,7 @@ def _block_is_active(
913
920
 
914
921
 
915
922
  def _latest_seven_day_and_window(
916
- conn: sqlite3.Connection,
923
+ conn: sqlite3.Connection, *, account_key: "str | None" = None,
917
924
  ) -> tuple[float | None, int | None]:
918
925
  """Return ``(latest_7d_percent, latest_5h_window_key)`` from
919
926
  ``weekly_usage_snapshots``.
@@ -923,15 +930,18 @@ def _latest_seven_day_and_window(
923
930
  Either or both elements may be ``None``. Used by
924
931
  ``cmd_five_hour_breakdown`` to override
925
932
  ``seven_day_pct_at_block_end`` on the active row.
933
+
934
+ ``account_key`` (#341, spec §3): ``None`` = merged / byte-stable; a real key /
935
+ ``unattributed`` scopes the latest-snapshot pick to that account.
926
936
  """
937
+ acct_pred = "" if account_key is None else " WHERE account_key = ?"
938
+ acct_p: tuple = () if account_key is None else (account_key,)
927
939
  try:
928
940
  row = conn.execute(
929
- """
930
- SELECT weekly_percent, five_hour_window_key
931
- FROM weekly_usage_snapshots
932
- ORDER BY captured_at_utc DESC, id DESC
933
- LIMIT 1
934
- """
941
+ "SELECT weekly_percent, five_hour_window_key "
942
+ "FROM weekly_usage_snapshots" + acct_pred +
943
+ " ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
944
+ acct_p,
935
945
  ).fetchone()
936
946
  except sqlite3.DatabaseError:
937
947
  return None, None
@@ -998,6 +1008,12 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
998
1008
  # cmd_five_hour_breakdown). Used by the active-predicate to gate
999
1009
  # natural expiration so an idle-past-reset block doesn't render ACTIVE.
1000
1010
  now_utc = _command_as_of()
1011
+ # #341 --account: resolve the render filter (provider=claude). This command
1012
+ # renders from the five_hour_blocks stats table (not the entry cache), so
1013
+ # needs_cache=False. None = merged / byte-stable.
1014
+ acct_key, acct_exit = _c.resolve_account_filter(args, "claude", needs_cache=False)
1015
+ if acct_exit is not None:
1016
+ return acct_exit
1001
1017
  conn = open_db()
1002
1018
  try:
1003
1019
  # Date filter parsing — same convention as cmd_blocks.
@@ -1026,6 +1042,9 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
1026
1042
  until_dt = dt.date.fromisoformat(until_iso) + dt.timedelta(days=1)
1027
1043
  where.append("block_start_at < ?")
1028
1044
  params.append(until_dt.isoformat())
1045
+ if acct_key is not None: # #341: scope blocks to the selected account
1046
+ where.append("account_key = ?")
1047
+ params.append(acct_key)
1029
1048
  clause = ("WHERE " + " AND ".join(where)) if where else ""
1030
1049
 
1031
1050
  # No filter → cap at 50; with filter → unbounded.
@@ -1071,19 +1090,21 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
1071
1090
  # SAME filter set (none here, but kept symmetric for clarity).
1072
1091
  truncated = False
1073
1092
  if cap is not None and len(rows) == cap:
1093
+ # #341: the cap path implies no date filter, but an --account filter
1094
+ # can still apply — probe over the SAME account scope.
1095
+ _probe_pred = "" if acct_key is None else " WHERE account_key = ?"
1096
+ _probe_p: tuple = () if acct_key is None else (acct_key,)
1074
1097
  extra = conn.execute(
1075
- """
1076
- SELECT 1 FROM five_hour_blocks
1077
- ORDER BY block_start_at DESC, id DESC
1078
- LIMIT 1 OFFSET ?
1079
- """,
1080
- (cap,),
1098
+ "SELECT 1 FROM five_hour_blocks" + _probe_pred +
1099
+ " ORDER BY block_start_at DESC, id DESC LIMIT 1 OFFSET ?",
1100
+ _probe_p + (cap,),
1081
1101
  ).fetchone()
1082
1102
  truncated = extra is not None
1083
1103
 
1084
1104
  # Latest live 7d% from the latest weekly_usage_snapshots row, used
1085
1105
  # to fill seven_day_pct_at_block_end on the active row.
1086
- latest_7d, latest_window_key = _latest_seven_day_and_window(conn)
1106
+ latest_7d, latest_window_key = _latest_seven_day_and_window(
1107
+ conn, account_key=acct_key)
1087
1108
 
1088
1109
  # Pre-load credit events for every window_key the rows query
1089
1110
  # returned. Single index-scan over `five_hour_reset_events`;
@@ -1091,11 +1112,14 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
1091
1112
  # JOIN against each block dict. Used by both the text/JSON
1092
1113
  # render path AND the share-output snapshot wiring (spec §5.1.1).
1093
1114
  # Loaded in a single pass — no per-block SELECT.
1115
+ _credit_pred = "" if acct_key is None else " WHERE account_key = ?"
1116
+ _credit_p: tuple = () if acct_key is None else (acct_key,)
1094
1117
  credit_rows = conn.execute(
1095
1118
  "SELECT five_hour_window_key, prior_percent, post_percent, "
1096
1119
  " effective_reset_at_utc "
1097
- " FROM five_hour_reset_events "
1098
- " ORDER BY five_hour_window_key, effective_reset_at_utc"
1120
+ " FROM five_hour_reset_events" + _credit_pred +
1121
+ " ORDER BY five_hour_window_key, effective_reset_at_utc",
1122
+ _credit_p,
1099
1123
  ).fetchall()
1100
1124
  credits_by_window: dict[int, list[dict]] = {}
1101
1125
  for cr in credit_rows:
@@ -1199,13 +1223,12 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
1199
1223
  )
1200
1224
 
1201
1225
  if args.json:
1202
- print(json.dumps(
1203
- _c._five_hour_blocks_to_json(
1204
- block_dicts, since_iso, until_iso,
1205
- cap, truncated, args.breakdown,
1206
- ),
1207
- indent=2,
1208
- ))
1226
+ _fhb_payload = _c._five_hour_blocks_to_json(
1227
+ block_dicts, since_iso, until_iso,
1228
+ cap, truncated, args.breakdown,
1229
+ )
1230
+ _fhb_payload.update(_c.account_json_fields(acct_key)) # #341 R8
1231
+ print(json.dumps(_fhb_payload, indent=2))
1209
1232
  return 0
1210
1233
 
1211
1234
  _c._render_five_hour_blocks_table(block_dicts, args)
@@ -1224,6 +1247,11 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
1224
1247
  # other testing-hook-only commands). Used for the active-block elapsed
1225
1248
  # display below so fixture-pinned harnesses get deterministic output.
1226
1249
  now_utc = _command_as_of()
1250
+ # #341 --account: resolve the render filter (provider=claude). Reads only the
1251
+ # five_hour_* stats tables (not the entry cache), so needs_cache=False.
1252
+ acct_key, acct_exit = _c.resolve_account_filter(args, "claude", needs_cache=False)
1253
+ if acct_exit is not None:
1254
+ return acct_exit
1227
1255
  conn = open_db()
1228
1256
  try:
1229
1257
  try:
@@ -1231,6 +1259,7 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
1231
1259
  conn,
1232
1260
  block_start=args.block_start,
1233
1261
  ago=args.ago,
1262
+ account_key=acct_key,
1234
1263
  )
1235
1264
  except ValueError as e:
1236
1265
  print(f"five-hour-breakdown: {e}", file=sys.stderr)
@@ -1271,14 +1300,14 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
1271
1300
  # renderer can interleave a ``⚡ CREDIT -Xpp @ HH:MM`` divider
1272
1301
  # row between pre- and post-credit milestone segments and JSON
1273
1302
  # consumers see the parallel ``credits[]`` array (Section 5.2).
1303
+ _bd_credit_pred = "" if acct_key is None else " AND account_key = ?"
1304
+ _bd_credit_p: tuple = () if acct_key is None else (acct_key,)
1274
1305
  credit_rows = conn.execute(
1275
- """
1276
- SELECT effective_reset_at_utc, prior_percent, post_percent
1277
- FROM five_hour_reset_events
1278
- WHERE five_hour_window_key = ?
1279
- ORDER BY effective_reset_at_utc ASC
1280
- """,
1281
- (block["five_hour_window_key"],),
1306
+ "SELECT effective_reset_at_utc, prior_percent, post_percent "
1307
+ "FROM five_hour_reset_events "
1308
+ "WHERE five_hour_window_key = ?" + _bd_credit_pred +
1309
+ " ORDER BY effective_reset_at_utc ASC",
1310
+ (block["five_hour_window_key"],) + _bd_credit_p,
1282
1311
  ).fetchall()
1283
1312
  credits_list: list[dict] = [
1284
1313
  {
@@ -1297,7 +1326,8 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
1297
1326
  p_end = block.get("seven_day_pct_at_block_end")
1298
1327
 
1299
1328
  # Live 7d_end on active row.
1300
- latest_7d, latest_window_key = _latest_seven_day_and_window(conn)
1329
+ latest_7d, latest_window_key = _latest_seven_day_and_window(
1330
+ conn, account_key=acct_key)
1301
1331
  is_active = _block_is_active(block, latest_window_key, now_utc)
1302
1332
  if is_active and latest_7d is not None:
1303
1333
  p_end = latest_7d
@@ -1352,15 +1382,14 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
1352
1382
  # ``milestones`` — same shape as the ``credits`` field on
1353
1383
  # ``five-hour-blocks --json`` (§5.1). Stacked credits across
1354
1384
  # distinct 10-min slots produce multiple entries.
1355
- print(json.dumps(
1356
- {
1357
- "schemaVersion": 1,
1358
- "block": block_out,
1359
- "milestones": ms_out,
1360
- "credits": credits_list,
1361
- },
1362
- indent=2,
1363
- ))
1385
+ _bd_payload = {
1386
+ "schemaVersion": 1,
1387
+ "block": block_out,
1388
+ "milestones": ms_out,
1389
+ "credits": credits_list,
1390
+ }
1391
+ _bd_payload.update(_c.account_json_fields(acct_key)) # #341 R8
1392
+ print(json.dumps(_bd_payload, indent=2))
1364
1393
  return 0
1365
1394
 
1366
1395
  # Human-readable header line.
@@ -1493,18 +1522,21 @@ def _backfill_five_hour_blocks(
1493
1522
  # 5h percent. The percent guard is critical: MAX(five_hour_percent)
1494
1523
  # over a NULL-only window is NULL, which would trip the
1495
1524
  # final_five_hour_percent NOT NULL constraint at insert time.
1525
+ # #341: re-materialize one open block per (account_key, window_key). A
1526
+ # shared physical 5h window observed by two accounts yields two distinct
1527
+ # open blocks so rebuild reproduces per-account ownership.
1496
1528
  keys_sql = """
1497
- SELECT DISTINCT five_hour_window_key
1529
+ SELECT DISTINCT five_hour_window_key, account_key
1498
1530
  FROM weekly_usage_snapshots
1499
1531
  WHERE five_hour_window_key IS NOT NULL
1500
1532
  AND five_hour_percent IS NOT NULL
1501
1533
  """
1502
1534
  if only_missing:
1503
1535
  keys_sql += (
1504
- " AND five_hour_window_key NOT IN "
1505
- "(SELECT five_hour_window_key FROM five_hour_blocks)"
1536
+ " AND (five_hour_window_key, account_key) NOT IN "
1537
+ "(SELECT five_hour_window_key, account_key FROM five_hour_blocks)"
1506
1538
  )
1507
- keys = [int(r[0]) for r in conn.execute(keys_sql).fetchall()]
1539
+ keys = [(int(r[0]), r[1]) for r in conn.execute(keys_sql).fetchall()]
1508
1540
 
1509
1541
  now_iso = now_utc_iso()
1510
1542
  now_dt = parse_iso_datetime(now_iso, "now")
@@ -1520,30 +1552,32 @@ def _backfill_five_hour_blocks(
1520
1552
  # See cctally-dev#87.
1521
1553
  conn.execute("BEGIN IMMEDIATE")
1522
1554
  try:
1523
- for key in keys:
1555
+ for key, acct in keys:
1524
1556
  # MIN-captured row defines the immutable block boundary
1525
1557
  # values (deterministic — picking "any in-window row"
1526
1558
  # would be nondeterministic under seconds-level Anthropic
1527
- # ISO jitter that the canonical key collapses).
1559
+ # ISO jitter that the canonical key collapses). Scoped to
1560
+ # (window_key, account_key) so a shared window's per-account
1561
+ # blocks each read their own account's boundary rows (#341).
1528
1562
  min_row = conn.execute(
1529
1563
  """
1530
1564
  SELECT five_hour_resets_at, captured_at_utc, weekly_percent
1531
1565
  FROM weekly_usage_snapshots
1532
- WHERE five_hour_window_key = ?
1566
+ WHERE five_hour_window_key = ? AND account_key = ?
1533
1567
  AND five_hour_percent IS NOT NULL
1534
1568
  ORDER BY captured_at_utc ASC, id ASC LIMIT 1
1535
1569
  """,
1536
- (key,),
1570
+ (key, acct),
1537
1571
  ).fetchone()
1538
1572
  max_row = conn.execute(
1539
1573
  """
1540
1574
  SELECT captured_at_utc, weekly_percent, five_hour_percent
1541
1575
  FROM weekly_usage_snapshots
1542
- WHERE five_hour_window_key = ?
1576
+ WHERE five_hour_window_key = ? AND account_key = ?
1543
1577
  AND five_hour_percent IS NOT NULL
1544
1578
  ORDER BY captured_at_utc DESC, id DESC LIMIT 1
1545
1579
  """,
1546
- (key,),
1580
+ (key, acct),
1547
1581
  ).fetchone()
1548
1582
  if min_row is None or max_row is None:
1549
1583
  continue # defensive — should be unreachable per the keys query
@@ -1574,22 +1608,24 @@ def _backfill_five_hour_blocks(
1574
1608
  cross_row = conn.execute(
1575
1609
  """
1576
1610
  SELECT 1 FROM week_reset_events
1577
- WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
1611
+ WHERE account_key = ?
1612
+ AND unixepoch(effective_reset_at_utc) >= unixepoch(?)
1578
1613
  AND unixepoch(effective_reset_at_utc) <= unixepoch(?)
1579
1614
  LIMIT 1
1580
1615
  """,
1581
- (block_start_at, last_obs),
1616
+ (acct, block_start_at, last_obs),
1582
1617
  ).fetchone()
1583
1618
  if cross_row is None:
1584
1619
  cross_row = conn.execute(
1585
1620
  """
1586
1621
  SELECT 1 FROM weekly_usage_snapshots
1587
1622
  WHERE week_start_at IS NOT NULL
1623
+ AND account_key = ?
1588
1624
  AND unixepoch(week_start_at) > unixepoch(?)
1589
1625
  AND unixepoch(week_start_at) <= unixepoch(?)
1590
1626
  LIMIT 1
1591
1627
  """,
1592
- (block_start_at, last_obs),
1628
+ (acct, block_start_at, last_obs),
1593
1629
  ).fetchone()
1594
1630
  crossed = 1 if cross_row is not None else 0
1595
1631
 
@@ -1624,9 +1660,10 @@ def _backfill_five_hour_blocks(
1624
1660
  total_cost_usd,
1625
1661
  is_closed,
1626
1662
  created_at_utc,
1627
- last_updated_at_utc
1663
+ last_updated_at_utc,
1664
+ account_key
1628
1665
  )
1629
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1666
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1630
1667
  """,
1631
1668
  (
1632
1669
  key,
@@ -1646,6 +1683,7 @@ def _backfill_five_hour_blocks(
1646
1683
  is_closed,
1647
1684
  now_iso,
1648
1685
  now_iso,
1686
+ acct,
1649
1687
  ),
1650
1688
  )
1651
1689
  inserted += cur.rowcount or 0
@@ -1655,8 +1693,9 @@ def _backfill_five_hour_blocks(
1655
1693
  # re-runs (UNIQUE(five_hour_window_key, model|project_path)).
1656
1694
  # Same transaction as the parent INSERT.
1657
1695
  parent_id_row = conn.execute(
1658
- "SELECT id FROM five_hour_blocks WHERE five_hour_window_key = ?",
1659
- (key,),
1696
+ "SELECT id FROM five_hour_blocks "
1697
+ "WHERE five_hour_window_key = ? AND account_key = ?",
1698
+ (key, acct),
1660
1699
  ).fetchone()
1661
1700
  if parent_id_row is not None:
1662
1701
  parent_id = int(parent_id_row["id"])
@@ -1667,9 +1706,9 @@ def _backfill_five_hour_blocks(
1667
1706
  block_id, five_hour_window_key, model,
1668
1707
  input_tokens, output_tokens,
1669
1708
  cache_create_tokens, cache_read_tokens,
1670
- cost_usd, entry_count
1709
+ cost_usd, entry_count, account_key
1671
1710
  )
1672
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1711
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1673
1712
  """,
1674
1713
  [
1675
1714
  (
@@ -1682,6 +1721,7 @@ def _backfill_five_hour_blocks(
1682
1721
  b["cache_read_tokens"],
1683
1722
  b["cost_usd"],
1684
1723
  b["entry_count"],
1724
+ acct,
1685
1725
  )
1686
1726
  for model, b in totals["by_model"].items()
1687
1727
  ],
@@ -1693,9 +1733,9 @@ def _backfill_five_hour_blocks(
1693
1733
  block_id, five_hour_window_key, project_path,
1694
1734
  input_tokens, output_tokens,
1695
1735
  cache_create_tokens, cache_read_tokens,
1696
- cost_usd, entry_count
1736
+ cost_usd, entry_count, account_key
1697
1737
  )
1698
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1738
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1699
1739
  """,
1700
1740
  [
1701
1741
  (
@@ -1708,6 +1748,7 @@ def _backfill_five_hour_blocks(
1708
1748
  b["cache_read_tokens"],
1709
1749
  b["cost_usd"],
1710
1750
  b["entry_count"],
1751
+ acct,
1711
1752
  )
1712
1753
  for project_path, b in totals["by_project"].items()
1713
1754
  ],