cctally 1.16.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,14 @@ 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
+
8
16
  ## [1.16.0] - 2026-05-26
9
17
 
10
18
  ### 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
@@ -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:
@@ -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)
@@ -4804,6 +4810,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
4804
4810
  return 0
4805
4811
 
4806
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
+
4807
4839
  def cmd_codex_daily(args: argparse.Namespace) -> int:
4808
4840
  """Show Codex usage report grouped by date (display tz, --tz, or --timezone)."""
4809
4841
  config = load_config()
@@ -4823,13 +4855,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
4823
4855
  range_start, range_end = range
4824
4856
 
4825
4857
  entries = get_codex_entries(range_start, range_end)
4826
- _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)
4827
4860
  # Route through ``build_codex_daily_view`` (issue #58). The View
4828
4861
  # wraps ``_aggregate_codex_daily`` without changing it — preserves
4829
4862
  # LiteLLM token semantics, intentional dedup vs upstream, and
4830
4863
  # ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
4831
4864
  view = build_codex_daily_view(
4832
- entries, now_utc=_command_as_of(), tz_name=tz_name,
4865
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
4833
4866
  )
4834
4867
  days = list(view.rows) # asc — matches aggregator default
4835
4868
  if args.order == "desc":
@@ -4883,10 +4916,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
4883
4916
  range_start, range_end = range
4884
4917
 
4885
4918
  entries = get_codex_entries(range_start, range_end)
4886
- _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)
4887
4921
  # Route through ``build_codex_monthly_view`` (issue #58).
4888
4922
  view = build_codex_monthly_view(
4889
- entries, now_utc=_command_as_of(), tz_name=tz_name,
4923
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
4890
4924
  )
4891
4925
  months = list(view.rows)
4892
4926
  if args.order == "desc":
@@ -4943,11 +4977,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
4943
4977
  week_start_idx = WEEKDAY_MAP[week_start_name]
4944
4978
 
4945
4979
  entries = get_codex_entries(range_start, range_end)
4946
- _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)
4947
4982
  # Route through ``build_codex_weekly_view`` (issue #58).
4948
4983
  view = build_codex_weekly_view(
4949
4984
  entries, now_utc=now_utc, tz_name=tz_name,
4950
- week_start_idx=week_start_idx,
4985
+ week_start_idx=week_start_idx, speed=speed,
4951
4986
  )
4952
4987
  weeks = list(view.rows)
4953
4988
  if args.order == "desc":
@@ -5004,12 +5039,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5004
5039
  range_start, range_end = range
5005
5040
 
5006
5041
  entries = get_codex_entries(range_start, range_end)
5007
- _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)
5008
5044
  # Route through ``build_codex_session_view`` (issue #58). View rows
5009
5045
  # come descending by last_activity (aggregator default + upstream
5010
5046
  # parity); --order asc reverses.
5011
5047
  view = build_codex_session_view(
5012
- entries, now_utc=_command_as_of(), tz_name=tz_name,
5048
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
5013
5049
  )
5014
5050
  sessions = list(view.rows)
5015
5051
  if args.order == "asc":
@@ -9959,6 +9995,12 @@ def _add_codex_shared_args(parser: argparse.ArgumentParser) -> None:
9959
9995
  "-O", "--offline", action=argparse.BooleanOptionalAction, default=False,
9960
9996
  help="Accepted for drop-in compat with ccusage-codex; we are always offline.",
9961
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
+ )
9962
10004
  parser.add_argument(
9963
10005
  "--tz", default=None, type=_argparse_tz, metavar="TZ",
9964
10006
  help="Display timezone: local, utc, or IANA name. Overrides "
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.16.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": {