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 +16 -0
- package/bin/_lib_aggregators.py +12 -6
- package/bin/_lib_pricing.py +43 -1
- package/bin/_lib_view_models.py +16 -16
- package/bin/cctally +100 -20
- package/package.json +1 -1
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
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -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
|
package/bin/_lib_pricing.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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``
|
|
4363
|
-
#
|
|
4364
|
-
#
|
|
4365
|
-
# the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|