cctally 1.96.2 → 1.98.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.
@@ -27,6 +27,20 @@ import hashlib
27
27
  RETAINED_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch", "op"})
28
28
 
29
29
 
30
+ def selector_slot(record):
31
+ """Return one position-preserving input slot for the shared selector.
32
+
33
+ Every successfully decoded journal line consumes one sequence number in
34
+ ``resolve_effective_events``. Decision records therefore stay decoded,
35
+ while observations and other irrelevant records become ``None`` rather
36
+ than being dropped (which renumbers durable protocol fingerprints) or kept
37
+ as dictionaries (which makes memory follow observation volume).
38
+ """
39
+ if record.get("t") in RETAINED_RECORD_TYPES:
40
+ return record
41
+ return None
42
+
43
+
30
44
  class LastSeenAccumulator:
31
45
  """Reproduce `_derive_account_last_seen`'s contribution set from a stream.
32
46
 
package/bin/_lib_share.py CHANGED
@@ -381,7 +381,7 @@ PALETTE_LIGHT = {
381
381
  "#dc2626", # red-600
382
382
  "#0891b2", # cyan-600
383
383
  ),
384
- "ref_warn": "#d97706", # amber-600
384
+ "ref_warn": "#b45309", # amber-700
385
385
  "ref_alarm": "#dc2626", # red-600
386
386
  "table_header_bg": "#f3f4f6",
387
387
  "table_row_alt": "#f9fafb",
@@ -903,7 +903,10 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
903
903
  seg_bot_y = iy + scale_y(y_running)
904
904
  seg_h = seg_bot_y - seg_top_y
905
905
  color = series_palette[k_idx % len(series_palette)]
906
- elements.append(svg_rect(bx, seg_top_y, bar_w, seg_h, fill=color))
906
+ elements.append(svg_rect(
907
+ bx, seg_top_y, bar_w, seg_h,
908
+ fill=color, stroke=palette["bg"],
909
+ ))
907
910
  y_running += seg_v
908
911
  else:
909
912
  by = iy + scale_y(p.y_value)
@@ -1965,7 +1968,9 @@ def _merge_inventories(
1965
1968
  # original project labels.
1966
1969
  #
1967
1970
  # Half two — unambiguous classes, scanned document-wide. Only identifier
1968
- # classes that cannot plausibly occur as legitimate artifact content.
1971
+ # classes that cannot plausibly occur as legitimate artifact content. The two
1972
+ # reveal-basename exceptions are masked at typed project sites before the
1973
+ # complete document reaches this unchanged detector, then restored afterward.
1969
1974
  #
1970
1975
  # Original project labels are deliberately NOT in half two. A project
1971
1976
  # legitimately named `cctally` collides with the static branding string this
@@ -2177,11 +2182,10 @@ def _scan_forbidden_classes(text: str) -> "list[tuple[str, str]]":
2177
2182
  """Return `(class label, matched value)` per unambiguous class in `text`.
2178
2183
 
2179
2184
  The matched value is carried out of the scan, not just the class name.
2180
- Naming only the class leaves the user nothing to act on: the accepted
2181
- limitation in `docs/share-gotchas.md` is that a project whose basename is
2182
- itself a canonical UUID or a 32-character hex token cannot be rendered in
2183
- reveal mode, and "canonical UUID" alone does not tell that user which of
2184
- their directories to rename.
2185
+ Naming only the class leaves the user nothing to act on when an untyped
2186
+ field leaks a value. The narrow reveal-basename exception is handled by
2187
+ provenance-aware masking before a complete document reaches this scanner;
2188
+ direct scanner calls remain fail-closed.
2185
2189
  """
2186
2190
  findings: list[tuple[str, str]] = []
2187
2191
  match = _UUID_RE.search(text)
@@ -2238,11 +2242,12 @@ def _verify_output(
2238
2242
  handler's exception converter turns it into the generic 500 envelope and
2239
2243
  the CLI converts it to a stderr refusal and exit 3.
2240
2244
 
2241
- Takes NO privacy mode. Both halves are mode-independent — half one
2242
- compares against the allowlist preparation itself built under whichever
2243
- mode was asked for, and half two's classes are forbidden in reveal mode
2244
- too, because reveal discloses a project's basename and never its path.
2245
- The parameter existed and was read by nothing.
2245
+ Takes NO privacy mode. Both halves are mode-independent — half one compares
2246
+ against the allowlist preparation itself built under whichever mode was
2247
+ asked for, while the entry points mask the two provenance-qualified reveal
2248
+ basename shapes before calling this unchanged document-wide detector. A
2249
+ direct call still rejects every forbidden class. The former mode parameter
2250
+ was read by nothing.
2246
2251
  """
2247
2252
  # Half one — provenance. Every project display value the prepared snapshot
2248
2253
  # carries must be one preparation was allowed to write. This catches a
@@ -2280,6 +2285,127 @@ def _verify_output(
2280
2285
  )
2281
2286
 
2282
2287
 
2288
+ def _reveal_basename_token(label: str) -> "str | None":
2289
+ """Return the narrowly exemptible token from one raw project label.
2290
+
2291
+ The exception is for a directory whose BASENAME is exactly a UUID or a
2292
+ source-root-shaped token. A label that merely contains either shape is not
2293
+ eligible. Parent qualifiers added later by `disambiguate_basenames` do not
2294
+ broaden this decision because eligibility is derived from the raw label.
2295
+ """
2296
+ segments = _path_segments(label)
2297
+ basename = segments[-1] if segments else label
2298
+ if _UUID_RE.fullmatch(basename) or _SOURCE_ROOT_KEY_RE.fullmatch(basename):
2299
+ return basename
2300
+ return None
2301
+
2302
+
2303
+ def _width_safe_hex_mask(token: str, ordinal: int) -> str:
2304
+ """Build a detector-safe token that never under-reserves SVG width.
2305
+
2306
+ Helvetica's ``g``/``h`` are each 556 units wide: equal to digits and at
2307
+ least as wide as every lowercase hex letter. ``H``/``N`` are each 722
2308
+ units wide, at least as wide as every uppercase hex letter. Hyphens retain
2309
+ their real width. The binary ordinal makes equal-shaped UUID/source-root
2310
+ masks unique without introducing another hexadecimal run. NUL sentinels
2311
+ keep the placeholder distinct from every ordinary renderer string; they
2312
+ exist only in the intermediate Python value and add safe width headroom.
2313
+ """
2314
+ chars = [
2315
+ char if char == "-" else ("H" if char.isupper() else "g")
2316
+ for char in token
2317
+ ]
2318
+ slots = [index for index, char in enumerate(chars) if char != "-"]
2319
+ bits = f"{ordinal + 1:b}"
2320
+ for index, bit in zip(reversed(slots), reversed(bits)):
2321
+ if token[index].isupper():
2322
+ chars[index] = "N" if bit == "1" else "H"
2323
+ else:
2324
+ chars[index] = "h" if bit == "1" else "g"
2325
+ return "\x00" + "".join(chars) + "\x00"
2326
+
2327
+
2328
+ def _mask_reveal_basename_mapping(
2329
+ snaps: "Sequence[ShareSnapshot]",
2330
+ mapping: "dict[_ProjectAnonKey, str]",
2331
+ *,
2332
+ reveal_projects: bool,
2333
+ reserved_strings: "Sequence[str]" = (),
2334
+ ) -> "tuple[dict[_ProjectAnonKey, str], tuple[tuple[str, str], ...]]":
2335
+ """Mask only provenance-qualified UUID/hex basenames before rendering.
2336
+
2337
+ This is deliberately a mapping transformation, not a document allowlist.
2338
+ Untyped fields retain their bytes and therefore remain visible to the
2339
+ unchanged document-wide detector. The returned substitutions are applied
2340
+ only after that detector accepts the complete document.
2341
+ """
2342
+ if not reveal_projects:
2343
+ return mapping, ()
2344
+
2345
+ raw_tokens: dict[_ProjectAnonKey, set[str | None]] = {}
2346
+ reserved: set[str] = set(mapping.values()) | set(reserved_strings)
2347
+ for snap in snaps:
2348
+ def _record_token(site: _ProjectDisplaySite) -> "str | None":
2349
+ if site.keyed and site.value:
2350
+ key = _project_anon_key(site.value, site.identity)
2351
+ raw_tokens.setdefault(key, set()).add(
2352
+ _reveal_basename_token(site.value))
2353
+ return site.value
2354
+
2355
+ # Visit every typed site directly. `_project_label_by_key` is a display
2356
+ # helper and intentionally keeps only the first label per identity;
2357
+ # security eligibility must instead hear every raw label.
2358
+ _map_project_display(snap, _record_token)
2359
+ _walk_strings(snap, reserved, set())
2360
+
2361
+ eligible_by_key: dict[_ProjectAnonKey, str] = {}
2362
+ eligible: dict[str, str] = {}
2363
+ for key, displayed in mapping.items():
2364
+ candidates = raw_tokens.get(key, set())
2365
+ token = next(iter(candidates)) if len(candidates) == 1 else None
2366
+ if token is not None and token in displayed:
2367
+ eligible_by_key[key] = token
2368
+ eligible.setdefault(token, "")
2369
+ if not eligible:
2370
+ return mapping, ()
2371
+
2372
+ substitutions: list[tuple[str, str]] = []
2373
+ ordinal = 0
2374
+ for token in sorted(eligible):
2375
+ while True:
2376
+ placeholder = _width_safe_hex_mask(token, ordinal)
2377
+ ordinal += 1
2378
+ if all(placeholder not in value for value in reserved):
2379
+ break
2380
+ eligible[token] = placeholder
2381
+ substitutions.append((placeholder, token))
2382
+ reserved.add(placeholder)
2383
+
2384
+ masked: dict[_ProjectAnonKey, str] = {}
2385
+ for key, displayed in mapping.items():
2386
+ token = eligible_by_key.get(key)
2387
+ placeholder = eligible.get(token or "")
2388
+ masked[key] = (
2389
+ displayed.replace(token, placeholder, 1)
2390
+ if token is not None and placeholder is not None
2391
+ else displayed
2392
+ )
2393
+ return masked, tuple(substitutions)
2394
+
2395
+
2396
+ def _verify_and_restore_reveal_basenames(
2397
+ text: str,
2398
+ *,
2399
+ inventory: SensitiveInventory,
2400
+ substitutions: "Sequence[tuple[str, str]]",
2401
+ ) -> str:
2402
+ """Verify the masked complete document, then restore approved basenames."""
2403
+ _verify_output(text, inventory=inventory)
2404
+ for placeholder, token in substitutions:
2405
+ text = text.replace(placeholder, token)
2406
+ return text
2407
+
2408
+
2283
2409
  def _encode_probe_identity_key() -> str:
2284
2410
  """A syntactically valid IdentityV1 key, for the detector's own tests.
2285
2411
 
@@ -3633,7 +3759,7 @@ def _print_stylesheet() -> str:
3633
3759
 
3634
3760
  THE DATA COLOURS ARE MAPPED TOO (#503 S2 review F1/F2), because they
3635
3761
  are not legible on white: dark `ref_warn` #fbbf24 measures 1.67:1
3636
- where its light counterpart #d97706 measures 3.19:1, dark `ref_alarm`
3762
+ where its light counterpart #b45309 measures 5.02:1, dark `ref_alarm`
3637
3763
  #f87171 measures 2.77:1 against 4.83:1, and dark `series_primary`
3638
3764
  #60a5fa measures 2.54:1 against 5.17:1. A reference LABEL is mapped
3639
3765
  alongside its LINE, so the colour that encodes severity stays paired;
@@ -3708,18 +3834,20 @@ def _print_stylesheet() -> str:
3708
3834
  """
3709
3835
  light = PALETTE_LIGHT
3710
3836
  dark = PALETTE_DARK
3711
- # Data roles, mapped index-wise so a re-picked palette stays paired.
3712
- data_pairs = [(dark[role], light[role])
3713
- for role in ("series_primary", "series_secondary",
3714
- "ref_warn", "ref_alarm")]
3715
- data_pairs += list(zip(dark["series_palette"], light["series_palette"]))
3716
- seen: set = set()
3837
+ # Fill and stroke roles are mapped separately. A dark source value may
3838
+ # intentionally mean different things in those channels: #fbbf24 is both
3839
+ # the warning stroke/text and the fourth stacked-series fill, whose light
3840
+ # counterparts are #b45309 and #d97706 respectively (#524). Deduplicating
3841
+ # them in one value-only map silently assigns one role the other's target.
3842
+ fill_pairs = [(dark[role], light[role])
3843
+ for role in ("series_primary", "series_secondary")]
3844
+ fill_pairs += list(zip(dark["series_palette"], light["series_palette"]))
3717
3845
  fills = []
3718
- strokes = []
3719
- for dark_value, light_value in data_pairs:
3720
- if dark_value in seen:
3846
+ fill_seen: set = set()
3847
+ for dark_value, light_value in fill_pairs:
3848
+ if dark_value in fill_seen:
3721
3849
  continue
3722
- seen.add(dark_value)
3850
+ fill_seen.add(dark_value)
3723
3851
  # `rect` is the only element this kernel currently fills with a
3724
3852
  # data colour, but `path` and `polyline` are filled elements too,
3725
3853
  # so a future filled area chart would otherwise print dark. Named
@@ -3730,6 +3858,16 @@ def _print_stylesheet() -> str:
3730
3858
  f' svg path[fill="{dark_value}"],'
3731
3859
  f' svg polyline[fill="{dark_value}"]'
3732
3860
  f' {{ fill: {light_value} !important; }}')
3861
+
3862
+ stroke_pairs = [(dark[role], light[role])
3863
+ for role in ("series_primary", "series_secondary",
3864
+ "ref_warn", "ref_alarm")]
3865
+ strokes = []
3866
+ stroke_seen: set = set()
3867
+ for dark_value, light_value in stroke_pairs:
3868
+ if dark_value in stroke_seen:
3869
+ continue
3870
+ stroke_seen.add(dark_value)
3733
3871
  strokes.append(
3734
3872
  f' svg line[stroke="{dark_value}"] {{ stroke: {light_value} !important; }}'
3735
3873
  f' svg polyline[stroke="{dark_value}"] {{ stroke: {light_value} !important; }}'
@@ -3996,8 +4134,9 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
3996
4134
 
3997
4135
  The second complete-document boundary that owns the privacy contract
3998
4136
  (#503 S1). `sections` must carry RAW snapshots: `compose()` prepares them
3999
- itself under `opts.reveal_projects`, stitches, and then verifies the whole
4000
- composed document. Callers must not pre-scrub a pre-scrubbed section
4137
+ itself under `opts.reveal_projects`, stitches, verifies the whole composed
4138
+ document, and only then restores provenance-qualified reveal basenames.
4139
+ Callers must not pre-scrub — a pre-scrubbed section
4001
4140
  reaching a second aliasing pass merges two distinct projects that each
4002
4141
  mapped locally to `project-1` into one alias.
4003
4142
  """
@@ -4009,10 +4148,15 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
4009
4148
  # a handler-only fix would miss the CLI `source=all` path.
4010
4149
  merged = _merged_project_mapping(
4011
4150
  [sec.snap for sec in sections], reveal_projects=opts.reveal_projects)
4151
+ render_mapping, reveal_substitutions = _mask_reveal_basename_mapping(
4152
+ [sec.snap for sec in sections], merged,
4153
+ reveal_projects=opts.reveal_projects,
4154
+ reserved_strings=(opts.title,),
4155
+ )
4012
4156
  prepared = tuple(
4013
4157
  ComposedSection(
4014
4158
  snap=_prepare(sec.snap, reveal_projects=opts.reveal_projects,
4015
- mapping=merged),
4159
+ mapping=render_mapping),
4016
4160
  drift_detected=sec.drift_detected,
4017
4161
  )
4018
4162
  for sec in sections
@@ -4040,10 +4184,13 @@ def compose(sections: tuple[ComposedSection, ...], *, opts: ComposeOptions) -> s
4040
4184
  body = _stitch_svg(prepared, opts=opts)
4041
4185
  else:
4042
4186
  raise ValueError(f"unknown format: {fmt!r}")
4043
- _verify_output(body, inventory=_merge_inventories([
4044
- (raw.snap, out.snap) for raw, out in zip(sections, prepared)
4045
- ]))
4046
- return body
4187
+ return _verify_and_restore_reveal_basenames(
4188
+ body,
4189
+ inventory=_merge_inventories([
4190
+ (raw.snap, out.snap) for raw, out in zip(sections, prepared)
4191
+ ]),
4192
+ substitutions=reveal_substitutions,
4193
+ )
4047
4194
 
4048
4195
 
4049
4196
  def _stitch_html(sections: tuple[ComposedSection, ...], *,
@@ -4255,8 +4402,8 @@ def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool,
4255
4402
  responsible for emitting the result (stdout/file/clipboard/open).
4256
4403
 
4257
4404
  One of the two complete-document boundaries that own the privacy contract
4258
- (#503 S1). It runs inventory -> prepare -> render -> verify. The gate goes
4259
- here and in `compose()`, not in `_render_fragment` and not in
4405
+ (#503 S1). It runs inventory -> prepare/mask -> render -> verify -> restore.
4406
+ The gate goes here and in `compose()`, not in `_render_fragment` and not in
4260
4407
  `_wrap_document`, because composition bypasses the latter and a fragment
4261
4408
  is not a complete document.
4262
4409
 
@@ -4270,11 +4417,19 @@ def render(snap: ShareSnapshot, *, format: str, theme: str, branding: bool,
4270
4417
  snapshot renumbers aliases on the legacy path, so preparation refuses it.
4271
4418
  """
4272
4419
  inventory_source = snap
4273
- prepared = _prepare(snap, reveal_projects=reveal_projects)
4420
+ resolved = _resolved_project_labels(
4421
+ snap, reveal_projects=reveal_projects)
4422
+ render_mapping, reveal_substitutions = _mask_reveal_basename_mapping(
4423
+ [snap], resolved, reveal_projects=reveal_projects)
4424
+ prepared = _prepare(
4425
+ snap, reveal_projects=reveal_projects, mapping=render_mapping)
4274
4426
  out = _render_prepared(prepared, format=format, theme=theme,
4275
4427
  branding=branding)
4276
- _verify_output(out, inventory=_inventory_for(inventory_source, prepared))
4277
- return out
4428
+ return _verify_and_restore_reveal_basenames(
4429
+ out,
4430
+ inventory=_inventory_for(inventory_source, prepared),
4431
+ substitutions=reveal_substitutions,
4432
+ )
4278
4433
 
4279
4434
 
4280
4435
  def _render_prepared(snap: ShareSnapshot, *, format: str, theme: str,
@@ -106,6 +106,16 @@ class SnapshotSignature(NamedTuple):
106
106
  # Codex mutation happens along. Empty when nothing is owed (the writer
107
107
  # DELETEs the key at zero), so a fully-ingested store is byte-neutral.
108
108
  codex_ingest_backlog_sig: str = ""
109
+ # #556 S3 §2.9: the Claude twin of `codex_stats_digest`. The stats legs
110
+ # above are `MAX(id)` over the two weekly snapshot tables plus the
111
+ # reset-event change signal, and a Claude milestone INSERT or an
112
+ # `alerted_at` arming UPDATE touches none of them — measured: inserting a
113
+ # `budget_milestones` row with `vendor='claude'` left every other leg
114
+ # byte-identical. Without this leg a fired Claude alert could leave the
115
+ # idle path short-circuiting on a retained prior bundle. Unlike the two
116
+ # legs above, this one is a digest and is never empty: a store with no
117
+ # armed Claude alert carries a constant hash, not the empty string.
118
+ claude_stats_digest: str = ""
109
119
 
110
120
 
111
121
  def _max_id(conn: sqlite3.Connection, table: str) -> int:
@@ -221,6 +231,7 @@ def compute_signature(
221
231
  generation: int,
222
232
  codex_stats_digest: str = "",
223
233
  accounts_digest: str = "",
234
+ claude_stats_digest: str = "",
224
235
  ) -> SnapshotSignature:
225
236
  """Composite data-version signature across cache.db + stats.db (spec §3).
226
237
 
@@ -243,6 +254,7 @@ def compute_signature(
243
254
  codex_stats_digest=str(codex_stats_digest),
244
255
  accounts_digest=str(accounts_digest),
245
256
  codex_ingest_backlog_sig=_codex_ingest_backlog_sig(cache_conn),
257
+ claude_stats_digest=str(claude_stats_digest),
246
258
  )
247
259
 
248
260
 
package/bin/cctally CHANGED
@@ -1372,6 +1372,19 @@ _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
1372
1372
  _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
1373
1373
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
1374
1374
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
1375
+ # #556 S2: the shared cross-provider aggregate range and the two range-native
1376
+ # folds it feeds. Re-exported so the source-bundle builder in
1377
+ # `_cctally_tui._tui_build_source_bundle` reaches them through `_cctally()`,
1378
+ # which is also the seam its fold-failure tests monkeypatch.
1379
+ resolve_shared_range = _cctally_dashboard.resolve_shared_range
1380
+ iter_shared_range_entries = _cctally_dashboard.iter_shared_range_entries
1381
+ fold_projects_over_range = _cctally_dashboard.fold_projects_over_range
1382
+ fold_daily_over_range = _cctally_dashboard.fold_daily_over_range
1383
+ materialise_daily_calendar = _cctally_dashboard.materialise_daily_calendar
1384
+ build_daily_aggregate_rows = _cctally_dashboard.build_daily_aggregate_rows
1385
+ daily_panel_row_to_wire = _cctally_dashboard.daily_panel_row_to_wire
1386
+ build_project_aggregate_rows = _cctally_dashboard.build_project_aggregate_rows
1387
+ legacy_project_labels = _cctally_dashboard.legacy_project_labels
1375
1388
  # _iso_z is NOT bound here anymore — the former dashboard-then-forecast
1376
1389
  # double-bind collapses to a single canonical bind below (#279 S6 W4).
1377
1390
  # Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
@@ -1864,7 +1877,34 @@ def _sum_cost_for_range(
1864
1877
  by the per-account ``sync-week`` cost materialization + per-account budget
1865
1878
  ladders so a snapshot / budget carries genuinely per-account cost.
1866
1879
  """
1880
+ return _sum_cost_and_tokens_for_range(
1881
+ start, end, mode, project, skip_sync=skip_sync, account_key=account_key,
1882
+ )[0]
1883
+
1884
+
1885
+ def _sum_cost_and_tokens_for_range(
1886
+ start: dt.datetime,
1887
+ end: dt.datetime,
1888
+ mode: str = "auto",
1889
+ project: str | None = None,
1890
+ *,
1891
+ skip_sync: bool = False,
1892
+ account_key: "str | None" = None,
1893
+ ) -> "tuple[float, int]":
1894
+ """Sum USD cost and #104 total tokens over ONE walk of the same entries.
1895
+
1896
+ #556 S1 §3.3. The two halves must describe the SAME entry set, so they are
1897
+ accumulated together rather than by a second read: a separate read runs on
1898
+ another connection with its own end-boundary semantics and can legitimately
1899
+ disagree with this one.
1900
+
1901
+ Tokens follow the #104 convention (`input + output + cache_create +
1902
+ cache_read`). `cache_creation_1h_input_tokens` is deliberately absent — it
1903
+ is a TTL SUBDIVISION of `cache_creation_input_tokens` (#195), so adding it
1904
+ would double-count every 1-hour cache write.
1905
+ """
1867
1906
  total = 0.0
1907
+ tokens = 0
1868
1908
  for entry in get_entries(start, end, project=project, skip_sync=skip_sync,
1869
1909
  account_key=account_key):
1870
1910
  total += _calculate_entry_cost(
@@ -1873,7 +1913,14 @@ def _sum_cost_for_range(
1873
1913
  mode=mode,
1874
1914
  cost_usd=entry.cost_usd,
1875
1915
  )
1876
- return total
1916
+ usage = entry.usage
1917
+ tokens += (
1918
+ int(usage.get("input_tokens", 0) or 0)
1919
+ + int(usage.get("output_tokens", 0) or 0)
1920
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
1921
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
1922
+ )
1923
+ return total, tokens
1877
1924
 
1878
1925
 
1879
1926
  def _bridge_z_into_tz(args: argparse.Namespace,