cctally 1.80.4 → 1.82.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 +32 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +434 -48
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +56 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_pricing.py +27 -3
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BzWujBS3.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
package/bin/_lib_journal.py
CHANGED
|
@@ -107,10 +107,19 @@ def evt_id(kind: str, *parts: object) -> str:
|
|
|
107
107
|
# fully-formed line records
|
|
108
108
|
# --------------------------------------------------------------------------
|
|
109
109
|
|
|
110
|
-
def make_obs(at: str, src: str, provider: str, payload: dict
|
|
110
|
+
def make_obs(at: str, src: str, provider: str, payload: dict,
|
|
111
|
+
account: str | None = None) -> dict:
|
|
111
112
|
"""Build a complete ``obs`` line record (raw capture; canonicalization is
|
|
112
|
-
derivation-time, never baked into the stored line — spec §4.2).
|
|
113
|
+
derivation-time, never baked into the stored line — spec §4.2).
|
|
114
|
+
|
|
115
|
+
``account`` (#341) is the account_key stamp, a top-level sibling of
|
|
116
|
+
``provider``. It is emitted ONLY when supplied, so a pre-epic / single-account
|
|
117
|
+
writer that passes nothing produces a byte-identical line (and a byte-stable
|
|
118
|
+
content id). When supplied it participates in the content id, so two obs that
|
|
119
|
+
differ only by account are distinct records."""
|
|
113
120
|
core = {"t": "obs", "at": at, "src": src, "provider": provider, "payload": payload}
|
|
121
|
+
if account is not None:
|
|
122
|
+
core["account"] = account
|
|
114
123
|
return {"v": LINE_VERSION, **core, "id": content_id(core)}
|
|
115
124
|
|
|
116
125
|
|
|
@@ -133,6 +142,60 @@ def make_evt(kind: str, id: str, at: str, payload: dict, rev: int = 0) -> dict:
|
|
|
133
142
|
"at": at, "src": "ingest", "payload": body}
|
|
134
143
|
|
|
135
144
|
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
# accounts-machinery records (#341): registered op kinds folded into the
|
|
147
|
+
# `accounts` registry. These are NOT data-bearing account-stamped lines and are
|
|
148
|
+
# NOT legacy — the classifier recognises them by their registered `kind`.
|
|
149
|
+
# --------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def make_account_observe(
|
|
152
|
+
at: str,
|
|
153
|
+
account_key: str,
|
|
154
|
+
provider: str,
|
|
155
|
+
*,
|
|
156
|
+
natural_id: str | None = None,
|
|
157
|
+
email: str | None = None,
|
|
158
|
+
plan_type: str | None = None,
|
|
159
|
+
label: str | None = None,
|
|
160
|
+
label_source: str | None = None,
|
|
161
|
+
) -> dict:
|
|
162
|
+
"""Build an ``account_observe`` op line — appended on first sight of an
|
|
163
|
+
account or an identity change (NOT every tick). Folded into the ``accounts``
|
|
164
|
+
registry by ``_apply_op_account_observe`` (rebuild applier). ``last_seen_utc``
|
|
165
|
+
is NOT carried here — it derives at fold time from the max ``at`` of any
|
|
166
|
+
account-stamped line (spec §1)."""
|
|
167
|
+
payload = {"kind": "account_observe", "account_key": account_key,
|
|
168
|
+
"provider": provider}
|
|
169
|
+
if natural_id is not None:
|
|
170
|
+
payload["natural_id"] = natural_id
|
|
171
|
+
if email is not None:
|
|
172
|
+
payload["email"] = email
|
|
173
|
+
if plan_type is not None:
|
|
174
|
+
payload["plan_type"] = plan_type
|
|
175
|
+
if label is not None:
|
|
176
|
+
payload["label"] = label
|
|
177
|
+
if label_source is not None:
|
|
178
|
+
payload["label_source"] = label_source
|
|
179
|
+
return make_op(at=at, src="account-observe", payload=payload)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def make_account_label(
|
|
183
|
+
at: str,
|
|
184
|
+
account_key: str,
|
|
185
|
+
label: str,
|
|
186
|
+
*,
|
|
187
|
+
provider: str | None = None,
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""Build an ``account_label`` op line (a user rename). Folded by
|
|
190
|
+
``_apply_op_account_label`` with ``label_source='user'`` — the top of the
|
|
191
|
+
label-precedence order (user > switcher > auto), so a later switcher/auto
|
|
192
|
+
enrichment never overrides it (spec §1)."""
|
|
193
|
+
payload = {"kind": "account_label", "account_key": account_key, "label": label}
|
|
194
|
+
if provider is not None:
|
|
195
|
+
payload["provider"] = provider
|
|
196
|
+
return make_op(at=at, src="account-label", payload=payload)
|
|
197
|
+
|
|
198
|
+
|
|
136
199
|
# --------------------------------------------------------------------------
|
|
137
200
|
# segment naming + canonical order
|
|
138
201
|
# --------------------------------------------------------------------------
|
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-07-
|
|
56
|
+
PRICING_SNAPSHOT_DATE = "2026-07-24"
|
|
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).
|
|
@@ -99,7 +99,7 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
|
|
|
99
99
|
|
|
100
100
|
# Anthropic API pricing snapshot:
|
|
101
101
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
102
|
-
# - Captured: 2026-07-
|
|
102
|
+
# - Captured: 2026-07-24 (see PRICING_SNAPSHOT_DATE)
|
|
103
103
|
# - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
|
|
104
104
|
# update in PRs touching this table.
|
|
105
105
|
# 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
|
|
@@ -114,6 +114,22 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
|
|
|
114
114
|
# value_drift on all four cost fields. Suppressed via PRICING_DRIFT_ALLOWLIST
|
|
115
115
|
# above (the non-vacuity guard forces removal once LiteLLM reverts to the
|
|
116
116
|
# standard rate after 2026-08-31).
|
|
117
|
+
# 2026-07-24: added claude-opus-5 ($5/$25 per MTok — identical to Opus
|
|
118
|
+
# 4.5/4.6/4.7/4.8; $6.25 5-minute cache write and $0.50 cache read at the
|
|
119
|
+
# standard 1.25x/0.1x multipliers). 1M context at standard pricing, so NO
|
|
120
|
+
# above-200k tier. Dateless pinned id with no dated twin (matches Anthropic's
|
|
121
|
+
# published ID/alias table). LiteLLM has no opus-5 entry yet, so this table is
|
|
122
|
+
# simply ahead of it — `ahead_of_litellm` is never a drift finding, so no
|
|
123
|
+
# PRICING_DRIFT_ALLOWLIST entry is needed.
|
|
124
|
+
#
|
|
125
|
+
# Known gap — Claude *fast mode* is not modelled. Anthropic bills fast mode at a
|
|
126
|
+
# premium ($10/$50 per MTok on Opus 5 and Opus 4.8; $30/$150 on Opus 4.7), and
|
|
127
|
+
# Claude Code records the tier per assistant entry at `message.usage.speed`
|
|
128
|
+
# ("standard" | "fast"), so it IS derivable from the JSONL we already ingest —
|
|
129
|
+
# but this table is one rate per model and `_calculate_entry_cost` ignores speed,
|
|
130
|
+
# so a fast-mode entry is priced at the standard rate (2x undercount on Opus 5).
|
|
131
|
+
# The Codex side already carries the shape for this
|
|
132
|
+
# (`_calculate_codex_entry_cost(..., speed=...)` + its fast-tier multiplier).
|
|
117
133
|
CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
|
|
118
134
|
"claude-3-5-haiku-20241022": {
|
|
119
135
|
"input_cost_per_token": 8e-07,
|
|
@@ -269,6 +285,12 @@ CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
|
|
|
269
285
|
"cache_creation_input_token_cost": 6.25e-06,
|
|
270
286
|
"cache_read_input_token_cost": 5e-07,
|
|
271
287
|
},
|
|
288
|
+
"claude-opus-5": {
|
|
289
|
+
"input_cost_per_token": 5e-06,
|
|
290
|
+
"output_cost_per_token": 2.5e-05,
|
|
291
|
+
"cache_creation_input_token_cost": 6.25e-06,
|
|
292
|
+
"cache_read_input_token_cost": 5e-07,
|
|
293
|
+
},
|
|
272
294
|
"claude-sonnet-4-20250514": {
|
|
273
295
|
"input_cost_per_token": 3e-06,
|
|
274
296
|
"output_cost_per_token": 1.5e-05,
|
|
@@ -321,7 +343,9 @@ _unknown_model_warnings: set[str] = set()
|
|
|
321
343
|
#
|
|
322
344
|
# Codex (OpenAI) API pricing snapshot:
|
|
323
345
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
324
|
-
# - Captured: 2026-07-19
|
|
346
|
+
# - Captured: 2026-07-19 — the last full Codex sync. PRICING_SNAPSHOT_DATE moved
|
|
347
|
+
# ahead to 2026-07-24 for the Claude-side opus-5 sync; these Codex values were
|
|
348
|
+
# NOT re-verified that day.
|
|
325
349
|
# - As of the 2026-07-19 sync this carries every openai-provider
|
|
326
350
|
# gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
|
|
327
351
|
# nothing missing. Models absent from this table still fall back to `gpt-5`
|
package/bin/_lib_quota.py
CHANGED
|
@@ -11,9 +11,11 @@ import datetime as dt
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
13
|
import math
|
|
14
|
-
from dataclasses import dataclass, field
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
15
|
from typing import Iterable, Literal
|
|
16
16
|
|
|
17
|
+
import _lib_accounts
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
FUTURE_CLOCK_SKEW_SECONDS = 300
|
|
19
21
|
_SOURCE_PATH_KEY_PREFIX = b"cctally-source-path-v1\0"
|
|
@@ -52,11 +54,18 @@ class QuotaWindowIdentity:
|
|
|
52
54
|
logical_limit_key: str
|
|
53
55
|
observed_slot: str
|
|
54
56
|
window_minutes: int
|
|
57
|
+
# account_key (#341) participates in equality/hashing so never-combine
|
|
58
|
+
# extends to accounts automatically: two accounts sharing one physical
|
|
59
|
+
# window key are distinct identities. It defaults to the reserved sentinel so
|
|
60
|
+
# a single-account / pre-#341 caller building an identity by keyword is
|
|
61
|
+
# byte-stable. limit_id/limit_name remain compare=False display metadata.
|
|
62
|
+
account_key: str = _lib_accounts.UNATTRIBUTED
|
|
55
63
|
limit_id: str | None = field(default=None, compare=False)
|
|
56
64
|
limit_name: str | None = field(default=None, compare=False)
|
|
57
65
|
|
|
58
66
|
def __post_init__(self) -> None:
|
|
59
|
-
for name in ("source", "source_root_key", "logical_limit_key", "observed_slot"
|
|
67
|
+
for name in ("source", "source_root_key", "logical_limit_key", "observed_slot",
|
|
68
|
+
"account_key"):
|
|
60
69
|
if not isinstance(getattr(self, name), str) or not getattr(self, name):
|
|
61
70
|
raise ValueError(f"{name} must be a non-empty string")
|
|
62
71
|
if not isinstance(self.window_minutes, int) or isinstance(self.window_minutes, bool):
|
|
@@ -194,14 +203,20 @@ class QuotaThresholdDecision:
|
|
|
194
203
|
threshold: int
|
|
195
204
|
|
|
196
205
|
|
|
197
|
-
def identity_sort_key(identity: QuotaWindowIdentity) -> tuple[str, str, str, str, int]:
|
|
198
|
-
"""Return the stable full logical identity ordering used by every selector.
|
|
206
|
+
def identity_sort_key(identity: QuotaWindowIdentity) -> tuple[str, str, str, str, int, str]:
|
|
207
|
+
"""Return the stable full logical identity ordering used by every selector.
|
|
208
|
+
|
|
209
|
+
``account_key`` (#341) is appended LAST so the existing 5-element ordering is
|
|
210
|
+
preserved byte-for-byte for a single-account install (the account is constant
|
|
211
|
+
there) and only ever breaks a genuine cross-account tie deterministically.
|
|
212
|
+
"""
|
|
199
213
|
return (
|
|
200
214
|
identity.source,
|
|
201
215
|
identity.source_root_key,
|
|
202
216
|
identity.logical_limit_key,
|
|
203
217
|
identity.observed_slot,
|
|
204
218
|
identity.window_minutes,
|
|
219
|
+
identity.account_key,
|
|
205
220
|
)
|
|
206
221
|
|
|
207
222
|
|
|
@@ -270,6 +285,68 @@ def build_history(observations: Iterable[QuotaObservation]) -> tuple[QuotaHistor
|
|
|
270
285
|
return tuple(result)
|
|
271
286
|
|
|
272
287
|
|
|
288
|
+
def _physical_window_key(observation: QuotaObservation) -> tuple[object, ...]:
|
|
289
|
+
"""The account-INDEPENDENT physical window key used by the continuity fold.
|
|
290
|
+
|
|
291
|
+
Two observations share a physical window iff they agree on root, limit key,
|
|
292
|
+
slot, window minutes, and the exact (canonical UTC) reset boundary — the
|
|
293
|
+
account is deliberately EXCLUDED so unidentified observations can be adopted
|
|
294
|
+
by a same-window identified account (spec §2 window-account continuity).
|
|
295
|
+
"""
|
|
296
|
+
identity = observation.identity
|
|
297
|
+
return (
|
|
298
|
+
identity.source,
|
|
299
|
+
identity.source_root_key,
|
|
300
|
+
identity.logical_limit_key,
|
|
301
|
+
identity.observed_slot,
|
|
302
|
+
identity.window_minutes,
|
|
303
|
+
observation.resets_at,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def adopt_unidentified_observations(
|
|
308
|
+
observations: Iterable[QuotaObservation],
|
|
309
|
+
) -> tuple[QuotaObservation, ...]:
|
|
310
|
+
"""Apply the window-account continuity rule (#341 spec §2 rev 4).
|
|
311
|
+
|
|
312
|
+
Group observations by their physical window key (account EXCLUDED). Within a
|
|
313
|
+
group, identified observations (``account_key != unattributed``) are
|
|
314
|
+
AUTHORITATIVE and NEVER re-assigned — two identified accounts sharing an
|
|
315
|
+
identical physical window key stay separate windows (never-combine holds
|
|
316
|
+
unconditionally). Unidentified observations are adopted by the window's
|
|
317
|
+
account IFF exactly ONE identified account is ever observed for that key;
|
|
318
|
+
zero or ambiguous (>=2) identified accounts leave them ``unattributed``.
|
|
319
|
+
|
|
320
|
+
Pure and order-preserving: identified observations pass through untouched; an
|
|
321
|
+
adopted unidentified observation is returned with its identity re-stamped to
|
|
322
|
+
the single identified account. A single-account install (all observations
|
|
323
|
+
already carry one account, or all are unattributed with no identified peer)
|
|
324
|
+
is a byte-stable no-op.
|
|
325
|
+
"""
|
|
326
|
+
values = tuple(observations)
|
|
327
|
+
identified_by_window: dict[tuple[object, ...], set[str]] = {}
|
|
328
|
+
for observation in values:
|
|
329
|
+
account = observation.identity.account_key
|
|
330
|
+
if account != _lib_accounts.UNATTRIBUTED:
|
|
331
|
+
identified_by_window.setdefault(
|
|
332
|
+
_physical_window_key(observation), set()
|
|
333
|
+
).add(account)
|
|
334
|
+
result: list[QuotaObservation] = []
|
|
335
|
+
for observation in values:
|
|
336
|
+
if observation.identity.account_key != _lib_accounts.UNATTRIBUTED:
|
|
337
|
+
result.append(observation)
|
|
338
|
+
continue
|
|
339
|
+
accounts = identified_by_window.get(_physical_window_key(observation))
|
|
340
|
+
if accounts is not None and len(accounts) == 1:
|
|
341
|
+
adopted = next(iter(accounts))
|
|
342
|
+
result.append(replace(
|
|
343
|
+
observation, identity=replace(observation.identity, account_key=adopted),
|
|
344
|
+
))
|
|
345
|
+
else:
|
|
346
|
+
result.append(observation)
|
|
347
|
+
return tuple(result)
|
|
348
|
+
|
|
349
|
+
|
|
273
350
|
def latest_physical_observation(observations: Iterable[QuotaObservation]) -> QuotaObservation | None:
|
|
274
351
|
"""Select the latest local physical capture with deterministic tie breaking."""
|
|
275
352
|
values = tuple(observations)
|
package/bin/_lib_render.py
CHANGED
|
@@ -811,6 +811,7 @@ def _bucket_to_json(
|
|
|
811
811
|
*,
|
|
812
812
|
list_key: str,
|
|
813
813
|
date_key: str,
|
|
814
|
+
extra: "dict | None" = None,
|
|
814
815
|
) -> str:
|
|
815
816
|
"""Serialize bucket aggregates to JSON matching upstream ccusage's shape.
|
|
816
817
|
|
|
@@ -824,11 +825,15 @@ def _bucket_to_json(
|
|
|
824
825
|
"""
|
|
825
826
|
bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
|
|
826
827
|
totals = _bucket_totals_dict(buckets)
|
|
827
|
-
|
|
828
|
+
body = {list_key: bucket_list, "totals": totals}
|
|
829
|
+
if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
|
|
830
|
+
body.update(extra)
|
|
831
|
+
payload = stamp_schema_version(body)
|
|
828
832
|
return json.dumps(payload, indent=2)
|
|
829
833
|
|
|
830
834
|
|
|
831
|
-
def _bucket_by_project_to_json(project_groups, *, date_key: str = "date"
|
|
835
|
+
def _bucket_by_project_to_json(project_groups, *, date_key: str = "date",
|
|
836
|
+
extra: "dict | None" = None) -> str:
|
|
832
837
|
"""Serialize ``[(label, [BucketUsage]), ...]`` to ``{projects:{label:[rows]},
|
|
833
838
|
totals}`` (upstream ccusage daily --instances shape). Caller passes the
|
|
834
839
|
disambiguated (unique, non-aliased) label per group; insertion order is
|
|
@@ -838,8 +843,10 @@ def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str
|
|
|
838
843
|
for label, buckets in project_groups:
|
|
839
844
|
projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
|
|
840
845
|
all_buckets.extend(buckets)
|
|
841
|
-
|
|
842
|
-
|
|
846
|
+
body = {"projects": projects, "totals": _bucket_totals_dict(all_buckets)}
|
|
847
|
+
if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
|
|
848
|
+
body.update(extra)
|
|
849
|
+
payload = stamp_schema_version(body)
|
|
843
850
|
return json.dumps(payload, indent=2)
|
|
844
851
|
|
|
845
852
|
|
|
@@ -847,6 +854,8 @@ def _weekly_to_json(
|
|
|
847
854
|
buckets: list[BucketUsage],
|
|
848
855
|
weeks: list[SubWeek],
|
|
849
856
|
week_pct_overlay: list[tuple[float | None, float | None]],
|
|
857
|
+
*,
|
|
858
|
+
extra: "dict | None" = None,
|
|
850
859
|
) -> str:
|
|
851
860
|
"""Serialize weekly rollup to JSON.
|
|
852
861
|
|
|
@@ -924,6 +933,8 @@ def _weekly_to_json(
|
|
|
924
933
|
"totalCost": tot_cost,
|
|
925
934
|
},
|
|
926
935
|
}
|
|
936
|
+
if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
|
|
937
|
+
payload.update(extra)
|
|
927
938
|
return json.dumps(stamp_schema_version(payload), indent=2)
|
|
928
939
|
|
|
929
940
|
|
|
@@ -1179,7 +1190,8 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
1179
1190
|
return json.dumps(payload, indent=2)
|
|
1180
1191
|
|
|
1181
1192
|
|
|
1182
|
-
def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]
|
|
1193
|
+
def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage], *,
|
|
1194
|
+
extra: "dict | None" = None) -> str:
|
|
1183
1195
|
"""Serialize Claude sessions to JSON (spec A2.8, amended by issue #104).
|
|
1184
1196
|
|
|
1185
1197
|
Per-session: sessionId, projectPath, sourcePaths (list), firstActivity
|
|
@@ -1242,6 +1254,8 @@ def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
|
|
|
1242
1254
|
"totalCost": tot_cost,
|
|
1243
1255
|
},
|
|
1244
1256
|
}
|
|
1257
|
+
if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
|
|
1258
|
+
payload.update(extra)
|
|
1245
1259
|
return json.dumps(stamp_schema_version(payload), indent=2)
|
|
1246
1260
|
|
|
1247
1261
|
|
package/bin/_lib_share.py
CHANGED
|
@@ -920,6 +920,31 @@ def _build_anon_mapping(
|
|
|
920
920
|
return mapping
|
|
921
921
|
|
|
922
922
|
|
|
923
|
+
def anonymize_account_label(
|
|
924
|
+
label: str, index: int, *, reveal: bool,
|
|
925
|
+
) -> str:
|
|
926
|
+
"""Map an account label to ``Account A/B/C`` unless ``reveal`` (#341 Task 4).
|
|
927
|
+
|
|
928
|
+
Account labels are user data (spec §4 Privacy) — a share/export must never
|
|
929
|
+
leak them, exactly like project names. This is the fail-closed anonymization
|
|
930
|
+
chokepoint: ``reveal`` is the SAME toggle that governs project reveal
|
|
931
|
+
(``reveal_projects``), so anon-mode (the default) rewrites the label to a
|
|
932
|
+
positional ``Account <letter>`` keyed by the account's deterministic registry
|
|
933
|
+
``index`` (0→A, 1→B, …); only an explicit reveal shows the real label. Emails
|
|
934
|
+
never enter a share at all — the ``data.accounts[]`` wire carries no email
|
|
935
|
+
field — so this covers the only account user-data a share can see.
|
|
936
|
+
"""
|
|
937
|
+
if reveal:
|
|
938
|
+
return label
|
|
939
|
+
if index < 0:
|
|
940
|
+
index = 0
|
|
941
|
+
# A..Z then Account-27, Account-28, … (registries never approach 26 real
|
|
942
|
+
# accounts, but stay total rather than raise).
|
|
943
|
+
if index < 26:
|
|
944
|
+
return f"Account {chr(ord('A') + index)}"
|
|
945
|
+
return f"Account {index + 1}"
|
|
946
|
+
|
|
947
|
+
|
|
923
948
|
def _apply_anon_mapping(
|
|
924
949
|
snap: ShareSnapshot, mapping: dict[_ProjectAnonKey, str] | dict[str, str],
|
|
925
950
|
) -> ShareSnapshot:
|
|
@@ -91,6 +91,12 @@ class SnapshotSignature(NamedTuple):
|
|
|
91
91
|
# from the independently-committed quota/budget projection database.
|
|
92
92
|
codex_physical_mutation_seq: int = 0
|
|
93
93
|
codex_stats_digest: str = ""
|
|
94
|
+
# #341 finding 9: a digest of the account registry + the providers' on-disk
|
|
95
|
+
# identity-file/active-account state. Empty for every <=1-account install (no
|
|
96
|
+
# account ever observed), so it is byte-neutral there; otherwise it flips on
|
|
97
|
+
# an account SWITCH with zero new ingested rows, so the idle short-circuit is
|
|
98
|
+
# left and the `active` marker rebuilds on the next tick.
|
|
99
|
+
accounts_digest: str = ""
|
|
94
100
|
|
|
95
101
|
|
|
96
102
|
def _max_id(conn: sqlite3.Connection, table: str) -> int:
|
|
@@ -179,6 +185,7 @@ def compute_signature(
|
|
|
179
185
|
*,
|
|
180
186
|
generation: int,
|
|
181
187
|
codex_stats_digest: str = "",
|
|
188
|
+
accounts_digest: str = "",
|
|
182
189
|
) -> SnapshotSignature:
|
|
183
190
|
"""Composite data-version signature across cache.db + stats.db (spec §3).
|
|
184
191
|
|
|
@@ -199,6 +206,7 @@ def compute_signature(
|
|
|
199
206
|
entry_mutation_seq=_entry_mutation_seq(cache_conn),
|
|
200
207
|
codex_physical_mutation_seq=_codex_physical_mutation_seq(cache_conn),
|
|
201
208
|
codex_stats_digest=str(codex_stats_digest),
|
|
209
|
+
accounts_digest=str(accounts_digest),
|
|
202
210
|
)
|
|
203
211
|
|
|
204
212
|
|
|
@@ -284,9 +284,18 @@ def _compute_subscription_weeks(
|
|
|
284
284
|
range_start: dt.datetime,
|
|
285
285
|
range_end: dt.datetime,
|
|
286
286
|
config: "dict | None" = None,
|
|
287
|
+
*,
|
|
288
|
+
account_key: "str | None",
|
|
287
289
|
) -> list[SubWeek]:
|
|
288
290
|
"""Generate the ordered list of subscription weeks overlapping [range_start, range_end].
|
|
289
291
|
|
|
292
|
+
``account_key`` (#341, review finding 11): MANDATORY account context — no
|
|
293
|
+
silent global fallback. A real key scopes the snapshot-derived reset anchors
|
|
294
|
+
to that account so two accounts with different reset cadences never
|
|
295
|
+
re-anchor each other's week walk; ``None`` is the explicit "all accounts"
|
|
296
|
+
(merged) read used by the analytics callers, byte-identical to today on a
|
|
297
|
+
single-account install.
|
|
298
|
+
|
|
290
299
|
Prefers snapshot rows (authoritative reset boundaries from actual data)
|
|
291
300
|
and extrapolates by 7-day multiples only for the range tail before the
|
|
292
301
|
earliest snapshot. When no snapshots exist at all, falls back to
|
|
@@ -309,7 +318,10 @@ def _compute_subscription_weeks(
|
|
|
309
318
|
dates that miss actual snapshot boundaries for middle weeks. Using
|
|
310
319
|
snapshot rows directly for weeks they cover avoids that drift.
|
|
311
320
|
"""
|
|
312
|
-
# Case A: snapshots exist.
|
|
321
|
+
# Case A: snapshots exist. Account scoping (#341): a real key filters the
|
|
322
|
+
# reset anchors to that account; None is the merged (all-accounts) read.
|
|
323
|
+
acct_pred = "" if account_key is None else " AND account_key = ?"
|
|
324
|
+
acct_params: tuple = () if account_key is None else (account_key,)
|
|
313
325
|
snap_rows = conn.execute(
|
|
314
326
|
"SELECT "
|
|
315
327
|
" MIN(week_start_at) AS week_start_at, "
|
|
@@ -320,8 +332,10 @@ def _compute_subscription_weeks(
|
|
|
320
332
|
"WHERE week_start_at IS NOT NULL "
|
|
321
333
|
" AND week_end_at IS NOT NULL "
|
|
322
334
|
" AND week_start_date IS NOT NULL "
|
|
335
|
+
f" {acct_pred} "
|
|
323
336
|
"GROUP BY week_start_date "
|
|
324
|
-
"ORDER BY MIN(week_start_at) ASC"
|
|
337
|
+
"ORDER BY MIN(week_start_at) ASC",
|
|
338
|
+
acct_params,
|
|
325
339
|
).fetchall()
|
|
326
340
|
|
|
327
341
|
weeks: list[SubWeek] = []
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -572,7 +572,8 @@ class WeeklyView:
|
|
|
572
572
|
|
|
573
573
|
|
|
574
574
|
def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
|
|
575
|
-
as_of_utc=None, mode="auto", aggregated_override=None
|
|
575
|
+
as_of_utc=None, mode="auto", aggregated_override=None,
|
|
576
|
+
account_key=None):
|
|
576
577
|
"""Build a ``WeeklyView`` from subscription-week boundaries
|
|
577
578
|
(spec §5.3).
|
|
578
579
|
|
|
@@ -648,7 +649,8 @@ def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
|
|
|
648
649
|
week_start_at=sw.start_ts,
|
|
649
650
|
week_end_at=sw.end_ts,
|
|
650
651
|
)
|
|
651
|
-
usage_row = get_usage(conn, ref, as_of_utc=as_of_utc
|
|
652
|
+
usage_row = get_usage(conn, ref, as_of_utc=as_of_utc,
|
|
653
|
+
account_key=account_key)
|
|
652
654
|
used_pct = None
|
|
653
655
|
if usage_row is not None and usage_row["weekly_percent"] is not None:
|
|
654
656
|
used_pct = float(usage_row["weekly_percent"])
|
|
@@ -759,7 +761,7 @@ class TrendView:
|
|
|
759
761
|
|
|
760
762
|
|
|
761
763
|
def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
|
|
762
|
-
use_weekref_cost_cache=False):
|
|
764
|
+
use_weekref_cost_cache=False, account_key=None):
|
|
763
765
|
"""Build a ``TrendView`` of the last ``n`` subscription weeks
|
|
764
766
|
(spec §5.4).
|
|
765
767
|
|
|
@@ -799,7 +801,7 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
|
|
|
799
801
|
get_usage = _cct_core.get_latest_usage_for_week
|
|
800
802
|
c = _cctally()
|
|
801
803
|
|
|
802
|
-
week_refs = c.get_recent_weeks(conn, max(1, n))
|
|
804
|
+
week_refs = c.get_recent_weeks(conn, max(1, n), account_key=account_key)
|
|
803
805
|
if not week_refs:
|
|
804
806
|
return TrendView(
|
|
805
807
|
rows=(), avg_dollars_per_pct=None,
|
|
@@ -809,10 +811,13 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
|
|
|
809
811
|
|
|
810
812
|
# Determine current_key + current_week_start_at — same pattern as
|
|
811
813
|
# _tui_build_trend / cmd_report's Bug D handling.
|
|
814
|
+
_acct_pred = "" if account_key is None else " WHERE account_key = ?"
|
|
815
|
+
_acct_p = () if account_key is None else (account_key,)
|
|
812
816
|
latest_usage = conn.execute(
|
|
813
817
|
"SELECT week_start_date, week_end_date "
|
|
814
|
-
"FROM weekly_usage_snapshots
|
|
815
|
-
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
818
|
+
"FROM weekly_usage_snapshots" + _acct_pred +
|
|
819
|
+
" ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
820
|
+
_acct_p,
|
|
816
821
|
).fetchone()
|
|
817
822
|
current_key = None
|
|
818
823
|
current_week_start_at = None
|
|
@@ -858,6 +863,7 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
|
|
|
858
863
|
as_of_utc=(
|
|
859
864
|
week_ref.week_end_at if week_ref.key in split_keys else None
|
|
860
865
|
),
|
|
866
|
+
account_key=account_key,
|
|
861
867
|
)
|
|
862
868
|
usage_captured_at = usage["captured_at_utc"] if usage else None
|
|
863
869
|
if c._week_ref_has_reset_event(conn, week_ref):
|
|
@@ -881,12 +887,12 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
|
|
|
881
887
|
week_end_at=parse_iso(week_ref.week_end_at, "week_end_at"),
|
|
882
888
|
now_utc=now_utc,
|
|
883
889
|
compute=lambda: c._compute_cost_for_weekref(
|
|
884
|
-
week_ref, skip_sync=True
|
|
890
|
+
week_ref, skip_sync=True, account_key=account_key
|
|
885
891
|
),
|
|
886
892
|
)
|
|
887
893
|
else:
|
|
888
894
|
cost_usd = c._compute_cost_for_weekref(
|
|
889
|
-
week_ref, skip_sync=skip_sync
|
|
895
|
+
week_ref, skip_sync=skip_sync, account_key=account_key
|
|
890
896
|
)
|
|
891
897
|
cost_captured_at = (
|
|
892
898
|
usage_captured_at if cost_usd is not None else None
|
|
@@ -894,7 +900,8 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None, skip_sync=False,
|
|
|
894
900
|
range_start_iso = week_ref.week_start_at
|
|
895
901
|
range_end_iso = week_ref.week_end_at
|
|
896
902
|
else:
|
|
897
|
-
cost = c.get_latest_cost_for_week(
|
|
903
|
+
cost = c.get_latest_cost_for_week(
|
|
904
|
+
conn, week_ref, account_key=account_key)
|
|
898
905
|
cost_usd = float(cost["cost_usd"]) if cost else None
|
|
899
906
|
cost_captured_at = cost["captured_at_utc"] if cost else None
|
|
900
907
|
range_start_iso = (
|
|
@@ -1605,6 +1612,7 @@ def build_forecast_view(
|
|
|
1605
1612
|
skip_sync: bool = False,
|
|
1606
1613
|
display_tz=None,
|
|
1607
1614
|
use_weekref_cost_cache: bool = False,
|
|
1615
|
+
account_key=None,
|
|
1608
1616
|
):
|
|
1609
1617
|
"""Build a ``ForecastView`` (issue #57).
|
|
1610
1618
|
|
|
@@ -1626,6 +1634,7 @@ def build_forecast_view(
|
|
|
1626
1634
|
inputs = c._load_forecast_inputs(
|
|
1627
1635
|
conn, now_utc, skip_sync=skip_sync,
|
|
1628
1636
|
use_weekref_cost_cache=use_weekref_cost_cache,
|
|
1637
|
+
account_key=account_key,
|
|
1629
1638
|
)
|
|
1630
1639
|
if inputs is None:
|
|
1631
1640
|
return ForecastView(
|
package/bin/cctally
CHANGED
|
@@ -876,6 +876,20 @@ _cmd_config_set = _cctally_config._cmd_config_set
|
|
|
876
876
|
_cmd_config_unset = _cctally_config._cmd_config_unset
|
|
877
877
|
_get_quota_alerts_config = _cctally_config._get_quota_alerts_config
|
|
878
878
|
|
|
879
|
+
# Account subcommand + the R8 decoration helpers (#341 Task 3). Eager re-export
|
|
880
|
+
# so the parser's `set_defaults(func=c.cmd_account)` binds and the alerts /
|
|
881
|
+
# --account / doctor / dashboard consumers reach the shared registry helpers
|
|
882
|
+
# through the cctally namespace.
|
|
883
|
+
_cctally_account = _load_sibling("_cctally_account")
|
|
884
|
+
cmd_account = _cctally_account.cmd_account
|
|
885
|
+
load_accounts = _cctally_account.load_accounts
|
|
886
|
+
real_account_count = _cctally_account.real_account_count
|
|
887
|
+
provider_is_decorated = _cctally_account.provider_is_decorated
|
|
888
|
+
account_label = _cctally_account.account_label
|
|
889
|
+
resolve_active_account_keys = _cctally_account.resolve_active_account_keys
|
|
890
|
+
resolve_account_filter = _cctally_account.resolve_account_filter
|
|
891
|
+
account_json_fields = _cctally_account.account_json_fields
|
|
892
|
+
|
|
879
893
|
|
|
880
894
|
# `cctally refresh-usage` + the OAuth-usage fetch/render/config surface,
|
|
881
895
|
# the in-process refresh helper consumed by the dashboard `POST /api/sync`
|
|
@@ -1015,6 +1029,7 @@ _collect_codex_entries_direct = _cctally_cache._collect_codex_entries_direct
|
|
|
1015
1029
|
get_codex_entries = _cctally_cache.get_codex_entries
|
|
1016
1030
|
_sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
|
|
1017
1031
|
get_entries = _cctally_cache.get_entries
|
|
1032
|
+
AccountAttributionUnavailable = _cctally_cache.AccountAttributionUnavailable
|
|
1018
1033
|
open_cache_db = _cctally_cache.open_cache_db
|
|
1019
1034
|
open_conversations_db = _cctally_cache.open_conversations_db
|
|
1020
1035
|
sync_claude_conversations = _cctally_cache.sync_claude_conversations
|
|
@@ -1772,14 +1787,21 @@ def _sum_cost_for_range(
|
|
|
1772
1787
|
project: str | None = None,
|
|
1773
1788
|
*,
|
|
1774
1789
|
skip_sync: bool = False,
|
|
1790
|
+
account_key: "str | None" = None,
|
|
1775
1791
|
) -> float:
|
|
1776
1792
|
"""Sum USD cost of all Claude Code usage entries in [start, end].
|
|
1777
1793
|
|
|
1778
1794
|
Pass `skip_sync=True` to read only the cache-as-of-now without running
|
|
1779
1795
|
a JSONL ingest pass (for forecast --no-sync).
|
|
1796
|
+
|
|
1797
|
+
``account_key`` (#341, P2-CQ2) scopes the sum to one account's stamped
|
|
1798
|
+
entries; ``None`` (default) sums all accounts (merged, byte-identical). Used
|
|
1799
|
+
by the per-account ``sync-week`` cost materialization + per-account budget
|
|
1800
|
+
ladders so a snapshot / budget carries genuinely per-account cost.
|
|
1780
1801
|
"""
|
|
1781
1802
|
total = 0.0
|
|
1782
|
-
for entry in get_entries(start, end, project=project, skip_sync=skip_sync
|
|
1803
|
+
for entry in get_entries(start, end, project=project, skip_sync=skip_sync,
|
|
1804
|
+
account_key=account_key):
|
|
1783
1805
|
total += _calculate_entry_cost(
|
|
1784
1806
|
entry.model,
|
|
1785
1807
|
entry.usage,
|
|
@@ -2106,10 +2128,14 @@ def _load_claude_config_for_args(args: argparse.Namespace) -> "dict":
|
|
|
2106
2128
|
|
|
2107
2129
|
|
|
2108
2130
|
def _resolve_primary_model_for_block(
|
|
2109
|
-
conn: sqlite3.Connection, five_hour_window_key: int
|
|
2131
|
+
conn: sqlite3.Connection, five_hour_window_key: int,
|
|
2132
|
+
*, account_key: str = "unattributed",
|
|
2110
2133
|
) -> "str | None":
|
|
2111
2134
|
"""Return the highest-cost model active in this 5h block, or ``None``.
|
|
2112
2135
|
|
|
2136
|
+
``account_key`` (#341) scopes the child lookup so a shared physical window
|
|
2137
|
+
reflects THIS account's models.
|
|
2138
|
+
|
|
2113
2139
|
Reads from ``five_hour_block_models`` (recompute-every-tick rollup-child;
|
|
2114
2140
|
UNIQUE on ``(five_hour_window_key, model)``). Returns ``None`` when the
|
|
2115
2141
|
child table has no row for this window (possible when the parent block
|
|
@@ -2124,9 +2150,9 @@ def _resolve_primary_model_for_block(
|
|
|
2124
2150
|
"""
|
|
2125
2151
|
row = conn.execute(
|
|
2126
2152
|
"SELECT model FROM five_hour_block_models "
|
|
2127
|
-
"WHERE five_hour_window_key = ? "
|
|
2153
|
+
"WHERE five_hour_window_key = ? AND account_key = ? "
|
|
2128
2154
|
"ORDER BY cost_usd DESC, model ASC LIMIT 1",
|
|
2129
|
-
(int(five_hour_window_key),),
|
|
2155
|
+
(int(five_hour_window_key), account_key),
|
|
2130
2156
|
).fetchone()
|
|
2131
2157
|
if row is None:
|
|
2132
2158
|
return None
|
|
@@ -2881,6 +2907,13 @@ def _validate_source_args(args) -> None:
|
|
|
2881
2907
|
return
|
|
2882
2908
|
source = getattr(args, "source", "claude")
|
|
2883
2909
|
speed = getattr(args, "speed", "auto")
|
|
2910
|
+
# #341: `--account` on the source-aware analytics commands scopes the CLAUDE
|
|
2911
|
+
# analytics only (the account dimension is provider-scoped; source `all` has
|
|
2912
|
+
# no account selector, and Codex account filtering lives on `codex quota`).
|
|
2913
|
+
# Reject `--account` with `--source {codex,all}` (spec §3 / handoff item d).
|
|
2914
|
+
if getattr(args, "account", None) is not None and source in {"codex", "all"}:
|
|
2915
|
+
raise ValueError(
|
|
2916
|
+
f"--account is only valid with --source claude (got --source {source})")
|
|
2884
2917
|
if source == "claude" and speed != "auto":
|
|
2885
2918
|
raise ValueError("--speed is only valid for Codex or all-source requests")
|
|
2886
2919
|
if command == "project" and source in {"codex", "all"} and getattr(args, "sort", None) == "used":
|
|
@@ -3267,6 +3300,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
3267
3300
|
# generic DatabaseError so it never renders as a raw traceback.
|
|
3268
3301
|
eprint(f"cctally: {exc}")
|
|
3269
3302
|
return 3
|
|
3303
|
+
except AccountAttributionUnavailable as exc:
|
|
3304
|
+
# #341: a `--account`-scoped entry read hit a query-time cache degrade
|
|
3305
|
+
# (open failure / concurrent ingest) that would drop the account
|
|
3306
|
+
# identity. Fail closed (exit 3) rather than emit a mislabeled render.
|
|
3307
|
+
eprint(f"cctally: {exc}")
|
|
3308
|
+
return 3
|
|
3270
3309
|
except sqlite3.DatabaseError as exc:
|
|
3271
3310
|
if _is_sqlite_corruption_error(exc):
|
|
3272
3311
|
eprint(
|