cctally 1.15.0 → 1.17.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 CHANGED
@@ -5,6 +5,22 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.17.0] - 2026-05-27
9
+
10
+ ### Added
11
+ - **`--speed {auto,standard,fast}` on `cctally codex-daily`, `codex-monthly`, `codex-weekly`, and `codex-session` (and their `cctally codex <cmd>` subgroup forms)** — a drop-in for `ccusage codex --speed`. `auto` (the default) reads `service_tier` from `~/.codex/config.toml` and applies fast-tier pricing when it is `fast` or `priority`; `fast`/`standard` force the tier. Fast-tier multiplies the per-model Codex cost by a fixed factor (`gpt-5.5` ×2.5, all other models ×2.0). `--json` gains no new field; only the `costUSD` figures change. On the flat `codex-*` forms this is a cctally extension (the standalone `ccusage-codex` binary has no `--speed`); the subgroup form mirrors `ccusage codex`. (#86)
12
+
13
+ ### Changed
14
+ - **`cctally codex-*` now applies fast-tier (≥2×) Codex pricing by default when `~/.codex/config.toml` sets `service_tier = "fast"` or `"priority"`.** Previously cctally always priced Codex usage at the standard tier, under-reporting cost for workstations on the fast/priority tier (who pay the premium). With the new `--speed auto` default, those costs now match what was actually billed; pass `--speed standard` to force the old behavior. Users without a fast/priority `service_tier` see identical numbers. (#86)
15
+
16
+ ## [1.16.0] - 2026-05-26
17
+
18
+ ### Added
19
+ - **`-m/--mode {auto,calculate,display}` cost-source selector on `cctally daily`, `monthly`, `weekly`, `session`, and `blocks`** — a drop-in for `ccusage <cmd> --mode`. `auto` (the default) uses the recorded `costUSD` from JSONL when present and otherwise computes from embedded pricing; `calculate` always computes from pricing, ignoring any recorded `costUSD`; `display` shows the recorded `costUSD` only and renders `$0.00` when an entry has none (ccusage-faithful — and because most modern Claude Code JSONL omits `costUSD`, `display` reports `$0` for nearly everything). On `blocks` the mode is honored on both the main grouping and the active canonical-swapped block. `cctally five-hour-blocks` also accepts `--mode` but as a documented no-op, since its cost is the authoritative per-block value materialized at record-time. The default-`auto` output is byte-identical to before for `daily`, `monthly`, `weekly`, and `blocks`, and no `mode` key is added to any JSON shape (see the Changed note below for the one `session` exception). (#86)
20
+
21
+ ### Changed
22
+ - **`cctally session` (and the TUI session views) now prefer a session's recorded `costUSD` over a recomputed cost by default, for the historical sessions whose JSONL still carries it.** `session` was the one report that always recomputed cost from embedded pricing and ignored any recorded `costUSD`, even though `daily`, `monthly`, `weekly`, and the dashboard already preferred the recorded value — so on `costUSD`-bearing windows `cctally session` totals silently disagreed with `cctally daily`. Wiring the new `--mode auto` default through the session aggregator closes that gap: default totals now match the other reports (and the shipped reconcile invariant `Σ session.totalCost == Σ daily.totalCost`). Only the ~3.9% of historical files that still carry `costUSD` are affected (modern Claude Code omits it); everything else is unchanged. The TUI session panel/detail follow the same default and have no `--mode` flag; pass `cctally session --mode calculate` on the CLI to force the previous always-recompute behavior. (#86)
23
+
8
24
  ## [1.15.0] - 2026-05-26
9
25
 
10
26
  ### Added
@@ -363,6 +363,7 @@ class ClaudeSessionUsage:
363
363
  def _aggregate_codex_buckets(
364
364
  entries: list[CodexEntry],
365
365
  key_fn: Callable[[CodexEntry], str],
366
+ speed: str = "standard",
366
367
  ) -> list[CodexBucketUsage]:
367
368
  """Group CodexEntry list into per-bucket records sorted by key ascending.
368
369
 
@@ -386,6 +387,7 @@ def _aggregate_codex_buckets(
386
387
  entry.cached_input_tokens,
387
388
  entry.output_tokens,
388
389
  entry.reasoning_output_tokens,
390
+ speed=speed,
389
391
  )
390
392
 
391
393
  bucket["input"] += entry.input_tokens
@@ -441,6 +443,7 @@ def _aggregate_codex_buckets(
441
443
 
442
444
  def _aggregate_codex_daily(
443
445
  entries: list[CodexEntry], *, tz_name: str | None = None,
446
+ speed: str = "standard",
444
447
  ) -> list[CodexBucketUsage]:
445
448
  """Daily grouping. Default: local tz. With ``tz_name``: that IANA zone."""
446
449
  tz = _resolve_tz(tz_name)
@@ -448,11 +451,12 @@ def _aggregate_codex_daily(
448
451
  key_fn = lambda e: e.timestamp.astimezone(tz).strftime("%Y-%m-%d") # noqa: E731
449
452
  else:
450
453
  key_fn = lambda e: e.timestamp.astimezone().strftime("%Y-%m-%d") # noqa: E731
451
- return _aggregate_codex_buckets(entries, key_fn=key_fn)
454
+ return _aggregate_codex_buckets(entries, key_fn=key_fn, speed=speed)
452
455
 
453
456
 
454
457
  def _aggregate_codex_monthly(
455
458
  entries: list[CodexEntry], *, tz_name: str | None = None,
459
+ speed: str = "standard",
456
460
  ) -> list[CodexBucketUsage]:
457
461
  """Monthly grouping. Default: local tz. With ``tz_name``: that IANA zone."""
458
462
  tz = _resolve_tz(tz_name)
@@ -460,13 +464,14 @@ def _aggregate_codex_monthly(
460
464
  key_fn = lambda e: e.timestamp.astimezone(tz).strftime("%Y-%m") # noqa: E731
461
465
  else:
462
466
  key_fn = lambda e: e.timestamp.astimezone().strftime("%Y-%m") # noqa: E731
463
- return _aggregate_codex_buckets(entries, key_fn=key_fn)
467
+ return _aggregate_codex_buckets(entries, key_fn=key_fn, speed=speed)
464
468
 
465
469
 
466
470
  def _aggregate_codex_weekly(
467
471
  entries: list[CodexEntry],
468
472
  tz_name: str | None,
469
473
  week_start_idx: int,
474
+ speed: str = "standard",
470
475
  ) -> list[CodexBucketUsage]:
471
476
  """Group Codex entries by calendar week.
472
477
 
@@ -485,7 +490,7 @@ def _aggregate_codex_weekly(
485
490
  week_start = local_date - dt.timedelta(days=diff)
486
491
  return week_start.isoformat()
487
492
 
488
- return _aggregate_codex_buckets(entries, key_fn=_week_key)
493
+ return _aggregate_codex_buckets(entries, key_fn=_week_key, speed=speed)
489
494
 
490
495
 
491
496
  def _session_path_parts(source_path: str) -> tuple[str, str, str]:
@@ -520,7 +525,7 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
520
525
  return str(stem), stem.name, str(stem.parent)
521
526
 
522
527
 
523
- def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsage]:
528
+ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
524
529
  """Group by session file path (upstream-compatible).
525
530
 
526
531
  Sessions are keyed by the full relative-path-without-.jsonl rather than
@@ -543,7 +548,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsa
543
548
  })
544
549
  cost = _calculate_codex_entry_cost(
545
550
  entry.model, entry.input_tokens, entry.cached_input_tokens,
546
- entry.output_tokens, entry.reasoning_output_tokens,
551
+ entry.output_tokens, entry.reasoning_output_tokens, speed=speed,
547
552
  )
548
553
  sess["input"] += entry.input_tokens
549
554
  sess["cached_input"] += entry.cached_input_tokens
@@ -608,6 +613,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsa
608
613
 
609
614
  def _aggregate_claude_sessions(
610
615
  entries: list["_JoinedClaudeEntry"],
616
+ mode: str = "auto",
611
617
  ) -> list[ClaudeSessionUsage]:
612
618
  """Group entries by session_id, collapsing resumed-across-files sessions.
613
619
 
@@ -666,7 +672,7 @@ def _aggregate_claude_sessions(
666
672
  "cache_creation_input_tokens": entry.cache_creation_tokens,
667
673
  "cache_read_input_tokens": entry.cache_read_tokens,
668
674
  }
669
- cost = _calculate_entry_cost(entry.model, usage)
675
+ cost = _calculate_entry_cost(entry.model, usage, mode=mode, cost_usd=entry.cost_usd)
670
676
 
671
677
  sess["input"] += entry.input_tokens
672
678
  sess["cache_create"] += entry.cache_creation_tokens
@@ -346,6 +346,44 @@ _unknown_codex_model_warnings: set[str] = set()
346
346
  # directly comparable.
347
347
  CODEX_LEGACY_FALLBACK_MODEL = "gpt-5"
348
348
 
349
+ # Per-model fast-tier price multipliers, ported from ryoppippi/ccusage
350
+ # fast-multiplier-overrides.json ("exact" map — Codex/gpt entries only; the
351
+ # upstream claude-opus-* entries are for ccusage's Claude adapter and never
352
+ # price Codex models). Any fast-tier model NOT listed falls back to
353
+ # CODEX_FAST_MULTIPLIER_FALLBACK — upstream's `fast_multiplier == 1.0 → 2.0`
354
+ # rule in adapter/codex/report.rs:calculate_codex_model_cost.
355
+ CODEX_FAST_MULTIPLIER_OVERRIDES: dict[str, float] = {
356
+ "gpt-5.5": 2.5,
357
+ "gpt-5.4": 2.0,
358
+ "gpt-5.3-codex": 2.0,
359
+ }
360
+ CODEX_FAST_MULTIPLIER_FALLBACK = 2.0
361
+
362
+
363
+ def _codex_fast_multiplier(model: str) -> float:
364
+ """Fast-tier price multiplier for a Codex model (standard tier = 1.0)."""
365
+ return CODEX_FAST_MULTIPLIER_OVERRIDES.get(model, CODEX_FAST_MULTIPLIER_FALLBACK)
366
+
367
+
368
+ def _codex_config_requests_fast_service_tier(content: str) -> bool:
369
+ """True iff any line sets ``service_tier = "fast"|"priority"``.
370
+
371
+ Naive line-scan ported from ryoppippi/ccusage adapter/codex/speed.rs
372
+ (NOT a TOML parse): strip the trailing ``#``-comment, split on the first
373
+ ``=``, the key must be exactly ``service_tier``, and the quote-stripped
374
+ value must be ``fast`` or ``priority``. Matches a ``service_tier`` line in
375
+ ANY table; ignores ``service_tier_override`` and substrings like
376
+ ``"breakfast"``.
377
+ """
378
+ for line in content.splitlines():
379
+ setting = line.split("#", 1)[0].strip()
380
+ key, sep, value = setting.partition("=")
381
+ if not sep or key.strip() != "service_tier":
382
+ continue
383
+ if value.strip().strip("\"'") in ("fast", "priority"):
384
+ return True
385
+ return False
386
+
349
387
 
350
388
  def _resolve_codex_pricing(model: str) -> tuple[dict[str, Any] | None, bool]:
351
389
  """Return (pricing_dict, is_fallback).
@@ -450,6 +488,7 @@ def _calculate_codex_entry_cost(
450
488
  cached_input_tokens: int,
451
489
  output_tokens: int,
452
490
  reasoning_output_tokens: int,
491
+ speed: str = "standard",
453
492
  ) -> float:
454
493
  """Compute USD cost for one Codex `token_count` event.
455
494
 
@@ -505,7 +544,10 @@ def _calculate_codex_entry_cost(
505
544
  "output_cost_per_token",
506
545
  "output_cost_per_token_above_272k_tokens",
507
546
  )
508
- return input_cost + cached_input_cost + output_cost
547
+ base = input_cost + cached_input_cost + output_cost
548
+ if speed == "fast":
549
+ base *= _codex_fast_multiplier(model)
550
+ return base
509
551
 
510
552
 
511
553
  def _short_model_name(model: str) -> str:
@@ -318,7 +318,7 @@ class DailyView:
318
318
  display_tz_label: str = ""
319
319
 
320
320
 
321
- def build_daily_view(entries, *, now_utc, display_tz=None):
321
+ def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto"):
322
322
  """Build a ``DailyView`` from raw ``UsageEntry`` list (spec §5.1).
323
323
 
324
324
  Gap-free: only days with entries appear in ``view.rows`` /
@@ -336,7 +336,7 @@ def build_daily_view(entries, *, now_utc, display_tz=None):
336
336
  share consumers ignore them and read ``view.aggregated`` instead.
337
337
  """
338
338
  _agg = _load_lib("_lib_aggregators")
339
- buckets = _agg._aggregate_daily(entries, mode="auto", tz=display_tz)
339
+ buckets = _agg._aggregate_daily(entries, mode=mode, tz=display_tz)
340
340
  if not buckets:
341
341
  return DailyView(
342
342
  rows=(),
@@ -427,7 +427,7 @@ class MonthlyView:
427
427
  display_tz_label: str = ""
428
428
 
429
429
 
430
- def build_monthly_view(entries, *, now_utc, n=12, display_tz=None):
430
+ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto"):
431
431
  """Build a ``MonthlyView`` for the trailing ``n`` calendar months
432
432
  (spec §5.2).
433
433
 
@@ -440,7 +440,7 @@ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None):
440
440
  CLI table footer would.
441
441
  """
442
442
  _agg = _load_lib("_lib_aggregators")
443
- buckets = _agg._aggregate_monthly(entries, mode="auto", tz=display_tz)
443
+ buckets = _agg._aggregate_monthly(entries, mode=mode, tz=display_tz)
444
444
  if not buckets:
445
445
  return MonthlyView(
446
446
  rows=(), aggregated=(),
@@ -528,7 +528,7 @@ class WeeklyView:
528
528
 
529
529
 
530
530
  def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
531
- as_of_utc=None):
531
+ as_of_utc=None, mode="auto"):
532
532
  """Build a ``WeeklyView`` from subscription-week boundaries
533
533
  (spec §5.3).
534
534
 
@@ -548,7 +548,7 @@ def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
548
548
  """
549
549
  _agg = _load_lib("_lib_aggregators")
550
550
  _cct_core = _load_lib("_cctally_core")
551
- buckets_asc = _agg._aggregate_weekly(entries, weeks)
551
+ buckets_asc = _agg._aggregate_weekly(entries, weeks, mode=mode)
552
552
  if not buckets_asc:
553
553
  return WeeklyView(
554
554
  rows=(), aggregated=(), overlay=(),
@@ -1022,7 +1022,7 @@ class SessionsView:
1022
1022
  display_tz_label: str = ""
1023
1023
 
1024
1024
 
1025
- def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
1025
+ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None, mode="auto"):
1026
1026
  """Build a ``SessionsView`` from joined Claude session entries
1027
1027
  (spec §5.5).
1028
1028
 
@@ -1053,7 +1053,7 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
1053
1053
  """
1054
1054
  import os as _os # late: keep top-level imports lean.
1055
1055
  _agg = _load_lib("_lib_aggregators")
1056
- aggregated = _agg._aggregate_claude_sessions(entries)
1056
+ aggregated = _agg._aggregate_claude_sessions(entries, mode=mode)
1057
1057
  # Apply limit truncation up front so `rows` and `aggregated` stay
1058
1058
  # in lockstep (spec §4.3 invariant: `total_sessions == len(rows)
1059
1059
  # == len(aggregated)`). limit=None → keep everything.
@@ -1703,7 +1703,7 @@ def _codex_period_start_from_month_bucket(buckets) -> "dt.datetime | None":
1703
1703
  return None
1704
1704
 
1705
1705
 
1706
- def build_codex_daily_view(entries, *, now_utc, tz_name=None):
1706
+ def build_codex_daily_view(entries, *, now_utc, tz_name=None, speed="standard"):
1707
1707
  """Build a ``CodexDailyView`` from a list of ``CodexEntry`` (issue #58).
1708
1708
 
1709
1709
  Delegates bucketing to ``_aggregate_codex_daily`` (LiteLLM-snapshot
@@ -1712,7 +1712,7 @@ def build_codex_daily_view(entries, *, now_utc, tz_name=None):
1712
1712
  (None → host-local fallback inside the aggregator).
1713
1713
  """
1714
1714
  _agg = _load_lib("_lib_aggregators")
1715
- buckets = _agg._aggregate_codex_daily(entries, tz_name=tz_name)
1715
+ buckets = _agg._aggregate_codex_daily(entries, tz_name=tz_name, speed=speed)
1716
1716
  total_cost, total_tok = _codex_bucket_totals(buckets)
1717
1717
  return CodexDailyView(
1718
1718
  rows=tuple(buckets),
@@ -1724,7 +1724,7 @@ def build_codex_daily_view(entries, *, now_utc, tz_name=None):
1724
1724
  )
1725
1725
 
1726
1726
 
1727
- def build_codex_monthly_view(entries, *, now_utc, tz_name=None):
1727
+ def build_codex_monthly_view(entries, *, now_utc, tz_name=None, speed="standard"):
1728
1728
  """Build a ``CodexMonthlyView`` from a list of ``CodexEntry`` (issue #58).
1729
1729
 
1730
1730
  Same wrap-the-kernel posture as ``build_codex_daily_view``; bucket
@@ -1732,7 +1732,7 @@ def build_codex_monthly_view(entries, *, now_utc, tz_name=None):
1732
1732
  earliest visible month at UTC midnight.
1733
1733
  """
1734
1734
  _agg = _load_lib("_lib_aggregators")
1735
- buckets = _agg._aggregate_codex_monthly(entries, tz_name=tz_name)
1735
+ buckets = _agg._aggregate_codex_monthly(entries, tz_name=tz_name, speed=speed)
1736
1736
  total_cost, total_tok = _codex_bucket_totals(buckets)
1737
1737
  return CodexMonthlyView(
1738
1738
  rows=tuple(buckets),
@@ -1745,7 +1745,7 @@ def build_codex_monthly_view(entries, *, now_utc, tz_name=None):
1745
1745
 
1746
1746
 
1747
1747
  def build_codex_weekly_view(entries, *, now_utc, tz_name=None,
1748
- week_start_idx=0):
1748
+ week_start_idx=0, speed="standard"):
1749
1749
  """Build a ``CodexWeeklyView`` from a list of ``CodexEntry`` (issue #58).
1750
1750
 
1751
1751
  ``week_start_idx`` is the resolved Mon=0..Sun=6 index the caller
@@ -1754,7 +1754,7 @@ def build_codex_weekly_view(entries, *, now_utc, tz_name=None,
1754
1754
  timezone (matches ``_aggregate_codex_weekly`` contract).
1755
1755
  """
1756
1756
  _agg = _load_lib("_lib_aggregators")
1757
- buckets = _agg._aggregate_codex_weekly(entries, tz_name, week_start_idx)
1757
+ buckets = _agg._aggregate_codex_weekly(entries, tz_name, week_start_idx, speed=speed)
1758
1758
  total_cost, total_tok = _codex_bucket_totals(buckets)
1759
1759
  return CodexWeeklyView(
1760
1760
  rows=tuple(buckets),
@@ -1766,7 +1766,7 @@ def build_codex_weekly_view(entries, *, now_utc, tz_name=None,
1766
1766
  )
1767
1767
 
1768
1768
 
1769
- def build_codex_session_view(entries, *, now_utc, tz_name=None):
1769
+ def build_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"):
1770
1770
  """Build a ``CodexSessionView`` from a list of ``CodexEntry`` (issue #58).
1771
1771
 
1772
1772
  ``rows`` order mirrors the aggregator: descending by
@@ -1779,7 +1779,7 @@ def build_codex_session_view(entries, *, now_utc, tz_name=None):
1779
1779
  aggregator only tracks ``last`` per session). ``None`` on empty.
1780
1780
  """
1781
1781
  _agg = _load_lib("_lib_aggregators")
1782
- sessions = _agg._aggregate_codex_sessions(entries)
1782
+ sessions = _agg._aggregate_codex_sessions(entries, speed=speed)
1783
1783
  total_cost = 0.0
1784
1784
  total_tok = 0
1785
1785
  earliest = None
package/bin/cctally CHANGED
@@ -268,6 +268,10 @@ _resolve_model_pricing = _lib_pricing._resolve_model_pricing
268
268
  _calculate_entry_cost = _lib_pricing._calculate_entry_cost
269
269
  _warn_unknown_codex_model = _lib_pricing._warn_unknown_codex_model
270
270
  _calculate_codex_entry_cost = _lib_pricing._calculate_codex_entry_cost
271
+ _codex_fast_multiplier = _lib_pricing._codex_fast_multiplier
272
+ CODEX_FAST_MULTIPLIER_OVERRIDES = _lib_pricing.CODEX_FAST_MULTIPLIER_OVERRIDES
273
+ CODEX_FAST_MULTIPLIER_FALLBACK = _lib_pricing.CODEX_FAST_MULTIPLIER_FALLBACK
274
+ _codex_config_requests_fast_service_tier = _lib_pricing._codex_config_requests_fast_service_tier
271
275
  _short_model_name = _lib_pricing._short_model_name
272
276
 
273
277
  _lib_display_tz = _load_sibling("_lib_display_tz")
@@ -1383,7 +1387,7 @@ class _CodexCostStats:
1383
1387
  samples: list = field(default_factory=list)
1384
1388
 
1385
1389
 
1386
- def _compute_codex_cost_stats(entries):
1390
+ def _compute_codex_cost_stats(entries, speed: str = "standard"):
1387
1391
  """Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
1388
1392
  per-entry computed-cost samples that ``_render_codex_cost_report``
1389
1393
  consumes (issue #92).
@@ -1414,6 +1418,7 @@ def _compute_codex_cost_stats(entries):
1414
1418
  entry.cached_input_tokens,
1415
1419
  entry.output_tokens,
1416
1420
  entry.reasoning_output_tokens,
1421
+ speed=speed,
1417
1422
  )
1418
1423
  stats.total_cost += cost
1419
1424
  stats.samples.append(_CodexCostSample(
@@ -1500,6 +1505,7 @@ def _emit_codex_debug_samples_if_set(
1500
1505
  entries,
1501
1506
  *,
1502
1507
  command_label: str,
1508
+ speed: str = "standard",
1503
1509
  ) -> None:
1504
1510
  """Emit the codex --debug report once per process when ``args.debug``
1505
1511
  is True (issue #92).
@@ -1517,7 +1523,7 @@ def _emit_codex_debug_samples_if_set(
1517
1523
  if not getattr(args, "debug", False):
1518
1524
  return
1519
1525
  sample_limit = int(getattr(args, "debug_samples", 5))
1520
- stats = _compute_codex_cost_stats(entries)
1526
+ stats = _compute_codex_cost_stats(entries, speed=speed)
1521
1527
  stats.command_label = command_label
1522
1528
  for line in _render_codex_cost_report(stats, sample_limit):
1523
1529
  eprint(line)
@@ -4238,7 +4244,7 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4238
4244
  range_start=range_start,
4239
4245
  range_end=range_end,
4240
4246
  display_tz=tz,
4241
- mode="auto",
4247
+ mode=args.mode,
4242
4248
  skip_rows=True,
4243
4249
  )
4244
4250
  blocks = list(view.aggregated)
@@ -4263,7 +4269,7 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4263
4269
  # additional entries. Without re-aggregation the displayed window
4264
4270
  # said one thing and the cost said another (live data: window
4265
4271
  # 20:50→01:50 with $45 cost vs the real $128).
4266
- _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc)
4272
+ _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc, mode=args.mode)
4267
4273
 
4268
4274
  if args.json:
4269
4275
  print(_blocks_to_json(blocks))
@@ -4284,6 +4290,7 @@ def _maybe_swap_active_block_to_canonical(
4284
4290
  all_entries: list[Any],
4285
4291
  *,
4286
4292
  now: dt.datetime,
4293
+ mode: str = "auto",
4287
4294
  ) -> None:
4288
4295
  """In-place swap of an ACTIVE heuristic block to its API-anchored
4289
4296
  canonical window — timestamps AND token/cost totals.
@@ -4359,10 +4366,10 @@ def _maybe_swap_active_block_to_canonical(
4359
4366
  if block_end_utc <= now.astimezone(dt.timezone.utc):
4360
4367
  return
4361
4368
  # Re-aggregate entries over the canonical interval. Build a fresh
4362
- # Block via ``_build_activity_block`` (mode="auto" matches
4363
- # ``_group_entries_into_blocks``'s default) so every total stays in
4364
- # one code path no field-by-field assignment that could drift if
4365
- # the dataclass grows new fields.
4369
+ # Block via ``_build_activity_block`` so every total stays in one code
4370
+ # path no field-by-field assignment that could drift if the dataclass
4371
+ # grows new fields. Thread the caller's ``mode`` so the active block's
4372
+ # cost honors --mode like the main grouping (Session C / Codex F1).
4366
4373
  canonical_entries = [
4367
4374
  e for e in all_entries
4368
4375
  if block_start_utc <= e.timestamp < block_end_utc
@@ -4372,7 +4379,7 @@ def _maybe_swap_active_block_to_canonical(
4372
4379
  block_start_utc,
4373
4380
  block_end_utc,
4374
4381
  now.astimezone(dt.timezone.utc),
4375
- "auto",
4382
+ mode,
4376
4383
  anchor="recorded",
4377
4384
  )
4378
4385
  blocks[active_idx] = rebuilt
@@ -4541,7 +4548,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
4541
4548
  # fields live on BucketUsage, not on DailyPanelRow. The builder's
4542
4549
  # `_aggregate_daily` call is the same one we used inline.
4543
4550
  view = build_daily_view(all_entries, now_utc=_command_as_of(),
4544
- display_tz=tz)
4551
+ display_tz=tz, mode=args.mode)
4545
4552
  # `_aggregate_daily` returned ascending order; build_daily_view stores
4546
4553
  # `aggregated` newest-first. CLI's default order is ascending, so
4547
4554
  # re-reverse to match the prior on-the-wire shape.
@@ -4628,7 +4635,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
4628
4635
  # Pass a large `n` so the CLI's `--since`/`--until` window controls
4629
4636
  # how many months render (the dashboard caps at n=12; CLI doesn't).
4630
4637
  view = build_monthly_view(all_entries, now_utc=_command_as_of(),
4631
- n=10**6, display_tz=tz)
4638
+ n=10**6, display_tz=tz, mode=args.mode)
4632
4639
  # The view stores `aggregated` newest-first; CLI default is asc.
4633
4640
  months = list(reversed(view.aggregated))
4634
4641
 
@@ -4745,7 +4752,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
4745
4752
  # assertions stay aligned.
4746
4753
  view = build_weekly_view(
4747
4754
  conn, all_entries, weeks=weeks, now_utc=now_utc,
4748
- display_tz=args._resolved_tz, as_of_utc=as_of_utc,
4755
+ display_tz=args._resolved_tz, as_of_utc=as_of_utc, mode=args.mode,
4749
4756
  )
4750
4757
  buckets = list(reversed(view.aggregated))
4751
4758
  overlay = list(reversed(view.overlay))
@@ -4803,6 +4810,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
4803
4810
  return 0
4804
4811
 
4805
4812
 
4813
+ def _detect_codex_fast_service_tier() -> bool:
4814
+ """True iff ``~/.codex/config.toml`` requests the fast/priority tier.
4815
+
4816
+ Reads from ``~/.codex`` only (single root; ``$CODEX_HOME`` multi-root is
4817
+ deferred — see #108). Tolerates an absent/unreadable config (→ False →
4818
+ standard tier).
4819
+ """
4820
+ cfg = pathlib.Path.home() / ".codex" / "config.toml"
4821
+ try:
4822
+ content = cfg.read_text(encoding="utf-8", errors="replace")
4823
+ except OSError:
4824
+ return False
4825
+ return _codex_config_requests_fast_service_tier(content)
4826
+
4827
+
4828
+ def _resolve_codex_speed(requested: str) -> str:
4829
+ """Resolve a ``--speed`` value to an effective tier.
4830
+
4831
+ ``auto`` → ``fast`` iff ``~/.codex/config.toml`` requests it, else
4832
+ ``standard``. ``fast``/``standard`` pass through unchanged.
4833
+ """
4834
+ if requested == "auto":
4835
+ return "fast" if _detect_codex_fast_service_tier() else "standard"
4836
+ return requested
4837
+
4838
+
4806
4839
  def cmd_codex_daily(args: argparse.Namespace) -> int:
4807
4840
  """Show Codex usage report grouped by date (display tz, --tz, or --timezone)."""
4808
4841
  config = load_config()
@@ -4822,13 +4855,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
4822
4855
  range_start, range_end = range
4823
4856
 
4824
4857
  entries = get_codex_entries(range_start, range_end)
4825
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily")
4858
+ speed = _resolve_codex_speed(args.speed)
4859
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily", speed=speed)
4826
4860
  # Route through ``build_codex_daily_view`` (issue #58). The View
4827
4861
  # wraps ``_aggregate_codex_daily`` without changing it — preserves
4828
4862
  # LiteLLM token semantics, intentional dedup vs upstream, and
4829
4863
  # ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
4830
4864
  view = build_codex_daily_view(
4831
- entries, now_utc=_command_as_of(), tz_name=tz_name,
4865
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
4832
4866
  )
4833
4867
  days = list(view.rows) # asc — matches aggregator default
4834
4868
  if args.order == "desc":
@@ -4882,10 +4916,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
4882
4916
  range_start, range_end = range
4883
4917
 
4884
4918
  entries = get_codex_entries(range_start, range_end)
4885
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly")
4919
+ speed = _resolve_codex_speed(args.speed)
4920
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly", speed=speed)
4886
4921
  # Route through ``build_codex_monthly_view`` (issue #58).
4887
4922
  view = build_codex_monthly_view(
4888
- entries, now_utc=_command_as_of(), tz_name=tz_name,
4923
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
4889
4924
  )
4890
4925
  months = list(view.rows)
4891
4926
  if args.order == "desc":
@@ -4942,11 +4977,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
4942
4977
  week_start_idx = WEEKDAY_MAP[week_start_name]
4943
4978
 
4944
4979
  entries = get_codex_entries(range_start, range_end)
4945
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly")
4980
+ speed = _resolve_codex_speed(args.speed)
4981
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly", speed=speed)
4946
4982
  # Route through ``build_codex_weekly_view`` (issue #58).
4947
4983
  view = build_codex_weekly_view(
4948
4984
  entries, now_utc=now_utc, tz_name=tz_name,
4949
- week_start_idx=week_start_idx,
4985
+ week_start_idx=week_start_idx, speed=speed,
4950
4986
  )
4951
4987
  weeks = list(view.rows)
4952
4988
  if args.order == "desc":
@@ -5003,12 +5039,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5003
5039
  range_start, range_end = range
5004
5040
 
5005
5041
  entries = get_codex_entries(range_start, range_end)
5006
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-session")
5042
+ speed = _resolve_codex_speed(args.speed)
5043
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-session", speed=speed)
5007
5044
  # Route through ``build_codex_session_view`` (issue #58). View rows
5008
5045
  # come descending by last_activity (aggregator default + upstream
5009
5046
  # parity); --order asc reverses.
5010
5047
  view = build_codex_session_view(
5011
- entries, now_utc=_command_as_of(), tz_name=tz_name,
5048
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
5012
5049
  )
5013
5050
  sessions = list(view.rows)
5014
5051
  if args.order == "asc":
@@ -5973,6 +6010,7 @@ def cmd_session(args: argparse.Namespace) -> int:
5973
6010
  # files collapses to ONE entry in BOTH tuples).
5974
6011
  view = build_sessions_view(
5975
6012
  entries, now_utc=_command_as_of(), limit=None, display_tz=tz,
6013
+ mode=args.mode,
5976
6014
  )
5977
6015
  sessions = list(view.aggregated)
5978
6016
 
@@ -9783,6 +9821,36 @@ def _argparse_has_arg(parser, option_string: str) -> bool:
9783
9821
  return False
9784
9822
 
9785
9823
 
9824
+ def _add_mode_arg(parser, *, noop: bool = False) -> None:
9825
+ """Add ccusage's -m/--mode {auto,calculate,display} cost-source flag.
9826
+
9827
+ Standalone (not folded into _add_ccusage_alias_args) so it lands only
9828
+ on the six Session-C reporting commands and never collides with
9829
+ range-cost, which defines its own -m/--mode.
9830
+
9831
+ noop=True (five-hour-blocks only): the flag is accepted for surface
9832
+ parity with `blocks` but does not alter numbers — that command's cost
9833
+ is the authoritative materialized five_hour_blocks.total_cost_usd
9834
+ computed at record-time (always auto semantics).
9835
+ """
9836
+ help_real = (
9837
+ "Cost source: auto (recorded costUSD when present, else computed), "
9838
+ "calculate (always compute from embedded pricing), display "
9839
+ "(recorded costUSD only; $0 when absent). Default: auto."
9840
+ )
9841
+ help_noop = (
9842
+ "Accepted for ccusage drop-in compat; no-op here — five-hour-blocks "
9843
+ "cost is the authoritative materialized per-block value computed at "
9844
+ "record-time. Default: auto."
9845
+ )
9846
+ parser.add_argument(
9847
+ "-m", "--mode",
9848
+ default="auto",
9849
+ choices=["auto", "calculate", "display"],
9850
+ help=help_noop if noop else help_real,
9851
+ )
9852
+
9853
+
9786
9854
  def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
9787
9855
  """Attach the Session A ccusage alias surface to a Claude-cmd subparser.
9788
9856
 
@@ -9927,6 +9995,12 @@ def _add_codex_shared_args(parser: argparse.ArgumentParser) -> None:
9927
9995
  "-O", "--offline", action=argparse.BooleanOptionalAction, default=False,
9928
9996
  help="Accepted for drop-in compat with ccusage-codex; we are always offline.",
9929
9997
  )
9998
+ parser.add_argument(
9999
+ "--speed", choices=("auto", "standard", "fast"), default="auto",
10000
+ help="Codex pricing tier. auto (default) reads service_tier from "
10001
+ "~/.codex/config.toml (fast|priority -> fast pricing); fast "
10002
+ "forces the fast-tier multiplier; standard forces base pricing.",
10003
+ )
9930
10004
  parser.add_argument(
9931
10005
  "--tz", default=None, type=_argparse_tz, metavar="TZ",
9932
10006
  help="Display timezone: local, utc, or IANA name. Overrides "
@@ -10137,6 +10211,7 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
10137
10211
  "Overrides config display.tz for this call.",
10138
10212
  )
10139
10213
  _add_ccusage_alias_args(p, ansi_emit=False)
10214
+ _add_mode_arg(p)
10140
10215
  _add_share_args(p)
10141
10216
  p.set_defaults(func=cmd_daily)
10142
10217
  return p
@@ -10195,6 +10270,7 @@ def _build_monthly_parser(subparsers, name, *, help_text, xref):
10195
10270
  "Overrides config display.tz for this call.",
10196
10271
  )
10197
10272
  _add_ccusage_alias_args(p, ansi_emit=False)
10273
+ _add_mode_arg(p)
10198
10274
  _add_share_args(p)
10199
10275
  p.set_defaults(func=cmd_monthly)
10200
10276
  return p
@@ -10235,6 +10311,7 @@ def _build_weekly_parser(subparsers, name, *, help_text, xref):
10235
10311
  help="Display timezone: local, utc, or IANA name. "
10236
10312
  "Overrides config display.tz for this call.")
10237
10313
  _add_ccusage_alias_args(p, ansi_emit=False)
10314
+ _add_mode_arg(p)
10238
10315
  _add_share_args(p)
10239
10316
  p.set_defaults(func=cmd_weekly)
10240
10317
  return p
@@ -10286,6 +10363,7 @@ def _build_session_parser(subparsers, name, *, help_text, xref):
10286
10363
  "Unknown id → exit 0 with the empty-render branch.",
10287
10364
  )
10288
10365
  _add_ccusage_alias_args(p, ansi_emit=False)
10366
+ _add_mode_arg(p)
10289
10367
  _add_share_args(p)
10290
10368
  p.set_defaults(func=cmd_session)
10291
10369
  return p
@@ -10340,6 +10418,7 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
10340
10418
  "Overrides config display.tz for this call.",
10341
10419
  )
10342
10420
  _add_ccusage_alias_args(p, ansi_emit=False)
10421
+ _add_mode_arg(p)
10343
10422
  p.set_defaults(func=cmd_blocks)
10344
10423
  return p
10345
10424
 
@@ -11266,6 +11345,7 @@ def build_parser() -> argparse.ArgumentParser:
11266
11345
  # guard. The helper's --color add lands as a parsed-and-ignored
11267
11346
  # no-op (the renderer emits plain text).
11268
11347
  _add_ccusage_alias_args(fhb, ansi_emit=False)
11348
+ _add_mode_arg(fhb, noop=True)
11269
11349
  _add_share_args(fhb)
11270
11350
  fhb.set_defaults(func=cmd_five_hour_blocks)
11271
11351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.15.0",
3
+ "version": "1.17.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": {