cctally 1.16.0 → 1.18.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,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.18.0] - 2026-05-27
9
+
10
+ ### Added
11
+ - **Project-axis flags on `cctally daily` (and `cctally claude daily`): `-i/--instances`, `-p/--project`, and `--project-aliases`** — a drop-in for `ccusage daily`. `-i/--instances` groups the daily report by project (git-root), rendering a `Project: <label>` section per project with one global Total (`--json` becomes `{projects: {...}, totals}`); `-p/--project PATTERN` filters to matching projects by case-insensitive substring of the project label or path, and is repeatable with OR semantics; `--project-aliases key=Label,...` overrides project display labels (table headers only — never the JSON keys). Two distinct git-roots that share a basename stay separate (`app (work)` / `app (personal)`), null-project entries collect under `(unknown)`, and under `--format` shareable output `-i` is a no-op while `-p` is honored (the filter survives into the artifact). (#86)
12
+
13
+ ## [1.17.0] - 2026-05-27
14
+
15
+ ### Added
16
+ - **`--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)
17
+
18
+ ### Changed
19
+ - **`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)
20
+
8
21
  ## [1.16.0] - 2026-05-26
9
22
 
10
23
  ### Added
@@ -937,6 +937,13 @@ class _JoinedClaudeEntry:
937
937
  # reconciles with daily/range-cost paths that already pass
938
938
  # `entry.cost_usd` into `_calculate_entry_cost`.
939
939
  cost_usd: float | None = None
940
+ # Non-token `usage` extras (parsed `usage_extra_json`) — notably
941
+ # `speed`, which `_aggregate_buckets` reads to render `<model>-fast`.
942
+ # `iter_entries` merges these into its `UsageEntry.usage`; the joined
943
+ # path must carry them too so `_usage_entry_from_joined` can restore
944
+ # them (else `daily -i`/`-p` lose fast-tier model labels). None when
945
+ # the row has no extras.
946
+ usage_extra: dict | None = None
940
947
 
941
948
 
942
949
  def get_claude_session_entries(
@@ -996,7 +1003,7 @@ def get_claude_session_entries(
996
1003
  " se.cache_create_tokens, se.cache_read_tokens, "
997
1004
  " se.source_path, "
998
1005
  " sf.session_id, sf.project_path, "
999
- " se.cost_usd_raw "
1006
+ " se.cost_usd_raw, se.usage_extra_json "
1000
1007
  "FROM session_entries se "
1001
1008
  "LEFT JOIN session_files sf ON sf.path = se.source_path "
1002
1009
  "WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ?"
@@ -1024,6 +1031,7 @@ def get_claude_session_entries(
1024
1031
  session_id=row[7],
1025
1032
  project_path=row[8],
1026
1033
  cost_usd=row[9],
1034
+ usage_extra=(json.loads(row[10]) if row[10] else None),
1027
1035
  )
1028
1036
  for row in rows
1029
1037
  ]
@@ -1135,9 +1143,16 @@ def _direct_parse_claude_session_entries(
1135
1143
  results: list[_JoinedClaudeEntry] = []
1136
1144
  flat: list[tuple[UsageEntry, str]] = list(dedupe_map.values()) + no_key_with_meta
1137
1145
  flat.sort(key=lambda pair: pair[0].timestamp)
1146
+ _token_keys = {
1147
+ "input_tokens", "output_tokens",
1148
+ "cache_creation_input_tokens", "cache_read_input_tokens",
1149
+ }
1138
1150
  for entry, source_path in flat:
1139
1151
  usage = entry.usage
1140
1152
  sid, cwd = meta_by_path[source_path]
1153
+ # Mirror the cache-backed path: carry non-token `usage` extras
1154
+ # (e.g. `speed`) so `_usage_entry_from_joined` can restore them.
1155
+ extras = {k: v for k, v in usage.items() if k not in _token_keys}
1141
1156
  results.append(_JoinedClaudeEntry(
1142
1157
  timestamp=entry.timestamp,
1143
1158
  model=entry.model,
@@ -1153,6 +1168,7 @@ def _direct_parse_claude_session_entries(
1153
1168
  session_id=sid,
1154
1169
  project_path=cwd,
1155
1170
  cost_usd=entry.cost_usd,
1171
+ usage_extra=(extras or None),
1156
1172
  ))
1157
1173
 
1158
1174
  return results
@@ -232,6 +232,41 @@ def _aggregate_monthly(
232
232
  )
233
233
 
234
234
 
235
+ def _aggregate_daily_by_project(
236
+ keyed_entries: list[tuple[Any, UsageEntry]],
237
+ *,
238
+ tz: "Any | None" = None,
239
+ mode: str = "auto",
240
+ ) -> list[tuple[Any, list[BucketUsage]]]:
241
+ """Group ``(project_key, UsageEntry)`` pairs into per-project daily buckets.
242
+
243
+ Returns ``[(project_key, [BucketUsage date-asc]), ...]`` ordered by each
244
+ project's total cost descending, ties broken by ``project_key.display_key``
245
+ ascending. ``project_key`` is opaque/hashable (a ``ProjectKey``); resolution
246
+ happened in the caller, so this stays pure (no filesystem).
247
+
248
+ Reuses ``_aggregate_daily`` per group, so per-model breakdowns, token sums,
249
+ and ``mode``/``cost_usd`` threading are identical to the non-instances path.
250
+ """
251
+ grouped: dict[Any, list[UsageEntry]] = {}
252
+ order: list[Any] = []
253
+ for key, entry in keyed_entries:
254
+ bucket = grouped.get(key)
255
+ if bucket is None:
256
+ grouped[key] = bucket = []
257
+ order.append(key)
258
+ bucket.append(entry)
259
+
260
+ ranked: list[tuple[Any, list[BucketUsage], float]] = []
261
+ for key in order:
262
+ buckets = _aggregate_daily(grouped[key], mode=mode, tz=tz) # date-asc
263
+ total = sum(b.cost_usd for b in buckets)
264
+ ranked.append((key, buckets, total))
265
+
266
+ ranked.sort(key=lambda t: (-t[2], t[0].display_key))
267
+ return [(key, buckets) for key, buckets, _ in ranked]
268
+
269
+
235
270
  def _aggregate_weekly(
236
271
  entries: list[UsageEntry],
237
272
  weeks: list[SubWeek],
@@ -363,6 +398,7 @@ class ClaudeSessionUsage:
363
398
  def _aggregate_codex_buckets(
364
399
  entries: list[CodexEntry],
365
400
  key_fn: Callable[[CodexEntry], str],
401
+ speed: str = "standard",
366
402
  ) -> list[CodexBucketUsage]:
367
403
  """Group CodexEntry list into per-bucket records sorted by key ascending.
368
404
 
@@ -386,6 +422,7 @@ def _aggregate_codex_buckets(
386
422
  entry.cached_input_tokens,
387
423
  entry.output_tokens,
388
424
  entry.reasoning_output_tokens,
425
+ speed=speed,
389
426
  )
390
427
 
391
428
  bucket["input"] += entry.input_tokens
@@ -441,6 +478,7 @@ def _aggregate_codex_buckets(
441
478
 
442
479
  def _aggregate_codex_daily(
443
480
  entries: list[CodexEntry], *, tz_name: str | None = None,
481
+ speed: str = "standard",
444
482
  ) -> list[CodexBucketUsage]:
445
483
  """Daily grouping. Default: local tz. With ``tz_name``: that IANA zone."""
446
484
  tz = _resolve_tz(tz_name)
@@ -448,11 +486,12 @@ def _aggregate_codex_daily(
448
486
  key_fn = lambda e: e.timestamp.astimezone(tz).strftime("%Y-%m-%d") # noqa: E731
449
487
  else:
450
488
  key_fn = lambda e: e.timestamp.astimezone().strftime("%Y-%m-%d") # noqa: E731
451
- return _aggregate_codex_buckets(entries, key_fn=key_fn)
489
+ return _aggregate_codex_buckets(entries, key_fn=key_fn, speed=speed)
452
490
 
453
491
 
454
492
  def _aggregate_codex_monthly(
455
493
  entries: list[CodexEntry], *, tz_name: str | None = None,
494
+ speed: str = "standard",
456
495
  ) -> list[CodexBucketUsage]:
457
496
  """Monthly grouping. Default: local tz. With ``tz_name``: that IANA zone."""
458
497
  tz = _resolve_tz(tz_name)
@@ -460,13 +499,14 @@ def _aggregate_codex_monthly(
460
499
  key_fn = lambda e: e.timestamp.astimezone(tz).strftime("%Y-%m") # noqa: E731
461
500
  else:
462
501
  key_fn = lambda e: e.timestamp.astimezone().strftime("%Y-%m") # noqa: E731
463
- return _aggregate_codex_buckets(entries, key_fn=key_fn)
502
+ return _aggregate_codex_buckets(entries, key_fn=key_fn, speed=speed)
464
503
 
465
504
 
466
505
  def _aggregate_codex_weekly(
467
506
  entries: list[CodexEntry],
468
507
  tz_name: str | None,
469
508
  week_start_idx: int,
509
+ speed: str = "standard",
470
510
  ) -> list[CodexBucketUsage]:
471
511
  """Group Codex entries by calendar week.
472
512
 
@@ -485,7 +525,7 @@ def _aggregate_codex_weekly(
485
525
  week_start = local_date - dt.timedelta(days=diff)
486
526
  return week_start.isoformat()
487
527
 
488
- return _aggregate_codex_buckets(entries, key_fn=_week_key)
528
+ return _aggregate_codex_buckets(entries, key_fn=_week_key, speed=speed)
489
529
 
490
530
 
491
531
  def _session_path_parts(source_path: str) -> tuple[str, str, str]:
@@ -520,7 +560,7 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
520
560
  return str(stem), stem.name, str(stem.parent)
521
561
 
522
562
 
523
- def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsage]:
563
+ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
524
564
  """Group by session file path (upstream-compatible).
525
565
 
526
566
  Sessions are keyed by the full relative-path-without-.jsonl rather than
@@ -543,7 +583,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsa
543
583
  })
544
584
  cost = _calculate_codex_entry_cost(
545
585
  entry.model, entry.input_tokens, entry.cached_input_tokens,
546
- entry.output_tokens, entry.reasoning_output_tokens,
586
+ entry.output_tokens, entry.reasoning_output_tokens, speed=speed,
547
587
  )
548
588
  sess["input"] += entry.input_tokens
549
589
  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:
@@ -680,6 +680,40 @@ def _render_blocks_table(
680
680
  return rendered
681
681
 
682
682
 
683
+ def _daily_row_dict(d: BucketUsage, *, date_key: str) -> dict[str, Any]:
684
+ """Single bucket → upstream-shaped row dict.
685
+
686
+ Shared by `_bucket_to_json` and `_bucket_by_project_to_json` so the per-row
687
+ shape (field set + order) can never drift. Key order matches ccusage:
688
+ date_key, inputTokens, outputTokens, cacheCreationTokens,
689
+ cacheReadTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns.
690
+ """
691
+ return {
692
+ date_key: d.bucket,
693
+ "inputTokens": d.input_tokens,
694
+ "outputTokens": d.output_tokens,
695
+ "cacheCreationTokens": d.cache_creation_tokens,
696
+ "cacheReadTokens": d.cache_read_tokens,
697
+ "totalTokens": d.total_tokens,
698
+ "totalCost": d.cost_usd,
699
+ "modelsUsed": list(d.models),
700
+ "modelBreakdowns": list(d.model_breakdowns),
701
+ }
702
+
703
+
704
+ def _bucket_totals_dict(buckets) -> dict[str, Any]:
705
+ """Aggregate totals across buckets (key order matches ccusage; note
706
+ totalCost BEFORE totalTokens)."""
707
+ return {
708
+ "inputTokens": sum(b.input_tokens for b in buckets),
709
+ "outputTokens": sum(b.output_tokens for b in buckets),
710
+ "cacheCreationTokens": sum(b.cache_creation_tokens for b in buckets),
711
+ "cacheReadTokens": sum(b.cache_read_tokens for b in buckets),
712
+ "totalCost": sum(b.cost_usd for b in buckets),
713
+ "totalTokens": sum(b.total_tokens for b in buckets),
714
+ }
715
+
716
+
683
717
  def _bucket_to_json(
684
718
  buckets: list[BucketUsage],
685
719
  *,
@@ -696,43 +730,27 @@ def _bucket_to_json(
696
730
  cacheReadTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns.
697
731
  Totals key order (note: totalCost BEFORE totalTokens, per ccusage).
698
732
  """
699
- bucket_list: list[dict[str, Any]] = []
700
- tot_input = 0
701
- tot_output = 0
702
- tot_cc = 0
703
- tot_cr = 0
704
- tot_cost = 0.0
705
- tot_tokens = 0
706
- for d in buckets:
707
- bucket_list.append({
708
- date_key: d.bucket,
709
- "inputTokens": d.input_tokens,
710
- "outputTokens": d.output_tokens,
711
- "cacheCreationTokens": d.cache_creation_tokens,
712
- "cacheReadTokens": d.cache_read_tokens,
713
- "totalTokens": d.total_tokens,
714
- "totalCost": d.cost_usd,
715
- "modelsUsed": list(d.models),
716
- "modelBreakdowns": list(d.model_breakdowns),
717
- })
718
- tot_input += d.input_tokens
719
- tot_output += d.output_tokens
720
- tot_cc += d.cache_creation_tokens
721
- tot_cr += d.cache_read_tokens
722
- tot_cost += d.cost_usd
723
- tot_tokens += d.total_tokens
724
-
725
- totals = {
726
- "inputTokens": tot_input,
727
- "outputTokens": tot_output,
728
- "cacheCreationTokens": tot_cc,
729
- "cacheReadTokens": tot_cr,
730
- "totalCost": tot_cost,
731
- "totalTokens": tot_tokens,
732
- }
733
+ bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
734
+ totals = _bucket_totals_dict(buckets)
733
735
  return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
734
736
 
735
737
 
738
+ def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str:
739
+ """Serialize ``[(label, [BucketUsage]), ...]`` to ``{projects:{label:[rows]},
740
+ totals}`` (upstream ccusage daily --instances shape). Caller passes the
741
+ disambiguated (unique, non-aliased) label per group; insertion order is
742
+ preserved as the JSON key order."""
743
+ projects: dict[str, Any] = {}
744
+ all_buckets: list = []
745
+ for label, buckets in project_groups:
746
+ projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
747
+ all_buckets.extend(buckets)
748
+ return json.dumps(
749
+ {"projects": projects, "totals": _bucket_totals_dict(all_buckets)},
750
+ indent=2,
751
+ )
752
+
753
+
736
754
  def _weekly_to_json(
737
755
  buckets: list[BucketUsage],
738
756
  weeks: list[SubWeek],
@@ -1079,6 +1097,7 @@ def _render_bucket_table(
1079
1097
  compact_split_fn: Callable[[str], str],
1080
1098
  breakdown: bool = False,
1081
1099
  compact: bool = False,
1100
+ project_groups=None,
1082
1101
  ) -> str:
1083
1102
  """Render bucket aggregates as a ccusage-style ANSI table.
1084
1103
 
@@ -1089,6 +1108,12 @@ def _render_bucket_table(
1089
1108
  "YYYY\\n..." for compact-mode two-line display.
1090
1109
  compact — force compact layout regardless of terminal width
1091
1110
  (Session A `--compact` flag; spec §7.6.1).
1111
+ project_groups — when provided as ``[(label, [BucketUsage]), ...]``,
1112
+ render section layout: a ``Project: <label>`` header
1113
+ per group with one global Total footer summed across
1114
+ all groups (`daily -i/--instances`; issue #86 Session
1115
+ E). Default ``None`` = today's flat single-table
1116
+ behavior driven by ``buckets``.
1092
1117
 
1093
1118
  Mirrors ccusage's ResponsiveTable behavior: single-line headers and dates
1094
1119
  when content fits the terminal; falls back to two-line compact headers
@@ -1126,10 +1151,10 @@ def _render_bucket_table(
1126
1151
  # ── Build raw rows: each is (cells, row_type) where a cell is the
1127
1152
  # tuple (text, color_fn_or_none). `text` may contain '\n' for
1128
1153
  # multi-line cells (Models list, compact Date).
1129
- ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
1154
+ ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER, ROW_PROJECT = "data", "breakdown", "footer", "project"
1130
1155
  raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
1131
1156
 
1132
- for d in buckets:
1157
+ def _emit_data_and_breakdown(d):
1133
1158
  # ccusage formatModelsDisplayMultiline: uniq → sort alphabetical
1134
1159
  short_models = sorted({_short_model_name(m) for m in d.models})
1135
1160
  models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
@@ -1166,13 +1191,30 @@ def _render_bucket_table(
1166
1191
  ]
1167
1192
  raw_rows.append((bd_cells, ROW_BREAKDOWN))
1168
1193
 
1194
+ if project_groups is not None:
1195
+ # Project-aware section layout (daily --instances): one cyan
1196
+ # `Project: <label>` header per group, then that group's normal
1197
+ # ROW_DATA (+ ROW_BREAKDOWN children); the single footer is computed
1198
+ # by flattening all groups' buckets.
1199
+ footer_buckets: list = []
1200
+ for label, group_buckets in project_groups:
1201
+ header_cells = [(f"Project: {label}", _cyan)] + [("", None)] * (num_cols - 1)
1202
+ raw_rows.append((header_cells, ROW_PROJECT))
1203
+ for d in group_buckets:
1204
+ _emit_data_and_breakdown(d)
1205
+ footer_buckets.append(d)
1206
+ else:
1207
+ for d in buckets:
1208
+ _emit_data_and_breakdown(d)
1209
+ footer_buckets = buckets
1210
+
1169
1211
  # Total footer row — yellow on all populated cells.
1170
- tot_input = sum(d.input_tokens for d in buckets)
1171
- tot_output = sum(d.output_tokens for d in buckets)
1172
- tot_cc = sum(d.cache_creation_tokens for d in buckets)
1173
- tot_cr = sum(d.cache_read_tokens for d in buckets)
1174
- tot_tokens = sum(d.total_tokens for d in buckets)
1175
- tot_cost = sum(d.cost_usd for d in buckets)
1212
+ tot_input = sum(d.input_tokens for d in footer_buckets)
1213
+ tot_output = sum(d.output_tokens for d in footer_buckets)
1214
+ tot_cc = sum(d.cache_creation_tokens for d in footer_buckets)
1215
+ tot_cr = sum(d.cache_read_tokens for d in footer_buckets)
1216
+ tot_tokens = sum(d.total_tokens for d in footer_buckets)
1217
+ tot_cost = sum(d.cost_usd for d in footer_buckets)
1176
1218
  footer_cells = [
1177
1219
  ("Total", _yellow),
1178
1220
  ("", None),
@@ -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")
@@ -364,6 +368,7 @@ CodexSessionUsage = _lib_aggregators.CodexSessionUsage
364
368
  ClaudeSessionUsage = _lib_aggregators.ClaudeSessionUsage
365
369
  _aggregate_buckets = _lib_aggregators._aggregate_buckets
366
370
  _aggregate_daily = _lib_aggregators._aggregate_daily
371
+ _aggregate_daily_by_project = _lib_aggregators._aggregate_daily_by_project
367
372
  _aggregate_monthly = _lib_aggregators._aggregate_monthly
368
373
  _aggregate_weekly = _lib_aggregators._aggregate_weekly
369
374
  _aggregate_codex_buckets = _lib_aggregators._aggregate_codex_buckets
@@ -404,7 +409,10 @@ build_codex_session_view = _lib_view_models.build_codex_session_view
404
409
  _lib_render = _load_sibling("_lib_render")
405
410
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
406
411
  _render_blocks_table = _lib_render._render_blocks_table
412
+ _daily_row_dict = _lib_render._daily_row_dict
413
+ _bucket_totals_dict = _lib_render._bucket_totals_dict
407
414
  _bucket_to_json = _lib_render._bucket_to_json
415
+ _bucket_by_project_to_json = _lib_render._bucket_by_project_to_json
408
416
  _weekly_to_json = _lib_render._weekly_to_json
409
417
  _daily_compact_split = _lib_render._daily_compact_split
410
418
  _monthly_compact_split = _lib_render._monthly_compact_split
@@ -1383,7 +1391,7 @@ class _CodexCostStats:
1383
1391
  samples: list = field(default_factory=list)
1384
1392
 
1385
1393
 
1386
- def _compute_codex_cost_stats(entries):
1394
+ def _compute_codex_cost_stats(entries, speed: str = "standard"):
1387
1395
  """Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
1388
1396
  per-entry computed-cost samples that ``_render_codex_cost_report``
1389
1397
  consumes (issue #92).
@@ -1414,6 +1422,7 @@ def _compute_codex_cost_stats(entries):
1414
1422
  entry.cached_input_tokens,
1415
1423
  entry.output_tokens,
1416
1424
  entry.reasoning_output_tokens,
1425
+ speed=speed,
1417
1426
  )
1418
1427
  stats.total_cost += cost
1419
1428
  stats.samples.append(_CodexCostSample(
@@ -1500,6 +1509,7 @@ def _emit_codex_debug_samples_if_set(
1500
1509
  entries,
1501
1510
  *,
1502
1511
  command_label: str,
1512
+ speed: str = "standard",
1503
1513
  ) -> None:
1504
1514
  """Emit the codex --debug report once per process when ``args.debug``
1505
1515
  is True (issue #92).
@@ -1517,7 +1527,7 @@ def _emit_codex_debug_samples_if_set(
1517
1527
  if not getattr(args, "debug", False):
1518
1528
  return
1519
1529
  sample_limit = int(getattr(args, "debug_samples", 5))
1520
- stats = _compute_codex_cost_stats(entries)
1530
+ stats = _compute_codex_cost_stats(entries, speed=speed)
1521
1531
  stats.command_label = command_label
1522
1532
  for line in _render_codex_cost_report(stats, sample_limit):
1523
1533
  eprint(line)
@@ -1532,16 +1542,27 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
1532
1542
  The joined-entry shape already carries ``source_path``, ``cost_usd``,
1533
1543
  and the per-token integers; this adapter is pure shape conversion
1534
1544
  with no cache re-read.
1545
+
1546
+ Non-token ``usage`` extras (``je.usage_extra``) — notably ``speed`` —
1547
+ are merged AFTER the four token keys, mirroring ``iter_entries``'
1548
+ ``usage.update(json.loads(...))``. Without this, the project-axis
1549
+ ``daily`` path (and the diff/report joined-entry consumers) would
1550
+ drop the fast-tier flag and render ``<model>`` where the normal path
1551
+ renders ``<model>-fast``. The write-side strips the four token keys
1552
+ from ``usage_extra_json`` so the merge never shadows the integers.
1535
1553
  """
1554
+ usage = {
1555
+ "input_tokens": je.input_tokens,
1556
+ "output_tokens": je.output_tokens,
1557
+ "cache_creation_input_tokens": je.cache_creation_tokens,
1558
+ "cache_read_input_tokens": je.cache_read_tokens,
1559
+ }
1560
+ if je.usage_extra:
1561
+ usage.update(je.usage_extra)
1536
1562
  return UsageEntry(
1537
1563
  timestamp=je.timestamp,
1538
1564
  model=je.model,
1539
- usage={
1540
- "input_tokens": je.input_tokens,
1541
- "output_tokens": je.output_tokens,
1542
- "cache_creation_input_tokens": je.cache_creation_tokens,
1543
- "cache_read_input_tokens": je.cache_read_tokens,
1544
- },
1565
+ usage=usage,
1545
1566
  cost_usd=je.cost_usd,
1546
1567
  source_path=je.source_path,
1547
1568
  )
@@ -1580,6 +1601,42 @@ def _project_filter_matches(key, project_patterns):
1580
1601
  return any((p in dname) or (p in pname) for p in project_patterns)
1581
1602
 
1582
1603
 
1604
+ def _parse_project_aliases(raw):
1605
+ """Parse a ``--project-aliases`` value into ``{key: label}``.
1606
+
1607
+ Form: comma-separated ``key=Label`` pairs. Whitespace around keys, labels,
1608
+ and pairs is stripped; segments without ``=`` or with an empty key/label are
1609
+ dropped (ported from ccusage's tolerant parser). ``None``/"" → ``{}``.
1610
+ """
1611
+ result: dict[str, str] = {}
1612
+ if not raw:
1613
+ return result
1614
+ for pair in raw.split(","):
1615
+ pair = pair.strip()
1616
+ if not pair or "=" not in pair:
1617
+ continue
1618
+ k, _, v = pair.partition("=")
1619
+ k = k.strip()
1620
+ v = v.strip()
1621
+ if k and v:
1622
+ result[k] = v
1623
+ return result
1624
+
1625
+
1626
+ def _alias_for(key, aliases):
1627
+ """Return the alias label for a ProjectKey, or None.
1628
+
1629
+ Looks up ``aliases`` by ``display_key``, then ``git_root``, then
1630
+ ``bucket_path`` (first hit). Display-only — never alters JSON keys.
1631
+ """
1632
+ if not aliases:
1633
+ return None
1634
+ for cand in (key.display_key, key.git_root, key.bucket_path):
1635
+ if cand and cand in aliases:
1636
+ return aliases[cand]
1637
+ return None
1638
+
1639
+
1583
1640
  def _emit_diff_debug_samples(args, window_a, window_b) -> None:
1584
1641
  """Two-window diff report (spec §7.2.2 Pattern D).
1585
1642
 
@@ -4506,6 +4563,29 @@ def _parse_cli_date_range(
4506
4563
  return range_start, range_end
4507
4564
 
4508
4565
 
4566
+ def _emit_daily_view_table_or_json(view, args):
4567
+ """Order + emit a DailyView as the flat daily table or {daily} JSON.
4568
+
4569
+ Shared by cmd_daily's default path and its -p-only (filter, no grouping)
4570
+ path so the two cannot drift. Body is exactly the default path's order +
4571
+ emit tail; callers keep their own --format share gate upstream of this.
4572
+ """
4573
+ days = list(reversed(view.aggregated))
4574
+ if args.order == "desc":
4575
+ days = list(reversed(days))
4576
+ if args.json:
4577
+ print(_bucket_to_json(days, list_key="daily", date_key="date"))
4578
+ return
4579
+ print(_render_bucket_table(
4580
+ days,
4581
+ first_col_name="Date",
4582
+ title_suffix="Daily",
4583
+ compact_split_fn=_daily_compact_split,
4584
+ breakdown=args.breakdown,
4585
+ compact=getattr(args, "compact", False),
4586
+ ))
4587
+
4588
+
4509
4589
  def cmd_daily(args: argparse.Namespace) -> int:
4510
4590
  """Show usage report grouped by display-timezone date."""
4511
4591
  _share_validate_args(args)
@@ -4527,6 +4607,109 @@ def cmd_daily(args: argparse.Namespace) -> int:
4527
4607
  return range
4528
4608
  range_start, range_end = range
4529
4609
 
4610
+ # ── Project-axis path (issue #86 Session E / T1.11) ────────────────────
4611
+ # Gated by -i/--instances or -p/--project; the default path below is
4612
+ # untouched/byte-stable. Mirrors cmd_project's I/O-layer git-root
4613
+ # resolution + substring-OR-path filter.
4614
+ aliases = _parse_project_aliases(getattr(args, "project_aliases", None))
4615
+ project_patterns = [p.lower() for p in (getattr(args, "project", None) or [])]
4616
+
4617
+ if getattr(args, "instances", False) or project_patterns:
4618
+ joined = list(get_claude_session_entries(range_start, range_end))
4619
+ resolver_cache: dict = {}
4620
+ keyed: list = [] # [(ProjectKey, UsageEntry)] — for -i grouping
4621
+ filtered_uentries: list = [] # UsageEntry — for -p-only / --format / debug
4622
+ for je in joined:
4623
+ if je.model == "<synthetic>":
4624
+ continue
4625
+ key = _resolve_project_key(je.project_path, "git-root", resolver_cache)
4626
+ if project_patterns and not _project_filter_matches(key, project_patterns):
4627
+ continue
4628
+ ue = _usage_entry_from_joined(je)
4629
+ keyed.append((key, ue))
4630
+ filtered_uentries.append(ue)
4631
+
4632
+ # Debug scope = the filtered entries (mirrors cmd_project).
4633
+ _emit_debug_samples_if_set(args, filtered_uentries, command_label="daily")
4634
+
4635
+ # --format share gate: -i is a no-op (no project-section share render),
4636
+ # but -p IS honored by building the snapshot from the filtered view.
4637
+ if getattr(args, "format", None):
4638
+ view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
4639
+ display_tz=tz, mode=args.mode)
4640
+ display_tz_str = _share_display_tz_label(tz)
4641
+ snap = _build_daily_snapshot(
4642
+ view, period_start=range_start, period_end=range_end,
4643
+ display_tz=display_tz_str, version=_share_resolve_version(),
4644
+ theme=args.theme, reveal_projects=args.reveal_projects,
4645
+ )
4646
+ if args.order == "desc":
4647
+ snap = dataclasses.replace(snap, rows=tuple(reversed(snap.rows)))
4648
+ _share_render_and_emit(snap, args)
4649
+ return 0
4650
+
4651
+ if getattr(args, "instances", False):
4652
+ groups = _aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
4653
+ aug = _project_disambiguate_labels(
4654
+ [{"key": k, "cost_usd": sum(b.cost_usd for b in bl)}
4655
+ for k, bl in groups]
4656
+ )
4657
+ json_groups: list = []
4658
+ table_groups: list = []
4659
+ # `_project_disambiguate_labels` only suffixes the immediate
4660
+ # parent-dir basename, so two distinct git-roots like
4661
+ # `/a/x/app` + `/b/x/app` both resolve to `app (x)`. Guarantee
4662
+ # per-group JSON-key uniqueness with a counter suffix on any
4663
+ # residual collision — otherwise `_bucket_by_project_to_json`'s
4664
+ # `projects[label] = ...` silently overwrites the earlier group
4665
+ # (data loss in --json). The table_label derives from the now-
4666
+ # unique json_label, so section headers stay distinct too.
4667
+ # `json_label`s are unique by construction (the `(#N)` counter
4668
+ # above). Table labels, however, can re-collide: `_alias_for`
4669
+ # matches on `display_key` first, so a basename alias like
4670
+ # `--project-aliases app=Alias` maps BOTH same-basename git-roots
4671
+ # to "Alias" — re-merging the exact sections this feature
4672
+ # disambiguates. Apply the SAME `(#N)` counter to table labels so
4673
+ # the two distinct-total sections stay tellable apart (JSON keys
4674
+ # are untouched — they use the non-aliased `json_label`).
4675
+ seen_json_labels: dict[str, int] = {}
4676
+ seen_table_labels: dict[str, int] = {}
4677
+ for i, (k, bl) in enumerate(groups):
4678
+ ordered = list(reversed(bl)) if args.order == "desc" else bl
4679
+ base_json_label = aug.get(i, k.display_key)
4680
+ n = seen_json_labels.get(base_json_label, 0) + 1
4681
+ seen_json_labels[base_json_label] = n
4682
+ json_label = (
4683
+ base_json_label if n == 1 else f"{base_json_label} (#{n})"
4684
+ )
4685
+ base_table_label = _alias_for(k, aliases) or json_label
4686
+ nt = seen_table_labels.get(base_table_label, 0) + 1
4687
+ seen_table_labels[base_table_label] = nt
4688
+ table_label = (
4689
+ base_table_label if nt == 1
4690
+ else f"{base_table_label} (#{nt})"
4691
+ )
4692
+ json_groups.append((json_label, ordered))
4693
+ table_groups.append((table_label, ordered))
4694
+ if args.json:
4695
+ print(_bucket_by_project_to_json(json_groups, date_key="date"))
4696
+ return 0
4697
+ print(_render_bucket_table(
4698
+ [], first_col_name="Date", title_suffix="Daily",
4699
+ compact_split_fn=_daily_compact_split,
4700
+ breakdown=args.breakdown,
4701
+ compact=getattr(args, "compact", False),
4702
+ project_groups=table_groups,
4703
+ ))
4704
+ return 0
4705
+
4706
+ # -p only (no -i): filter-only → normal date-aggregated daily output.
4707
+ view = build_daily_view(filtered_uentries, now_utc=_command_as_of(),
4708
+ display_tz=tz, mode=args.mode)
4709
+ _emit_daily_view_table_or_json(view, args)
4710
+ return 0
4711
+
4712
+ # ── Default path (UNCHANGED) ───────────────────────────────────────────
4530
4713
  # Collect entries.
4531
4714
  all_entries = get_entries(range_start, range_end)
4532
4715
 
@@ -4543,10 +4726,6 @@ def cmd_daily(args: argparse.Namespace) -> int:
4543
4726
  # `_aggregate_daily` call is the same one we used inline.
4544
4727
  view = build_daily_view(all_entries, now_utc=_command_as_of(),
4545
4728
  display_tz=tz, mode=args.mode)
4546
- # `_aggregate_daily` returned ascending order; build_daily_view stores
4547
- # `aggregated` newest-first. CLI's default order is ascending, so
4548
- # re-reverse to match the prior on-the-wire shape.
4549
- days = list(reversed(view.aggregated))
4550
4729
 
4551
4730
  # Shareable-reports gate: --format short-circuits the JSON / table
4552
4731
  # dispatch via `_share_render_and_emit`. The mutex in
@@ -4577,23 +4756,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
4577
4756
  _share_render_and_emit(snap, args)
4578
4757
  return 0
4579
4758
 
4580
- # Apply sort order.
4581
- if args.order == "desc":
4582
- days = list(reversed(days))
4583
-
4584
- if args.json:
4585
- print(_bucket_to_json(days, list_key="daily", date_key="date"))
4586
- return 0
4587
-
4588
- # Table output.
4589
- print(_render_bucket_table(
4590
- days,
4591
- first_col_name="Date",
4592
- title_suffix="Daily",
4593
- compact_split_fn=_daily_compact_split,
4594
- breakdown=args.breakdown,
4595
- compact=getattr(args, "compact", False),
4596
- ))
4759
+ # Order + emit the flat daily table / {daily} JSON. Extracted into
4760
+ # `_emit_daily_view_table_or_json` so this default path and the
4761
+ # -p-only (filter, no grouping) path above stay byte-identical.
4762
+ _emit_daily_view_table_or_json(view, args)
4597
4763
  return 0
4598
4764
 
4599
4765
 
@@ -4804,6 +4970,32 @@ def cmd_weekly(args: argparse.Namespace) -> int:
4804
4970
  return 0
4805
4971
 
4806
4972
 
4973
+ def _detect_codex_fast_service_tier() -> bool:
4974
+ """True iff ``~/.codex/config.toml`` requests the fast/priority tier.
4975
+
4976
+ Reads from ``~/.codex`` only (single root; ``$CODEX_HOME`` multi-root is
4977
+ deferred — see #108). Tolerates an absent/unreadable config (→ False →
4978
+ standard tier).
4979
+ """
4980
+ cfg = pathlib.Path.home() / ".codex" / "config.toml"
4981
+ try:
4982
+ content = cfg.read_text(encoding="utf-8", errors="replace")
4983
+ except OSError:
4984
+ return False
4985
+ return _codex_config_requests_fast_service_tier(content)
4986
+
4987
+
4988
+ def _resolve_codex_speed(requested: str) -> str:
4989
+ """Resolve a ``--speed`` value to an effective tier.
4990
+
4991
+ ``auto`` → ``fast`` iff ``~/.codex/config.toml`` requests it, else
4992
+ ``standard``. ``fast``/``standard`` pass through unchanged.
4993
+ """
4994
+ if requested == "auto":
4995
+ return "fast" if _detect_codex_fast_service_tier() else "standard"
4996
+ return requested
4997
+
4998
+
4807
4999
  def cmd_codex_daily(args: argparse.Namespace) -> int:
4808
5000
  """Show Codex usage report grouped by date (display tz, --tz, or --timezone)."""
4809
5001
  config = load_config()
@@ -4823,13 +5015,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
4823
5015
  range_start, range_end = range
4824
5016
 
4825
5017
  entries = get_codex_entries(range_start, range_end)
4826
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily")
5018
+ speed = _resolve_codex_speed(args.speed)
5019
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily", speed=speed)
4827
5020
  # Route through ``build_codex_daily_view`` (issue #58). The View
4828
5021
  # wraps ``_aggregate_codex_daily`` without changing it — preserves
4829
5022
  # LiteLLM token semantics, intentional dedup vs upstream, and
4830
5023
  # ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
4831
5024
  view = build_codex_daily_view(
4832
- entries, now_utc=_command_as_of(), tz_name=tz_name,
5025
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
4833
5026
  )
4834
5027
  days = list(view.rows) # asc — matches aggregator default
4835
5028
  if args.order == "desc":
@@ -4883,10 +5076,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
4883
5076
  range_start, range_end = range
4884
5077
 
4885
5078
  entries = get_codex_entries(range_start, range_end)
4886
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly")
5079
+ speed = _resolve_codex_speed(args.speed)
5080
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly", speed=speed)
4887
5081
  # Route through ``build_codex_monthly_view`` (issue #58).
4888
5082
  view = build_codex_monthly_view(
4889
- entries, now_utc=_command_as_of(), tz_name=tz_name,
5083
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
4890
5084
  )
4891
5085
  months = list(view.rows)
4892
5086
  if args.order == "desc":
@@ -4943,11 +5137,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
4943
5137
  week_start_idx = WEEKDAY_MAP[week_start_name]
4944
5138
 
4945
5139
  entries = get_codex_entries(range_start, range_end)
4946
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly")
5140
+ speed = _resolve_codex_speed(args.speed)
5141
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly", speed=speed)
4947
5142
  # Route through ``build_codex_weekly_view`` (issue #58).
4948
5143
  view = build_codex_weekly_view(
4949
5144
  entries, now_utc=now_utc, tz_name=tz_name,
4950
- week_start_idx=week_start_idx,
5145
+ week_start_idx=week_start_idx, speed=speed,
4951
5146
  )
4952
5147
  weeks = list(view.rows)
4953
5148
  if args.order == "desc":
@@ -5004,12 +5199,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5004
5199
  range_start, range_end = range
5005
5200
 
5006
5201
  entries = get_codex_entries(range_start, range_end)
5007
- _emit_codex_debug_samples_if_set(args, entries, command_label="codex-session")
5202
+ speed = _resolve_codex_speed(args.speed)
5203
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-session", speed=speed)
5008
5204
  # Route through ``build_codex_session_view`` (issue #58). View rows
5009
5205
  # come descending by last_activity (aggregator default + upstream
5010
5206
  # parity); --order asc reverses.
5011
5207
  view = build_codex_session_view(
5012
- entries, now_utc=_command_as_of(), tz_name=tz_name,
5208
+ entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
5013
5209
  )
5014
5210
  sessions = list(view.rows)
5015
5211
  if args.order == "asc":
@@ -9959,6 +10155,12 @@ def _add_codex_shared_args(parser: argparse.ArgumentParser) -> None:
9959
10155
  "-O", "--offline", action=argparse.BooleanOptionalAction, default=False,
9960
10156
  help="Accepted for drop-in compat with ccusage-codex; we are always offline.",
9961
10157
  )
10158
+ parser.add_argument(
10159
+ "--speed", choices=("auto", "standard", "fast"), default="auto",
10160
+ help="Codex pricing tier. auto (default) reads service_tier from "
10161
+ "~/.codex/config.toml (fast|priority -> fast pricing); fast "
10162
+ "forces the fast-tier multiplier; standard forces base pricing.",
10163
+ )
9962
10164
  parser.add_argument(
9963
10165
  "--tz", default=None, type=_argparse_tz, metavar="TZ",
9964
10166
  help="Display timezone: local, utc, or IANA name. Overrides "
@@ -10131,6 +10333,8 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
10131
10333
  cctally daily --since 20260414 --breakdown
10132
10334
  cctally daily --since 20260414 --json
10133
10335
  cctally daily --order desc
10336
+ cctally daily --instances
10337
+ cctally daily -i --project-aliases repos=Repos
10134
10338
  """),
10135
10339
  )
10136
10340
  p.add_argument(
@@ -10168,6 +10372,28 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
10168
10372
  help="Display timezone: local, utc, or IANA name. "
10169
10373
  "Overrides config display.tz for this call.",
10170
10374
  )
10375
+ p.add_argument(
10376
+ "-i", "--instances",
10377
+ action="store_true",
10378
+ default=False,
10379
+ help="Group the report by project (git-root).",
10380
+ )
10381
+ p.add_argument(
10382
+ "-p", "--project",
10383
+ action="append",
10384
+ default=None,
10385
+ metavar="PATTERN",
10386
+ help="Filter to projects matching PATTERN (substring of the project "
10387
+ "label or path; repeatable, OR semantics).",
10388
+ )
10389
+ p.add_argument(
10390
+ "--project-aliases",
10391
+ dest="project_aliases",
10392
+ default=None,
10393
+ metavar="PAIRS",
10394
+ help="Comma-separated key=Label pairs overriding project display "
10395
+ "labels (e.g. cctally-dev=Tracker). Display-only.",
10396
+ )
10171
10397
  _add_ccusage_alias_args(p, ansi_emit=False)
10172
10398
  _add_mode_arg(p)
10173
10399
  _add_share_args(p)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.16.0",
3
+ "version": "1.18.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": {