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.
- package/CHANGELOG.md +24 -0
- package/bin/_cctally_dashboard_envelope.py +14 -0
- package/bin/_cctally_dashboard_share.py +25 -5
- package/bin/_cctally_dashboard_sources.py +44 -23
- package/bin/_cctally_db.py +10 -0
- package/bin/_cctally_doctor.py +15 -10
- package/bin/_cctally_journal_repair.py +7 -6
- package/bin/_cctally_parser.py +47 -22
- package/bin/_cctally_tui.py +203 -34
- package/bin/_lib_dashboard_sources.py +376 -85
- package/bin/_lib_journal_router.py +14 -0
- package/bin/_lib_pricing.py +48 -38
- package/bin/_lib_share.py +191 -36
- package/bin/cctally +35 -1
- package/dashboard/static/assets/index-BSESoPIK.css +1 -0
- package/dashboard/static/assets/{index-Bhr5gZ14.js → index-DgsMz5hA.js} +55 -55
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-DfN_fsLZ.css +0 -1
package/bin/_lib_pricing.py
CHANGED
|
@@ -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-
|
|
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-
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
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-
|
|
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
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
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-
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
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":
|
|
348
|
-
"output_cost_per_token":
|
|
349
|
-
"cache_creation_input_token_cost":
|
|
350
|
-
"cache_read_input_token_cost":
|
|
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
|
|
393
|
-
# 2026-07-31, the gpt-5.6-terra/-luna correction logged below
|
|
394
|
-
#
|
|
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": "#
|
|
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(
|
|
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
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
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 #
|
|
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
|
-
#
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
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
|
-
|
|
3719
|
-
for dark_value, light_value in
|
|
3720
|
-
if dark_value in
|
|
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
|
-
|
|
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,
|
|
4000
|
-
|
|
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=
|
|
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
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4277
|
-
|
|
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
|
-
|
|
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,
|