cctally 1.96.1 → 1.97.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.
@@ -53,7 +53,7 @@ def _chip_for_model(name: str) -> str:
53
53
  # Date the embedded pricing snapshots below were last verified against
54
54
  # vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
55
55
  # is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
56
- PRICING_SNAPSHOT_DATE = "2026-07-31"
56
+ PRICING_SNAPSHOT_DATE = "2026-08-13"
57
57
  PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
58
58
 
59
59
  # Canonical machine-readable pricing source (Claude values + Codex values).
@@ -68,25 +68,20 @@ LITELLM_PRICES_URL = (
68
68
  # Guarded by `stale_allowlist_entries` (tests/test_pricing_check.py): an entry
69
69
  # that no longer corresponds to a real divergence fails the suite.
70
70
  #
71
- # claude-sonnet-5 (#274): LiteLLM tracks the $2/$10-per-MTok *introductory* rate
72
- # (in effect through 2026-08-31); we deliberately embed the durable *standard*
73
- # $3/$15 rate because the table is date-blind and the promo expires soon (see
74
- # the CLAUDE_MODEL_PRICING note below). The non-vacuity guard forces these four
75
- # entries out once LiteLLM reverts to the standard rate post-cutover.
71
+ # claude-mythos-preview (#560): Anthropic's Project Glasswing launch priced the
72
+ # historical Preview at $25/$125 per MTok after its credit period. LiteLLM
73
+ # currently mirrors successor Mythos 5's lower $10/$50 rate onto the Preview
74
+ # identifier. Retained Preview rows therefore keep the explicit historical
75
+ # rate rather than being rewritten to the successor's rate.
76
76
  PRICING_DRIFT_ALLOWLIST: list[dict] = [
77
77
  {
78
- "model": "claude-sonnet-5",
78
+ "model": "claude-mythos-preview",
79
79
  "field": field,
80
- # Structured cutover date (#279 S7 W7): the intro rate is in effect
81
- # THROUGH 2026-08-31, so this suppression is valid through then and
82
- # `expired_allowlist_entries` flags it the day after. Keep the prose in
83
- # `reason`; the date here is what the offline expiry check + the cron read.
84
- "expires": "2026-08-31",
85
80
  "reason": (
86
- "LiteLLM tracks the claude-sonnet-5 introductory rate "
87
- "($2/$10 per MTok, through 2026-08-31); we deliberately embed the "
88
- "durable standard $3/$15 rate (the table is date-blind). Remove "
89
- "once LiteLLM reverts post-cutover (#274)."
81
+ "Anthropic priced historical Claude Mythos Preview at $25/$125 "
82
+ "per MTok after its Project Glasswing credit period; LiteLLM "
83
+ "currently mirrors successor Mythos 5's $10/$50 rate onto the "
84
+ "Preview identifier (#560)."
90
85
  ),
91
86
  }
92
87
  for field in (
@@ -99,23 +94,19 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
99
94
 
100
95
  # Anthropic API pricing snapshot:
101
96
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
102
- # - Captured/verified: 2026-07-28. PRICING_SNAPSHOT_DATE has since moved to
103
- # 2026-07-31 for the Codex-side gpt-5.6-terra/-luna correction (#441); these
104
- # Claude values were NOT re-verified that day.
105
- # - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
106
- # update in PRs touching this table.
97
+ # - Captured/verified: 2026-08-13 against LiteLLM plus Anthropic's current
98
+ # pricing page and model launch pages.
99
+ # - Vendor sources: https://platform.claude.com/docs/en/about-claude/pricing,
100
+ # https://www.anthropic.com/news/claude-sonnet-5,
101
+ # https://www.anthropic.com/news/claude-fable-5-mythos-5, and
102
+ # https://www.anthropic.com/glasswing. Update in changes touching this table.
107
103
  # 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
108
104
  # long-context premium) — issue #172.
109
- # 2026-07-01: added claude-sonnet-5 ($3/$15 per MTok; 1M context, flat-rate
110
- # across the full window no long-context premium, same shape as
111
- # claude-sonnet-4-6). Embedded the STANDARD rate, not the $2/$10 introductory
112
- # rate in effect through 2026-08-31, because the table is date-blind and
113
- # $3/$15 is the durable post-cutover price.
114
- # 2026-07-06 (#274): LiteLLM published a sonnet-5 entry at the $2/$10
115
- # introductory rate, so the deliberate standard-rate choice now surfaces as
116
- # value_drift on all four cost fields. Suppressed via PRICING_DRIFT_ALLOWLIST
117
- # above (the non-vacuity guard forces removal once LiteLLM reverts to the
118
- # standard rate after 2026-08-31).
105
+ # 2026-07-01 (#274, superseded by #560): initially added claude-sonnet-5 at
106
+ # the then-announced future $3/$15 standard rate, preferring it over the
107
+ # launch promotion because this table is date-blind. LiteLLM's $2/$10 row was
108
+ # temporarily suppressed on all four fields. Anthropic later cancelled that
109
+ # transition; the 2026-08-13 entry below records the replacement decision.
119
110
  # 2026-07-24: added claude-opus-5 ($5/$25 per MTok — identical to Opus
120
111
  # 4.5/4.6/4.7/4.8; $6.25 5-minute cache write and $0.50 cache read at the
121
112
  # standard 1.25x/0.1x multipliers). 1M context at standard pricing, so NO
@@ -139,6 +130,12 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
139
130
  # cache multipliers stack on the fast base rate. This snapshot bump is the
140
131
  # pricing-fingerprint bust for safe conversation-rollup rederivation; durable
141
132
  # journaled milestones and weekly snapshots are not rewritten.
133
+ # 2026-08-13 (#560): Sonnet 5's $2/$10 launch pricing is now permanent, so
134
+ # replaced the cancelled $3/$15 rate and removed its four temporary drift
135
+ # suppressions. Added Mythos 5 at $10/$50 from Anthropic's launch/current
136
+ # pricing pages and historical Mythos Preview at the explicit $25/$125
137
+ # Project Glasswing rate. The snapshot bump re-arms the existing conversation
138
+ # rollup pricing fingerprint; immutable journaled/stored facts stay unchanged.
142
139
  # Anthropic prices a cache WRITE by TTL: 1.25x base input for a 5-minute write,
143
140
  # 2x for a 1-hour write; reads are 0.1x under both. Documented as applying
144
141
  # consistently across all supported models, so the 1h rate is DERIVED from
@@ -241,6 +238,18 @@ CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
241
238
  "cache_creation_input_token_cost": 1.25e-06,
242
239
  "cache_read_input_token_cost": 1e-07,
243
240
  },
241
+ "claude-mythos-5": {
242
+ "input_cost_per_token": 1e-05,
243
+ "output_cost_per_token": 5e-05,
244
+ "cache_creation_input_token_cost": 1.25e-05,
245
+ "cache_read_input_token_cost": 1e-06,
246
+ },
247
+ "claude-mythos-preview": {
248
+ "input_cost_per_token": 2.5e-05,
249
+ "output_cost_per_token": 1.25e-04,
250
+ "cache_creation_input_token_cost": 3.125e-05,
251
+ "cache_read_input_token_cost": 2.5e-06,
252
+ },
244
253
  "claude-opus-4-1": {
245
254
  "input_cost_per_token": 1.5e-05,
246
255
  "output_cost_per_token": 7.5e-05,
@@ -344,10 +353,10 @@ CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
344
353
  "cache_read_input_token_cost": 3e-07,
345
354
  },
346
355
  "claude-sonnet-5": {
347
- "input_cost_per_token": 3e-06,
348
- "output_cost_per_token": 1.5e-05,
349
- "cache_creation_input_token_cost": 3.75e-06,
350
- "cache_read_input_token_cost": 3e-07,
356
+ "input_cost_per_token": 2e-06,
357
+ "output_cost_per_token": 1e-05,
358
+ "cache_creation_input_token_cost": 2.5e-06,
359
+ "cache_read_input_token_cost": 2e-07,
351
360
  },
352
361
  }
353
362
 
@@ -389,9 +398,10 @@ _unknown_model_warnings: set[str] = set()
389
398
  # Codex (OpenAI) API pricing snapshot:
390
399
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
391
400
  # - Captured: 2026-07-19 — the last FULL Codex sync. PRICING_SNAPSHOT_DATE has
392
- # since moved for two targeted syncs (2026-07-24, the Claude-side opus-5 sync;
393
- # 2026-07-31, the gpt-5.6-terra/-luna correction logged below). Codex values
394
- # outside those two corrections were NOT re-verified on those days.
401
+ # since moved for three targeted syncs (2026-07-24, the Claude-side opus-5
402
+ # sync; 2026-07-31, the gpt-5.6-terra/-luna correction logged below; and
403
+ # 2026-08-13, the Claude-side Sonnet/Mythos sync above). Codex values outside
404
+ # the one Codex correction were NOT re-verified on those days.
395
405
  # - As of the 2026-07-19 sync this carries every openai-provider
396
406
  # gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
397
407
  # nothing missing. Models absent from this table still fall back to `gpt-5`
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,
package/bin/cctally CHANGED
@@ -1864,7 +1864,34 @@ def _sum_cost_for_range(
1864
1864
  by the per-account ``sync-week`` cost materialization + per-account budget
1865
1865
  ladders so a snapshot / budget carries genuinely per-account cost.
1866
1866
  """
1867
+ return _sum_cost_and_tokens_for_range(
1868
+ start, end, mode, project, skip_sync=skip_sync, account_key=account_key,
1869
+ )[0]
1870
+
1871
+
1872
+ def _sum_cost_and_tokens_for_range(
1873
+ start: dt.datetime,
1874
+ end: dt.datetime,
1875
+ mode: str = "auto",
1876
+ project: str | None = None,
1877
+ *,
1878
+ skip_sync: bool = False,
1879
+ account_key: "str | None" = None,
1880
+ ) -> "tuple[float, int]":
1881
+ """Sum USD cost and #104 total tokens over ONE walk of the same entries.
1882
+
1883
+ #556 S1 §3.3. The two halves must describe the SAME entry set, so they are
1884
+ accumulated together rather than by a second read: a separate read runs on
1885
+ another connection with its own end-boundary semantics and can legitimately
1886
+ disagree with this one.
1887
+
1888
+ Tokens follow the #104 convention (`input + output + cache_create +
1889
+ cache_read`). `cache_creation_1h_input_tokens` is deliberately absent — it
1890
+ is a TTL SUBDIVISION of `cache_creation_input_tokens` (#195), so adding it
1891
+ would double-count every 1-hour cache write.
1892
+ """
1867
1893
  total = 0.0
1894
+ tokens = 0
1868
1895
  for entry in get_entries(start, end, project=project, skip_sync=skip_sync,
1869
1896
  account_key=account_key):
1870
1897
  total += _calculate_entry_cost(
@@ -1873,7 +1900,14 @@ def _sum_cost_for_range(
1873
1900
  mode=mode,
1874
1901
  cost_usd=entry.cost_usd,
1875
1902
  )
1876
- return total
1903
+ usage = entry.usage
1904
+ tokens += (
1905
+ int(usage.get("input_tokens", 0) or 0)
1906
+ + int(usage.get("output_tokens", 0) or 0)
1907
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
1908
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
1909
+ )
1910
+ return total, tokens
1877
1911
 
1878
1912
 
1879
1913
  def _bridge_z_into_tz(args: argparse.Namespace,