cctally 1.102.0 → 1.103.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/README.md CHANGED
@@ -34,9 +34,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
34
34
 
35
35
  Highlights from the `v1.95.5` to `v1.101.0` stable upgrade:
36
36
 
37
- - Dashboard refreshes now release their coherent `cache.db` snapshot before constructing provider views, cutting the measured median read hold from 3.40 seconds to 1.04 seconds while preserving the exact published payload (#617).
38
- - Every dashboard warning state now offers the explanation for its own window. A row in Recent alerts, an alert toast, a warn or over budget block, and a capped forecast each carry a button that opens the surface explaining that warning: a weekly crossing opens the current week, a five-hour crossing opens that block, a calendar-month budget opens that month, a project-budget crossing opens that project's drill-down, and a projected-pace crossing opens the forecast. The window is checked before anything opens, a warning about a week the dashboard is no longer showing states that its window has closed and opens nothing at all, rather than quietly showing you the current week instead. A five-hour block still opens however old it is, as long as the alert recorded which block it was; an alert that recorded no block start says so and opens nothing. On Codex the button says `Open this cycle` rather than `Open this week`, because a Codex quota cycle is what it opens. A project-budget crossing on a machine with more than one account is recorded across all of them, so it states that it cannot be narrowed to one account's projects rather than picking one. Every button is an ordinary control reachable by Tab and activated by Enter or Space, and no keyboard shortcut is claimed (#620).
39
- - The dashboard's Projects table now writes out what its two percentages mean, under the week selector rather than only in a tooltip, a tooltip never appears on a phone. The `Used pp` column is relabelled `Used pp (sum)` and the caption states that it adds each week's attributed usage percentage across the selected window, so it is a sum of percentage points rather than a share of any one week; the caption also names the denominator of the `Cost share` column as total project spend in that same window. No column is added or removed. On an installation with more than one Claude account, both the Projects panel and the Projects table now also state that accounts are folded into one ranking and that their weekly quota percentages are never added together. That sentence appears on the Claude and All views only, because the Codex view ranks Codex projects and folds no Claude accounts at all (#620).
37
+ - Dashboard refreshes release their read lock on `cache.db` sooner, cutting the measured median hold from 3.40 seconds to 1.04. The published data is unchanged.
38
+ - Every dashboard warning carries a button that opens the surface explaining it, the week, the five-hour block, the month, the project, or the forecast. A warning whose window has closed says so and opens nothing.
39
+ - The dashboard's Projects table writes out what its two percentages mean under the week selector, where a phone can read it. `Used pp` is relabelled `Used pp (sum)`, and the caption names the denominator of `Cost share`.
40
40
 
41
41
  [See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.101.0)
42
42
  <!-- cctally:latest-stable:end -->
@@ -7327,6 +7327,20 @@ def sync_codex_cache(
7327
7327
  model=initial_model,
7328
7328
  total_tokens=initial_total_tokens,
7329
7329
  )
7330
+ if start_offset > 0 and not truncated and not requalified:
7331
+ prior_accounting = conn.execute(
7332
+ "SELECT MAX(timestamp_utc),MAX(line_offset) "
7333
+ "FROM codex_session_entries "
7334
+ "WHERE source_path=? AND line_offset<? ",
7335
+ (path_str, start_offset),
7336
+ ).fetchone()
7337
+ if prior_accounting is not None and prior_accounting[0] is not None:
7338
+ iter_state.last_accounting_timestamp = _parse_anchor_iso(
7339
+ prior_accounting[0])
7340
+ if prior_accounting is not None and prior_accounting[1] is not None:
7341
+ iter_state.generation_transition_pending = (
7342
+ _lib_jsonl._codex_generation_transition_between(
7343
+ path_str, int(prior_accounting[1]), start_offset))
7330
7344
  if (
7331
7345
  prev is not None and not truncated and not requalified
7332
7346
  and prev_native_thread_id is not None
@@ -11223,6 +11237,23 @@ def sync_codex_conversations(
11223
11237
  model=initial_model,
11224
11238
  total_tokens=initial_total_tokens,
11225
11239
  )
11240
+ if start_offset > 0 and not reset_file:
11241
+ try:
11242
+ prior_accounting = conn.execute(
11243
+ "SELECT MAX(timestamp_utc),MAX(line_offset) FROM "
11244
+ "cache_db.codex_session_entries "
11245
+ "WHERE source_path=? AND line_offset<? ",
11246
+ (path_str, start_offset),
11247
+ ).fetchone()
11248
+ except sqlite3.OperationalError:
11249
+ prior_accounting = None
11250
+ if prior_accounting is not None and prior_accounting[0] is not None:
11251
+ state.last_accounting_timestamp = _parse_anchor_iso(
11252
+ prior_accounting[0])
11253
+ if prior_accounting is not None and prior_accounting[1] is not None:
11254
+ state.generation_transition_pending = (
11255
+ _lib_jsonl._codex_generation_transition_between(
11256
+ path_str, int(prior_accounting[1]), start_offset))
11226
11257
  if initial_native and initial_root:
11227
11258
  state.thread = _lib_jsonl.CodexThreadMetadata(
11228
11259
  source_root_key=discovered.source_root_key,
package/bin/_lib_jsonl.py CHANGED
@@ -381,6 +381,19 @@ class _CodexIterState:
381
381
  # watermark by construction, and the caller persists exactly it (replacing
382
382
  # the old reconstructed initial+Σ(per-turn) sum, which could diverge).
383
383
  total_tokens: int = 0
384
+ # #647: provider time of the last accepted accounting event. The caller
385
+ # seeds this from the retained row immediately before a delta cursor, and
386
+ # the iterator advances it on every accepted accounting record. A lower
387
+ # cumulative can start a new producer generation only when its timestamp
388
+ # advances this independent chronological watermark; copied or
389
+ # out-of-order history at a new byte offset therefore cannot lower the
390
+ # cumulative watermark and make the copied suffix count twice.
391
+ last_accounting_timestamp: dt.datetime | None = None
392
+ # A provider lifecycle record (turn_context/task_started) observed since
393
+ # the last accepted accounting row. Token-count re-emissions cannot set
394
+ # this bit themselves, so it is the producer-transition evidence required
395
+ # before a restart-shaped lower cumulative may replace the watermark.
396
+ generation_transition_pending: bool = False
384
397
  # #279 S2 F1 parse-health counters — per-iterator-call; sync_codex_cache
385
398
  # folds them into CodexIngestStats after each file drains. Reason
386
399
  # vocabulary: info-non-dict / no-last-token-usage / bad-timestamp /
@@ -869,6 +882,57 @@ def _reject_nonfinite_json_constant(value: str) -> None:
869
882
  raise ValueError(f"non-finite JSON constant: {value}")
870
883
 
871
884
 
885
+ def _codex_generation_transition_between(
886
+ path_str: str, after_offset: int, before_offset: int,
887
+ ) -> bool:
888
+ """Return whether a producer lifecycle boundary exists in this byte gap.
889
+
890
+ ``after_offset`` is the physical row of the last retained accounting
891
+ event, so that row is skipped. ``before_offset`` is a durable resume cursor
892
+ and is never consumed. This reconstructs the one bit of parser state a
893
+ metadata-only or budgeted pass can leave between the last accounting row
894
+ and the next token event without adding another cache schema column.
895
+ Malformed or unreadable evidence fails closed.
896
+ """
897
+ if after_offset < 0 or before_offset <= after_offset:
898
+ return False
899
+ try:
900
+ with open(path_str, "rb") as fh:
901
+ fh.seek(after_offset)
902
+ fh.readline() # skip the retained accounting record itself
903
+ while fh.tell() < before_offset:
904
+ line_offset = fh.tell()
905
+ line = fh.readline()
906
+ if not line or not line.endswith(b"\n"):
907
+ return False
908
+ if fh.tell() > before_offset:
909
+ return False
910
+ try:
911
+ obj = json.loads(
912
+ line.decode("utf-8"),
913
+ parse_constant=_reject_nonfinite_json_constant,
914
+ )
915
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError):
916
+ continue
917
+ if not isinstance(obj, dict) or not _json_value_is_finite(obj):
918
+ continue
919
+ payload = (
920
+ obj.get("payload")
921
+ if isinstance(obj.get("payload"), dict)
922
+ else {}
923
+ )
924
+ if obj.get("type") == "turn_context" or (
925
+ obj.get("type") == "event_msg"
926
+ and payload.get("type") == "task_started"
927
+ ):
928
+ return True
929
+ if fh.tell() <= line_offset: # defensive progress invariant
930
+ return False
931
+ except OSError:
932
+ return False
933
+ return False
934
+
935
+
872
936
  def _seed_codex_iter_state(
873
937
  state: _CodexIterState, initial_session_id: str | None,
874
938
  initial_model: str | None, initial_total_tokens: int,
@@ -934,6 +998,11 @@ def _accounting_from_record(
934
998
  and state.thread.parent_thread_id is not None):
935
999
  return None, last_total_tokens, filename_session_id_warned
936
1000
 
1001
+ timestamp = _parse_codex_timestamp(obj.get("timestamp"))
1002
+ if timestamp is None:
1003
+ _codex_skip(state, "bad-timestamp")
1004
+ return None, last_total_tokens, filename_session_id_warned
1005
+
937
1006
  total_token_usage = info.get("total_token_usage")
938
1007
  if isinstance(total_token_usage, dict):
939
1008
  try:
@@ -941,14 +1010,33 @@ def _accounting_from_record(
941
1010
  except (TypeError, ValueError):
942
1011
  cumulative = 0
943
1012
  if cumulative <= last_total_tokens:
944
- return None, last_total_tokens, filename_session_id_warned
1013
+ # A continued Codex producer can append to the same rollout while
1014
+ # restarting its cumulative counter at zero. The first record of
1015
+ # that new generation is self-originating: its cumulative total is
1016
+ # exactly the complete provider-native last-turn total. A distinct
1017
+ # producer lifecycle record must have occurred since the last
1018
+ # accepted accounting row, and this event must also advance the
1019
+ # retained maximum timestamp. Token-count re-emissions cannot
1020
+ # manufacture the lifecycle evidence, even when they carry fresh
1021
+ # timestamps. Equal totals remain duplicates, and arbitrary or
1022
+ # non-monotonic lower totals remain noise.
1023
+ try:
1024
+ last_usage_total = int(last_token_usage.get("total_tokens") or 0)
1025
+ except (TypeError, ValueError):
1026
+ last_usage_total = 0
1027
+ starts_generation = (
1028
+ state.generation_transition_pending
1029
+ and cumulative < last_total_tokens
1030
+ and cumulative > 0
1031
+ and cumulative == last_usage_total
1032
+ and state.last_accounting_timestamp is not None
1033
+ and timestamp > state.last_accounting_timestamp
1034
+ )
1035
+ if not starts_generation:
1036
+ return None, last_total_tokens, filename_session_id_warned
945
1037
  else:
946
1038
  cumulative = None
947
1039
 
948
- timestamp = _parse_codex_timestamp(obj.get("timestamp"))
949
- if timestamp is None:
950
- _codex_skip(state, "bad-timestamp")
951
- return None, last_total_tokens, filename_session_id_warned
952
1040
  session_id = state.session_id
953
1041
  if session_id is None:
954
1042
  session_id = filename_uuid
@@ -982,6 +1070,12 @@ def _accounting_from_record(
982
1070
  if cumulative is not None:
983
1071
  state.total_tokens = cumulative
984
1072
  last_total_tokens = cumulative
1073
+ if (
1074
+ state.last_accounting_timestamp is None
1075
+ or timestamp > state.last_accounting_timestamp
1076
+ ):
1077
+ state.last_accounting_timestamp = timestamp
1078
+ state.generation_transition_pending = False
985
1079
  return entry, last_total_tokens, filename_session_id_warned
986
1080
 
987
1081
 
@@ -1076,6 +1170,9 @@ def _iter_codex_fused_records_with_offsets(
1076
1170
  model = _codex_string(payload.get("model"))
1077
1171
  if model is not None:
1078
1172
  state.model = model.strip()
1173
+ state.generation_transition_pending = True
1174
+ elif obj.get("type") == "event_msg" and payload.get("type") == "task_started":
1175
+ state.generation_transition_pending = True
1079
1176
 
1080
1177
  event = _event_from_record(
1081
1178
  obj, path_str, line_offset, source_root_key, state.thread
@@ -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-08-13"
56
+ PRICING_SNAPSHOT_DATE = "2026-08-25"
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).
@@ -73,6 +73,22 @@ LITELLM_PRICES_URL = (
73
73
  # currently mirrors successor Mythos 5's lower $10/$50 rate onto the Preview
74
74
  # identifier. Retained Preview rows therefore keep the explicit historical
75
75
  # rate rather than being rewritten to the successor's rate.
76
+ #
77
+ # gpt-5.6-sol (#643): OpenAI's pricing page states that "GPT-5.6 Sol's
78
+ # promotional pricing is available at least through November 21, 2026" and
79
+ # publishes no post-promotional price, so LiteLLM's $4/$20 per MTok rate is
80
+ # time-boxed. This table is date-blind and roughly half the retained Sol rows
81
+ # predate the promotion, so the pre-promotional $5/$30 card is kept and the
82
+ # promotion is suppressed here. `expires` is what stops the divergence
83
+ # ossifying: `stale_allowlist_entries` only fires if LiteLLM reverts, so a
84
+ # promotion made permanent would otherwise never surface.
85
+ #
86
+ # gpt-5.6 (#643): a model-only entry, which suppresses `missing_from_us` rather
87
+ # than a value drift. OpenAI lists `gpt-5.6` as an alias of `gpt-5.6-sol`, so
88
+ # CODEX_MODEL_ALIASES resolves it instead of duplicating the rate card.
89
+ # `diff_pricing` keys on raw table membership and is deliberately NOT
90
+ # alias-aware, so the omission needs this entry. It is self-policing: it goes
91
+ # stale automatically if upstream drops `gpt-5.6` or we restore a direct card.
76
92
  PRICING_DRIFT_ALLOWLIST: list[dict] = [
77
93
  {
78
94
  "model": "claude-mythos-preview",
@@ -90,6 +106,39 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
90
106
  "cache_creation_input_token_cost",
91
107
  "cache_read_input_token_cost",
92
108
  )
109
+ ] + [
110
+ {
111
+ "model": "gpt-5.6-sol",
112
+ "field": field,
113
+ "expires": "2026-11-21",
114
+ "reason": (
115
+ "OpenAI's pricing page guarantees GPT-5.6 Sol's promotional "
116
+ "$4/$20 per MTok rate only 'at least through November 21, 2026' "
117
+ "and publishes no post-promotional price. This table is "
118
+ "date-blind, so the pre-promotional $5/$30 card is kept rather "
119
+ "than adopting a rate that would have to be reverted by hand "
120
+ "when the promotion ends (#643)."
121
+ ),
122
+ }
123
+ for field in (
124
+ "input_cost_per_token",
125
+ "cache_read_input_token_cost",
126
+ "output_cost_per_token",
127
+ "input_cost_per_token_above_272k_tokens",
128
+ "cache_read_input_token_cost_above_272k_tokens",
129
+ "output_cost_per_token_above_272k_tokens",
130
+ )
131
+ ] + [
132
+ {
133
+ "model": "gpt-5.6",
134
+ "reason": (
135
+ "OpenAI lists gpt-5.6 as an alias of gpt-5.6-sol, so "
136
+ "CODEX_MODEL_ALIASES resolves it to Sol's card and this table "
137
+ "carries no duplicate entry. `diff_pricing` is deliberately not "
138
+ "alias-aware, so the intentional omission is suppressed here "
139
+ "(#643)."
140
+ ),
141
+ },
93
142
  ]
94
143
 
95
144
  # Anthropic API pricing snapshot:
@@ -398,10 +447,11 @@ _unknown_model_warnings: set[str] = set()
398
447
  # Codex (OpenAI) API pricing snapshot:
399
448
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
400
449
  # - Captured: 2026-07-19 — the last FULL Codex sync. PRICING_SNAPSHOT_DATE has
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.
450
+ # since moved for four targeted syncs (2026-07-24, the Claude-side opus-5
451
+ # sync; 2026-07-31, the gpt-5.6-terra/-luna correction logged below;
452
+ # 2026-08-13, the Claude-side Sonnet/Mythos sync above; and 2026-08-25, the
453
+ # gpt-5.6-cyber addition logged below). Codex values outside those two Codex
454
+ # corrections were NOT re-verified on those days.
405
455
  # - As of the 2026-07-19 sync this carries every openai-provider
406
456
  # gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
407
457
  # nothing missing. Models absent from this table still fall back to `gpt-5`
@@ -425,6 +475,21 @@ _unknown_model_warnings: set[str] = set()
425
475
  # the vendor lists these as standard ongoing prices with no promotional or
426
476
  # expiring annotation, so the durable rate is the cut rate. gpt-5.6 and
427
477
  # gpt-5.6-sol were not repriced and are unchanged.
478
+ # 2026-08-25 (#643): added gpt-5.6-cyber at OpenAI's published $12.50 input /
479
+ # $1.25 cached input / $75.00 output per MTok, with LiteLLM's above-272k tier
480
+ # ($25.00 / $2.50 / $112.50 per MTok). The vendor page shows dashes in the
481
+ # long-context columns, but max_input_tokens is 400,000, so a turn above the
482
+ # 272,000 threshold is reachable and pricing it at the base rate would be
483
+ # knowingly wrong; LiteLLM's tier ratios match every other gpt-5.6 member.
484
+ # Removed the duplicated gpt-5.6 card and aliased that identifier to
485
+ # gpt-5.6-sol, which OpenAI lists it as an alias of. gpt-5.6-sol's own values
486
+ # are UNCHANGED: LiteLLM now carries OpenAI's promotional $4/$20 per MTok
487
+ # rate, which the vendor guarantees only "at least through November 21, 2026"
488
+ # and publishes no successor for. That promotion is suppressed in
489
+ # PRICING_DRIFT_ALLOWLIST with expires 2026-11-21 instead of being written
490
+ # into this date-blind table, because roughly half the retained Sol rows
491
+ # predate it. The accepted cost is that while the promotion runs, Sol
492
+ # reporting is high by 25% on input and 50% on output.
428
493
  #
429
494
  # Billing rules:
430
495
  # - reasoning_output_tokens is billed at the *output* rate (matches
@@ -518,19 +583,15 @@ CODEX_MODEL_PRICING: dict[str, dict[str, Any]] = {
518
583
  "output_cost_per_token_above_272k_tokens": 4.5e-05,
519
584
  },
520
585
  # ── gpt-5.6 family (LiteLLM openai-provider entries) ──
521
- # Exact model_prices_and_context_window.json values; each carries the
522
- # above-272k tier (max_input_tokens 1050000). gpt-5.6 and gpt-5.6-sol keep
523
- # gpt-5.5's rate card from the 2026-07-10 sync; -terra and -luna carry
524
- # OpenAI's 2026-07-30 post-cut rates (#441) and no longer track gpt-5.4's
525
- # card or any other model's.
526
- "gpt-5.6": {
527
- "input_cost_per_token": 5e-06,
528
- "cache_read_input_token_cost": 5e-07,
529
- "output_cost_per_token": 3e-05,
530
- "input_cost_per_token_above_272k_tokens": 1e-05,
531
- "cache_read_input_token_cost_above_272k_tokens": 1e-06,
532
- "output_cost_per_token_above_272k_tokens": 4.5e-05,
533
- },
586
+ # Every member carries the above-272k tier, but the context windows differ:
587
+ # -sol, -terra and -luna are max_input_tokens 922,000 and -cyber is 400,000.
588
+ # gpt-5.6-sol keeps gpt-5.5's rate card from the 2026-07-10 sync and is
589
+ # deliberately HELD there while OpenAI's promotion runs see the
590
+ # gpt-5.6-sol block in PRICING_DRIFT_ALLOWLIST (#643). -terra and -luna
591
+ # carry OpenAI's 2026-07-30 post-cut rates (#441) and no longer track
592
+ # gpt-5.4's card or any other model's. -cyber carries its own launch rates.
593
+ # The bare `gpt-5.6` identifier is OpenAI's alias of -sol and has NO card
594
+ # here; CODEX_MODEL_ALIASES resolves it.
534
595
  "gpt-5.6-sol": {
535
596
  "input_cost_per_token": 5e-06,
536
597
  "cache_read_input_token_cost": 5e-07,
@@ -555,6 +616,16 @@ CODEX_MODEL_PRICING: dict[str, dict[str, Any]] = {
555
616
  "cache_read_input_token_cost_above_272k_tokens": 4e-08,
556
617
  "output_cost_per_token_above_272k_tokens": 1.8e-06,
557
618
  },
619
+ # No cache_creation field: the Codex cost kernel never reads one, so
620
+ # carrying LiteLLM's would only give `diff_pricing` a value to compare.
621
+ "gpt-5.6-cyber": {
622
+ "input_cost_per_token": 1.25e-05,
623
+ "cache_read_input_token_cost": 1.25e-06,
624
+ "output_cost_per_token": 7.5e-05,
625
+ "input_cost_per_token_above_272k_tokens": 2.5e-05,
626
+ "cache_read_input_token_cost_above_272k_tokens": 2.5e-06,
627
+ "output_cost_per_token_above_272k_tokens": 1.125e-04,
628
+ },
558
629
  # ── Issue #123: full gpt-5.x LiteLLM sync (2026-05-30 snapshot) ──
559
630
  # Exact model_prices_and_context_window.json values for every
560
631
  # openai-provider gpt-5* model `pricing-check`'s scope flags but the
@@ -740,11 +811,16 @@ CODEX_LEGACY_FALLBACK_MODEL = "gpt-5"
740
811
  # drift retains one source of truth per card. OpenAI's live pricing page states
741
812
  # that ``daybreak-blue-latest`` currently points to ``gpt-5.6-sol`` and inherits
742
813
  # the underlying model's pricing; Codex emits the prefixed runtime identifier
743
- # retained below. ``codex-auto-review`` is the hidden Guardian model and maps to
744
- # the model current when it appeared, covering every retained event observed
745
- # for issue #535.
814
+ # retained below. OpenAI's models page lists the bare ``gpt-5.6`` identifier as
815
+ # an alias of ``gpt-5.6-sol`` too, so it resolves here rather than duplicating
816
+ # Sol's rates (#643) — its intentional absence from CODEX_MODEL_PRICING is
817
+ # covered by a model-only PRICING_DRIFT_ALLOWLIST entry.
818
+ # ``codex-auto-review`` is the hidden Guardian model and maps to the model
819
+ # current when it appeared, covering every retained event observed for issue
820
+ # #535.
746
821
  CODEX_MODEL_ALIASES: dict[str, str] = {
747
822
  "codex-auto-review": "gpt-5.5",
823
+ "gpt-5.6": "gpt-5.6-sol",
748
824
  "gpt-daybreak-blue-latest": "gpt-5.6-sol",
749
825
  }
750
826
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.102.0",
3
+ "version": "1.103.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {