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 +13 -0
- package/bin/_cctally_cache.py +17 -1
- package/bin/_lib_aggregators.py +45 -5
- package/bin/_lib_pricing.py +43 -1
- package/bin/_lib_render.py +84 -42
- package/bin/_lib_view_models.py +8 -8
- package/bin/cctally +263 -37
- package/package.json +1 -1
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -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
|
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_render.py
CHANGED
|
@@ -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
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|
1171
|
-
tot_output = sum(d.output_tokens for d in
|
|
1172
|
-
tot_cc = sum(d.cache_creation_tokens for d in
|
|
1173
|
-
tot_cr = sum(d.cache_read_tokens for d in
|
|
1174
|
-
tot_tokens = sum(d.total_tokens for d in
|
|
1175
|
-
tot_cost = sum(d.cost_usd for d in
|
|
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),
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -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
|
-
#
|
|
4581
|
-
|
|
4582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|