copilot-session-usage 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1016 @@
1
+ """Shared cost-analysis core for coding-agent session cost analytics.
2
+
3
+ This module is a library, not an entry point. It has zero knowledge of
4
+ *where* session logs live — that is provider-specific discovery logic (see
5
+ ``vscode.py`` for the VS Code Copilot extension). It only knows how to:
6
+
7
+ - price tokens against bundled ``data/models-and-pricing.yml`` (or embedded defaults)
8
+ - parse the "Copilot debug log" JSONL event schema (``llm_request`` events)
9
+ shared across GitHub Copilot surfaces
10
+ - aggregate per-file stats into a session-level report
11
+ - shape that report to a requested detail level (``minimal``/``compact``/``full``)
12
+ - render JSON or a human-readable table
13
+ - expose reusable Click option decorators so every provider CLI looks the same
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import json
20
+ import re
21
+ from collections.abc import Sequence
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import click
27
+
28
+ # ─── Bundled data file access (works in wheels and editable installs) ───────
29
+
30
+
31
+ def _read_data_file(name: str) -> str | None:
32
+ """Read a bundled data file using importlib.resources.
33
+
34
+ Returns the file contents as a string, or None if the file is not found.
35
+ This works whether the package is installed as a wheel (zip) or editable.
36
+ """
37
+ try:
38
+ from importlib.resources import files
39
+
40
+ ref = files("copilot_session_usage.data") / name
41
+ return ref.read_text(encoding="utf-8")
42
+ except Exception:
43
+ return None
44
+
45
+
46
+ # ─── Embedded default pricing (USD per million tokens) ───────────────────────
47
+ # Approximate estimates; update with: just refresh-pricing
48
+
49
+ DEFAULT_PRICING: dict = {
50
+ "_note": "Approximate per-token costs (USD/M). Estimates only — not GitHub's billing.",
51
+ "_source": "embedded defaults",
52
+ "models": {
53
+ # Anthropic Claude
54
+ "claude-3-5-sonnet": [
55
+ {"input_per_m": 0.30, "output_per_m": 1.50, "cache_per_m": 0.030, "tier": "Default"}
56
+ ],
57
+ "claude-3-7-sonnet": [
58
+ {"input_per_m": 0.30, "output_per_m": 1.50, "cache_per_m": 0.030, "tier": "Default"}
59
+ ],
60
+ "claude-sonnet-4-5": [
61
+ {"input_per_m": 0.30, "output_per_m": 1.50, "cache_per_m": 0.030, "tier": "Default"}
62
+ ],
63
+ "claude-sonnet-4.6": [
64
+ {"input_per_m": 0.30, "output_per_m": 1.50, "cache_per_m": 0.030, "tier": "Default"}
65
+ ],
66
+ "claude-opus-4-5": [
67
+ {"input_per_m": 1.50, "output_per_m": 7.50, "cache_per_m": 0.150, "tier": "Default"}
68
+ ],
69
+ "claude-opus-4.6": [
70
+ {"input_per_m": 1.50, "output_per_m": 7.50, "cache_per_m": 0.150, "tier": "Default"}
71
+ ],
72
+ "claude-haiku-4-5": [
73
+ {"input_per_m": 0.08, "output_per_m": 0.40, "cache_per_m": 0.008, "tier": "Default"}
74
+ ],
75
+ "claude-haiku-4.6": [
76
+ {"input_per_m": 0.08, "output_per_m": 0.40, "cache_per_m": 0.008, "tier": "Default"}
77
+ ],
78
+ # OpenAI
79
+ "gpt-4o": [
80
+ {"input_per_m": 0.25, "output_per_m": 1.00, "cache_per_m": 0.025, "tier": "Default"}
81
+ ],
82
+ "gpt-4o-mini": [
83
+ {"input_per_m": 0.015, "output_per_m": 0.060, "cache_per_m": 0.002, "tier": "Default"}
84
+ ],
85
+ "o3": [
86
+ {"input_per_m": 10.00, "output_per_m": 40.00, "cache_per_m": 1.000, "tier": "Default"}
87
+ ],
88
+ "o3-mini": [
89
+ {"input_per_m": 1.10, "output_per_m": 4.40, "cache_per_m": 0.550, "tier": "Default"}
90
+ ],
91
+ "o4-mini": [
92
+ {"input_per_m": 1.10, "output_per_m": 4.40, "cache_per_m": 0.550, "tier": "Default"}
93
+ ],
94
+ # Google Gemini
95
+ "gemini-1.5-pro": [
96
+ {"input_per_m": 0.125, "output_per_m": 0.375, "cache_per_m": 0.013, "tier": "Default"}
97
+ ],
98
+ "gemini-2.0-flash": [
99
+ {"input_per_m": 0.075, "output_per_m": 0.30, "cache_per_m": 0.008, "tier": "Default"}
100
+ ],
101
+ "gemini-2.5-pro": [
102
+ {"input_per_m": 0.125, "output_per_m": 0.375, "cache_per_m": 0.013, "tier": "Default"}
103
+ ],
104
+ # Moonshot (Azure-hosted)
105
+ "Kimi-K2.6-azure": [
106
+ {"input_per_m": 0.15, "output_per_m": 0.60, "cache_per_m": 0.015, "tier": "Default"}
107
+ ],
108
+ # Fallback for unknown models
109
+ "default": [
110
+ {"input_per_m": 0.30, "output_per_m": 1.50, "cache_per_m": 0.030, "tier": "Default"}
111
+ ],
112
+ },
113
+ }
114
+
115
+
116
+ # ─── Pricing helpers ──────────────────────────────────────────────────────────
117
+
118
+
119
+ def _normalize_model_name(name: str) -> str:
120
+ """Normalize a raw model name from the YAML to our internal key format.
121
+
122
+ Examples:
123
+ "GPT-5.4" → "gpt-5.4"
124
+ "Claude Sonnet 4.6" → "claude-sonnet-4.6"
125
+ "Claude Sonnet 5[^sonnet-5-promo]" → "claude-sonnet-5"
126
+ "Claude Opus 4.8 (fast mode) (preview)" → "claude-opus-4.8"
127
+ """
128
+ cleaned = re.sub(r"\[\^[^\]]+\]", "", name)
129
+ cleaned = re.sub(r"\s*\([^)]*\)", "", cleaned)
130
+ cleaned = cleaned.strip()
131
+ return re.sub(r"\s+", "-", cleaned.lower())
132
+
133
+
134
+ def _parse_threshold(threshold: str) -> int | None:
135
+ """Parse a threshold string from the YAML into a token count.
136
+
137
+ Examples:
138
+ "≤ 272K" → 272_000
139
+ "> 272K" → None (unbounded / long-context tier)
140
+ "Not applicable" → None
141
+ """
142
+ if not threshold or threshold.lower() in ("not applicable", "n/a", ""):
143
+ return None
144
+ match = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*([KM]?)", threshold)
145
+ if not match:
146
+ return None
147
+ value = float(match.group(1))
148
+ suffix = match.group(2).upper()
149
+ multiplier = {"K": 1_000, "M": 1_000_000}.get(suffix, 1)
150
+ return int(value * multiplier)
151
+
152
+
153
+ def _parse_price(price: str) -> float:
154
+ """Parse a price string like '$2.50' into a float."""
155
+ cleaned = price.replace("$", "").replace(",", "").strip()
156
+ try:
157
+ return float(cleaned)
158
+ except ValueError:
159
+ return 0.0
160
+
161
+
162
+ def _load_custom_pricing(ref_dir: Path | None = None) -> dict[str, list[dict]] | None:
163
+ """Load custom model pricing from custom-models-pricing.yml.
164
+
165
+ Returns a dict of model_name → [tier_dict] or None if the file is missing
166
+ or unreadable. Custom entries override/extend the standard pricing.
167
+
168
+ When ``ref_dir`` is None, reads from the bundled package data.
169
+ """
170
+ text: str | None = None
171
+ if ref_dir is not None:
172
+ custom_path = ref_dir / "custom-models-pricing.yml"
173
+ if custom_path.exists():
174
+ with contextlib.suppress(Exception):
175
+ text = custom_path.read_text(encoding="utf-8")
176
+ else:
177
+ text = _read_data_file("custom-models-pricing.yml")
178
+
179
+ if text is None:
180
+ return None
181
+ try:
182
+ from ruamel.yaml import YAML
183
+
184
+ yaml = YAML(typ="safe")
185
+ entries = yaml.load(text)
186
+ if not isinstance(entries, list):
187
+ return None
188
+ models: dict[str, list[dict]] = {}
189
+ for entry in entries:
190
+ if not isinstance(entry, dict):
191
+ continue
192
+ raw_name = entry.get("model", "")
193
+ if not raw_name:
194
+ continue
195
+ name = raw_name.strip()
196
+ tier = {
197
+ "input_per_m": _parse_price(entry.get("input", "0")),
198
+ "output_per_m": _parse_price(entry.get("output", "0")),
199
+ "cache_per_m": _parse_price(entry.get("cached_input", "0")),
200
+ "tier": entry.get("tier", "Default"),
201
+ "threshold_tokens": None,
202
+ }
203
+ models[name] = [tier]
204
+ return models if models else None
205
+ except Exception:
206
+ return None
207
+
208
+
209
+ def load_pricing(ref_dir: Path | None = None) -> dict:
210
+ """Load pricing from models-and-pricing.yml, merge custom-models-pricing.yml.
211
+
212
+ Fall back to embedded defaults if files are missing or unreadable.
213
+
214
+ Args:
215
+ ref_dir: Directory containing models-and-pricing.yml and
216
+ custom-models-pricing.yml. If None, reads from the bundled data
217
+ shipped with the package via importlib.resources.
218
+ """
219
+ text: str | None = None
220
+ source = "embedded defaults"
221
+ if ref_dir is not None:
222
+ yaml_path = ref_dir / "models-and-pricing.yml"
223
+ if yaml_path.exists():
224
+ with contextlib.suppress(Exception):
225
+ text = yaml_path.read_text(encoding="utf-8")
226
+ source = str(yaml_path)
227
+ else:
228
+ text = _read_data_file("models-and-pricing.yml")
229
+ source = "bundled models-and-pricing.yml"
230
+
231
+ pricing: dict | None = None
232
+ if text is not None:
233
+ try:
234
+ from ruamel.yaml import YAML
235
+
236
+ yaml = YAML(typ="safe")
237
+ entries = yaml.load(text)
238
+ if isinstance(entries, list):
239
+ pricing = _build_pricing_from_yaml(entries, source)
240
+ except Exception:
241
+ pass
242
+ if pricing is None:
243
+ pricing = DEFAULT_PRICING.copy()
244
+
245
+ custom_models = _load_custom_pricing(ref_dir)
246
+ if custom_models:
247
+ pricing.setdefault("models", {}).update(custom_models)
248
+
249
+ return pricing
250
+
251
+
252
+ def _build_pricing_from_yaml(entries: list[dict], source: str) -> dict:
253
+ """Convert YAML entries into the tier-aware pricing dict."""
254
+ models: dict[str, list[dict]] = {}
255
+ for entry in entries:
256
+ if not isinstance(entry, dict):
257
+ continue
258
+ raw_name = entry.get("model", "")
259
+ if not raw_name:
260
+ continue
261
+ name = _normalize_model_name(raw_name)
262
+ tier = {
263
+ "input_per_m": _parse_price(entry.get("input", "0")),
264
+ "output_per_m": _parse_price(entry.get("output", "0")),
265
+ "cache_per_m": _parse_price(entry.get("cached_input", "0")),
266
+ "tier": entry.get("tier", "Default"),
267
+ "threshold_tokens": _parse_threshold(entry.get("threshold", "")),
268
+ }
269
+ models.setdefault(name, []).append(tier)
270
+
271
+ for name, tiers in models.items():
272
+ models[name] = sorted(
273
+ tiers, key=lambda t: (t["threshold_tokens"] is None, t["threshold_tokens"] or 0)
274
+ )
275
+
276
+ if "default" not in models:
277
+ models["default"] = DEFAULT_PRICING["models"]["default"]
278
+
279
+ return {
280
+ "_note": (
281
+ "Per-token costs in USD per million tokens. Source: GitHub Copilot official pricing."
282
+ ),
283
+ "_source": source,
284
+ "models": models,
285
+ }
286
+
287
+
288
+ def _get_model_rates(model: str, input_tok: int, pricing: dict[str, Any]) -> dict[str, Any]:
289
+ """Return pricing rates for a model at the given input-token volume.
290
+
291
+ Uses exact match, then prefix match, then 'default'. When the matched
292
+ model has multiple tiers (e.g. GPT-5.4 ≤272K vs >272K), selects the
293
+ appropriate tier based on ``input_tok``.
294
+ """
295
+ models = pricing.get("models", {})
296
+ tiers = models.get(model)
297
+ if tiers is None:
298
+ for key in models:
299
+ if key != "default" and model.startswith(key):
300
+ tiers = models[key]
301
+ break
302
+ if tiers is None:
303
+ tiers = models.get("default", [{}])
304
+
305
+ if len(tiers) == 1:
306
+ return tiers[0] # type: ignore[no-any-return]
307
+
308
+ for tier in tiers:
309
+ threshold = tier.get("threshold_tokens")
310
+ if threshold is not None and input_tok <= threshold:
311
+ return tier # type: ignore[no-any-return]
312
+ return tiers[-1] # type: ignore[no-any-return]
313
+
314
+
315
+ def model_uses_fallback_pricing(model: str, pricing: dict[str, Any]) -> bool:
316
+ """Return True if `model` matches no pricing key except the generic 'default'."""
317
+ models = pricing.get("models", {})
318
+ if model in models:
319
+ return False
320
+ return not any(key != "default" and model.startswith(key) for key in models)
321
+
322
+
323
+ def estimate_cost(
324
+ input_tok: int, output_tok: int, cached_tok: int, model: str, pricing: dict[str, Any]
325
+ ) -> float:
326
+ """Compute estimated USD cost. Cached tokens are billed at cache_per_m rate.
327
+
328
+ Threshold-aware: the correct tier is selected automatically based on
329
+ ``input_tok`` so long-context requests use the higher rate.
330
+ """
331
+ rates = _get_model_rates(model, input_tok, pricing)
332
+ billable_input = max(0, input_tok - cached_tok)
333
+ return ( # type: ignore[no-any-return]
334
+ billable_input * rates.get("input_per_m", 0.0) / 1_000_000
335
+ + cached_tok * rates.get("cache_per_m", 0.0) / 1_000_000
336
+ + output_tok * rates.get("output_per_m", 0.0) / 1_000_000
337
+ )
338
+
339
+
340
+ def estimate_cost_for_file(per_model: dict[str, dict[str, int]], pricing: dict[str, Any]) -> float:
341
+ """Sum costs per-model bucket — avoids mis-attribution when a file uses >1 model."""
342
+ return sum(
343
+ estimate_cost(v["input"], v["output"], v["cached"], model, pricing)
344
+ for model, v in per_model.items()
345
+ )
346
+
347
+
348
+ def _dominant_model(per_model: dict[str, dict]) -> str:
349
+ """Return the model with the most input tokens (representative label only)."""
350
+ if not per_model:
351
+ return "unknown"
352
+ return max(per_model, key=lambda m: per_model[m]["input"])
353
+
354
+
355
+ # ─── JSONL parsing ────────────────────────────────────────────────────────────
356
+
357
+
358
+ def parse_jsonl_file(path: Path) -> dict:
359
+ """Parse a single .jsonl file and return aggregated token stats.
360
+
361
+ Only ``llm_request`` events carry token data — every other event type
362
+ is ignored for cost purposes, though non-LLM timestamps still count
363
+ toward wall-clock ``first_ts``/``last_ts``.
364
+ """
365
+ stats: dict = {
366
+ "file": path.name,
367
+ "input_tokens": 0,
368
+ "output_tokens": 0,
369
+ "cached_tokens": 0,
370
+ "llm_calls": 0,
371
+ "per_model": {},
372
+ "models": set(),
373
+ "first_ts": None,
374
+ "last_ts": None,
375
+ "first_llm_ts": None,
376
+ "last_llm_ts": None,
377
+ }
378
+ try:
379
+ with path.open(encoding="utf-8") as f:
380
+ for raw in f:
381
+ line = raw.strip()
382
+ if not line:
383
+ continue
384
+ try:
385
+ obj = json.loads(line)
386
+ except json.JSONDecodeError:
387
+ continue
388
+ ts: int | None = obj.get("ts")
389
+ if ts is not None:
390
+ if stats["first_ts"] is None or ts < stats["first_ts"]:
391
+ stats["first_ts"] = ts
392
+ if stats["last_ts"] is None or ts > stats["last_ts"]:
393
+ stats["last_ts"] = ts
394
+ if obj.get("type") == "llm_request":
395
+ attrs = obj.get("attrs", {})
396
+ inp = attrs.get("inputTokens", 0)
397
+ out = attrs.get("outputTokens", 0)
398
+ cch = attrs.get("cachedTokens", 0)
399
+ stats["input_tokens"] += inp
400
+ stats["output_tokens"] += out
401
+ stats["cached_tokens"] += cch
402
+ stats["llm_calls"] += 1
403
+ model: str = attrs.get("model", "") or "unknown"
404
+ stats["models"].add(model)
405
+ bucket = stats["per_model"].setdefault(
406
+ model, {"input": 0, "output": 0, "cached": 0, "calls": 0}
407
+ )
408
+ bucket["input"] += inp
409
+ bucket["output"] += out
410
+ bucket["cached"] += cch
411
+ bucket["calls"] += 1
412
+ if ts is not None:
413
+ if stats["first_llm_ts"] is None or ts < stats["first_llm_ts"]:
414
+ stats["first_llm_ts"] = ts
415
+ if stats["last_llm_ts"] is None or ts > stats["last_llm_ts"]:
416
+ stats["last_llm_ts"] = ts
417
+ except OSError:
418
+ pass
419
+ stats["models"] = sorted(stats["models"])
420
+ return stats
421
+
422
+
423
+ def get_subagent_names(main_jsonl: Path) -> dict[str, str]:
424
+ """Extract {childSessionId → childTitle} from child_session_ref events."""
425
+ mapping: dict[str, str] = {}
426
+ if not main_jsonl.exists():
427
+ return mapping
428
+ try:
429
+ with main_jsonl.open(encoding="utf-8") as f:
430
+ for raw in f:
431
+ line = raw.strip()
432
+ if not line:
433
+ continue
434
+ try:
435
+ obj = json.loads(line)
436
+ except json.JSONDecodeError:
437
+ continue
438
+ if obj.get("type") == "child_session_ref":
439
+ attrs = obj.get("attrs", {})
440
+ child_id: str = attrs.get("childSessionId", "")
441
+ child_title: str = attrs.get("childTitle", "unknown")
442
+ if child_id:
443
+ mapping[child_id] = child_title
444
+ except OSError:
445
+ pass
446
+ return mapping
447
+
448
+
449
+ def _resolve_subagent_name(filename: str, subagent_names: dict[str, str]) -> tuple[str, str]:
450
+ """Parse JSONL filename to (display_name, subagent_id).
451
+
452
+ Pattern: runSubagent-<AgentName>-functions.runSubagent:<id>.jsonl
453
+ The separator between ``functions.runSubagent`` and the ID is treated as
454
+ a wildcard — any run of non-alphanumeric characters is accepted. This
455
+ makes the parser resilient to OS-specific filename sanitisation (e.g.
456
+ Windows replacing ``:`` with ``-`` or ``__``).
457
+ """
458
+ stem = filename.removesuffix(".jsonl")
459
+ if not stem.startswith("runSubagent-"):
460
+ name = (
461
+ "main"
462
+ if stem == "main"
463
+ else ("title-generation" if stem.startswith("title-") else stem)
464
+ )
465
+ return name, ""
466
+
467
+ match = re.search(r"functions\.runSubagent(.+)$", stem)
468
+ if match:
469
+ # Strip any separator characters to recover the bare ID
470
+ id_part = re.sub(r"^[^a-zA-Z0-9]+", "", match.group(1))
471
+ subagent_id = f"functions.runSubagent:{id_part}"
472
+ if subagent_id in subagent_names:
473
+ return subagent_names[subagent_id], subagent_id
474
+ name_part = stem[: match.start()].removeprefix("runSubagent-").rstrip("-")
475
+ return name_part, subagent_id
476
+
477
+ return stem.removeprefix("runSubagent-"), ""
478
+
479
+
480
+ # ─── Session analysis ─────────────────────────────────────────────────────────
481
+
482
+
483
+ def ts_to_iso(ts_ms: int | None) -> str | None:
484
+ """Convert epoch milliseconds to ISO 8601 UTC string."""
485
+ if ts_ms is None:
486
+ return None
487
+ return datetime.fromtimestamp(ts_ms / 1000.0, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
488
+
489
+
490
+ def analyze_session(session_dir: Path, pricing: dict) -> dict:
491
+ """Analyze all JSONL files in a session directory.
492
+
493
+ Reads every *.jsonl file to capture costs from both the parent agent
494
+ (main.jsonl) and all subagents (runSubagent-*.jsonl). Subagents
495
+ often account for 70-80% of total cost.
496
+ """
497
+ session_dir = Path(session_dir)
498
+ session_id = session_dir.name
499
+ subagent_names = get_subagent_names(session_dir / "main.jsonl")
500
+
501
+ file_results: list[dict] = []
502
+ global_first_ts: int | None = None
503
+ global_last_ts: int | None = None
504
+ all_models: set[str] = set()
505
+
506
+ for jsonl_file in sorted(session_dir.glob("*.jsonl")):
507
+ stats = parse_jsonl_file(jsonl_file)
508
+ if stats["llm_calls"] == 0:
509
+ continue
510
+ file_results.append(stats)
511
+ if stats["first_ts"] is not None and (
512
+ global_first_ts is None or stats["first_ts"] < global_first_ts
513
+ ):
514
+ global_first_ts = stats["first_ts"]
515
+ if stats["last_ts"] is not None and (
516
+ global_last_ts is None or stats["last_ts"] > global_last_ts
517
+ ):
518
+ global_last_ts = stats["last_ts"]
519
+ all_models.update(stats["models"])
520
+
521
+ total_input = sum(s["input_tokens"] for s in file_results)
522
+ total_output = sum(s["output_tokens"] for s in file_results)
523
+ total_cached = sum(s["cached_tokens"] for s in file_results)
524
+ total_calls = sum(s["llm_calls"] for s in file_results)
525
+
526
+ global_per_model: dict[str, dict] = {}
527
+ for stats in file_results:
528
+ for model, tokens in stats.get("per_model", {}).items():
529
+ if model not in global_per_model:
530
+ global_per_model[model] = {"input": 0, "output": 0, "cached": 0, "calls": 0}
531
+ global_per_model[model]["input"] += tokens["input"]
532
+ global_per_model[model]["output"] += tokens["output"]
533
+ global_per_model[model]["cached"] += tokens["cached"]
534
+ global_per_model[model]["calls"] += tokens["calls"]
535
+
536
+ subagents: list[dict] = []
537
+ total_usd = 0.0
538
+
539
+ first_llm_ts: int | None = None
540
+ last_llm_ts: int | None = None
541
+
542
+ for stats in file_results:
543
+ if stats.get("first_llm_ts") is not None and (
544
+ first_llm_ts is None or stats["first_llm_ts"] < first_llm_ts
545
+ ):
546
+ first_llm_ts = stats["first_llm_ts"]
547
+ if stats.get("last_llm_ts") is not None and (
548
+ last_llm_ts is None or stats["last_llm_ts"] > last_llm_ts
549
+ ):
550
+ last_llm_ts = stats["last_llm_ts"]
551
+
552
+ per_model = stats.get("per_model", {})
553
+ dominant = _dominant_model(per_model)
554
+ name, subagent_id = _resolve_subagent_name(stats["file"], subagent_names)
555
+ usd = estimate_cost_for_file(per_model, pricing)
556
+ total_usd += usd
557
+ subagents.append(
558
+ {
559
+ "file": stats["file"],
560
+ "name": name,
561
+ "subagent_id": subagent_id or None,
562
+ "model": dominant,
563
+ "input_tokens": stats["input_tokens"],
564
+ "output_tokens": stats["output_tokens"],
565
+ "cached_tokens": stats["cached_tokens"],
566
+ "llm_calls": stats["llm_calls"],
567
+ "estimated_usd": round(usd, 6),
568
+ }
569
+ )
570
+
571
+ duration_s = None
572
+ if global_first_ts is not None and global_last_ts is not None:
573
+ duration_s = round((global_last_ts - global_first_ts) / 1000.0)
574
+
575
+ active_duration_s = None
576
+ if first_llm_ts is not None and last_llm_ts is not None:
577
+ active_duration_s = round((last_llm_ts - first_llm_ts) / 1000.0)
578
+
579
+ cache_ratio = round(total_cached / total_input, 3) if total_input > 0 else 0.0
580
+
581
+ model_breakdown = sorted(
582
+ [
583
+ {
584
+ "model": model,
585
+ "input_tokens": v["input"],
586
+ "output_tokens": v["output"],
587
+ "cached_tokens": v["cached"],
588
+ "llm_calls": v["calls"],
589
+ "estimated_usd": round(
590
+ estimate_cost(v["input"], v["output"], v["cached"], model, pricing), 6
591
+ ),
592
+ }
593
+ for model, v in global_per_model.items()
594
+ ],
595
+ key=lambda x: x["estimated_usd"],
596
+ reverse=True,
597
+ )
598
+
599
+ fallback_pricing_models = sorted(
600
+ model for model in global_per_model if model_uses_fallback_pricing(model, pricing)
601
+ )
602
+
603
+ return {
604
+ "session_id": session_id,
605
+ "session_dir": str(session_dir),
606
+ "title": None,
607
+ "started_at": ts_to_iso(global_first_ts),
608
+ "ended_at": ts_to_iso(global_last_ts),
609
+ "duration_seconds": duration_s,
610
+ "active_duration_seconds": active_duration_s,
611
+ "total": {
612
+ "input_tokens": total_input,
613
+ "output_tokens": total_output,
614
+ "cached_tokens": total_cached,
615
+ "llm_calls": total_calls,
616
+ "estimated_usd": round(total_usd, 4),
617
+ "cache_ratio": cache_ratio,
618
+ },
619
+ "models": [m["model"] for m in model_breakdown] or sorted(all_models),
620
+ "fallback_pricing_models": fallback_pricing_models,
621
+ "model_breakdown": model_breakdown,
622
+ "subagents": subagents,
623
+ "pricing_note": (
624
+ "Cost estimates are approximations. Update rates in data/models-and-pricing.yml. "
625
+ "Models listed in fallback_pricing_models were priced with the generic "
626
+ "'default' rate and may be inaccurate. "
627
+ "Custom pricing for non-Copilot models can be added to data/custom-models-pricing.yml."
628
+ ),
629
+ }
630
+
631
+
632
+ # ─── Output shaping (detail levels) ───────────────────────────────────────────
633
+
634
+ DETAIL_LEVELS: tuple[str, ...] = ("minimal", "compact", "full")
635
+
636
+
637
+ def shape_session(data: dict, detail: str) -> dict:
638
+ """Shape a single-session report to the requested detail level.
639
+
640
+ - ``minimal``: identity, timing, and the ``total`` block only.
641
+ - ``compact``: minimal + ``models`` list, ``fallback_pricing_models``, and
642
+ ``pricing_note``. No per-model or per-subagent breakdown.
643
+ - ``full``: everything, including ``model_breakdown`` and ``subagents``.
644
+ """
645
+ if detail == "full":
646
+ return data
647
+
648
+ shaped = {
649
+ "session_id": data.get("session_id"),
650
+ "title": data.get("title"),
651
+ "started_at": data.get("started_at"),
652
+ "ended_at": data.get("ended_at"),
653
+ "duration_seconds": data.get("duration_seconds"),
654
+ "active_duration_seconds": data.get("active_duration_seconds"),
655
+ "models": data.get("models"),
656
+ "total": data.get("total"),
657
+ }
658
+ if detail == "minimal":
659
+ return shaped
660
+
661
+ shaped["fallback_pricing_models"] = data.get("fallback_pricing_models", [])
662
+ shaped["pricing_note"] = data.get("pricing_note")
663
+ return shaped
664
+
665
+
666
+ def shape_batch(results: list[dict], detail: str) -> dict:
667
+ """Aggregate multiple full session reports into {summary, sessions}.
668
+
669
+ ``summary`` is always a pre-computed aggregate across every session so
670
+ callers never need to iterate and sum themselves. ``sessions`` is the
671
+ per-session array, each shaped by ``detail``.
672
+ """
673
+ total_input = total_output = total_cached = total_calls = 0
674
+ total_usd = 0.0
675
+ total_dur = total_active = 0
676
+ cache_ratios: list[float] = []
677
+ fallback_models: set[str] = set()
678
+ sessions: list[dict] = []
679
+
680
+ for r in results:
681
+ t = r.get("total", {})
682
+ total_input += t.get("input_tokens", 0)
683
+ total_output += t.get("output_tokens", 0)
684
+ total_cached += t.get("cached_tokens", 0)
685
+ total_calls += t.get("llm_calls", 0)
686
+ total_usd += t.get("estimated_usd", 0.0)
687
+ total_dur += r.get("duration_seconds") or 0
688
+ total_active += r.get("active_duration_seconds") or 0
689
+ cache_ratios.append(t.get("cache_ratio", 0.0))
690
+ fallback_models.update(r.get("fallback_pricing_models") or [])
691
+ sessions.append(shape_session(r, detail))
692
+
693
+ avg_cache = round(sum(cache_ratios) / len(cache_ratios), 3) if cache_ratios else 0.0
694
+
695
+ return {
696
+ "summary": {
697
+ "session_count": len(results),
698
+ "total_input_tokens": total_input,
699
+ "total_output_tokens": total_output,
700
+ "total_cached_tokens": total_cached,
701
+ "total_llm_calls": total_calls,
702
+ "total_estimated_usd": round(total_usd, 4),
703
+ "avg_cache_ratio": avg_cache,
704
+ "total_duration_seconds": total_dur,
705
+ "total_active_duration_seconds": total_active,
706
+ "fallback_pricing_models": sorted(fallback_models),
707
+ },
708
+ "sessions": sessions,
709
+ }
710
+
711
+
712
+ def parse_since_to_ms(since: str) -> int | None:
713
+ """Parse a date/datetime string to epoch milliseconds (UTC)."""
714
+ for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
715
+ try:
716
+ dt = datetime.strptime(since, fmt).replace(tzinfo=timezone.utc)
717
+ return int(dt.timestamp() * 1000)
718
+ except ValueError:
719
+ continue
720
+ return None
721
+
722
+
723
+ # ─── Rendering ─────────────────────────────────────────────────────────────────
724
+
725
+
726
+ def _col_width(header: str, values: list[str], cap: int | None = None, floor: int = 0) -> int:
727
+ """Compute a column width that fits its widest value (and header), up to `cap`."""
728
+ width = max([len(header), floor, *(len(v) for v in values)])
729
+ return min(width, cap) if cap else width
730
+
731
+
732
+ def _render_columns(
733
+ headers: tuple[str, ...],
734
+ rows: Sequence[tuple[str, ...]],
735
+ left_cols: set[int],
736
+ indent: str = " ",
737
+ gutter: str = " ",
738
+ ) -> list[str]:
739
+ """Render a small table with per-column widths computed from actual cell strings."""
740
+ widths = [
741
+ max(len(headers[i]), max((len(r[i]) for r in rows), default=0)) for i in range(len(headers))
742
+ ]
743
+
744
+ def _fmt_row(cells: tuple[str, ...]) -> str:
745
+ parts = [
746
+ cell.ljust(widths[i]) if i in left_cols else cell.rjust(widths[i])
747
+ for i, cell in enumerate(cells)
748
+ ]
749
+ return indent + gutter.join(parts)
750
+
751
+ lines = [_fmt_row(headers)]
752
+ lines.append(indent + "-" * (sum(widths) + len(gutter) * (len(widths) - 1)))
753
+ lines.extend(_fmt_row(r) for r in rows)
754
+ return lines
755
+
756
+
757
+ def render_table_single(data: dict) -> str:
758
+ """Render one session as a human-readable block, adapting to available fields."""
759
+ lines: list[str] = []
760
+ total = data.get("total") or {}
761
+ usd = total.get("estimated_usd", 0)
762
+ dur = data.get("duration_seconds")
763
+ active = data.get("active_duration_seconds")
764
+ dur_str = f"{dur}s" if dur is not None else "n/a"
765
+ if active is not None and active != dur:
766
+ dur_str += f" (active: {active}s)"
767
+
768
+ lines.append(f"Session: {data.get('session_id')}")
769
+ lines.append(f"Title: {data.get('title') or '(unknown)'}")
770
+ lines.append(f"Started: {data.get('started_at')}")
771
+ lines.append(f"Duration: {dur_str}")
772
+ if data.get("models") is not None:
773
+ lines.append(f"Models: {', '.join(data.get('models') or [])}")
774
+ lines.append(f"Input: {total.get('input_tokens', 0):,} tokens")
775
+ lines.append(f"Output: {total.get('output_tokens', 0):,} tokens")
776
+ cache_pct = f"{total.get('cache_ratio', 0):.0%}"
777
+ lines.append(f"Cached: {total.get('cached_tokens', 0):,} ({cache_pct})")
778
+ lines.append(f"LLM calls: {total.get('llm_calls', 0)}")
779
+ lines.append(f"Est. cost: ${usd:.4f}")
780
+
781
+ fallback = data.get("fallback_pricing_models")
782
+ if fallback:
783
+ lines.append(
784
+ f"Warning: fallback pricing used for {', '.join(fallback)} (may be inaccurate)"
785
+ )
786
+
787
+ model_breakdown = data.get("model_breakdown")
788
+ if model_breakdown:
789
+ headers = ("Model", "Input", "Cached", "Output", "Calls", "Cost")
790
+ rows = [
791
+ (
792
+ m["model"],
793
+ f"{m['input_tokens']:,}",
794
+ f"{m['cached_tokens']:,}",
795
+ f"{m['output_tokens']:,}",
796
+ f"{m['llm_calls']:,}",
797
+ f"${m['estimated_usd']:.2f}",
798
+ )
799
+ for m in model_breakdown
800
+ ]
801
+ lines.append("")
802
+ lines.append("Per-Model Breakdown:")
803
+ lines.extend(_render_columns(headers, rows, left_cols={0}))
804
+
805
+ subs = data.get("subagents")
806
+ if subs:
807
+ headers = ("Name", "Model", "Input", "Cached", "Output", "Cost")
808
+ rows = [
809
+ (
810
+ sub["name"],
811
+ sub["model"],
812
+ f"{sub['input_tokens']:,}",
813
+ f"{sub['cached_tokens']:,}",
814
+ f"{sub['output_tokens']:,}",
815
+ f"${sub['estimated_usd']:.2f}",
816
+ )
817
+ for sub in subs
818
+ ]
819
+ lines.append("")
820
+ lines.append("Subagents:")
821
+ lines.extend(_render_columns(headers, rows, left_cols={0, 1}))
822
+
823
+ return "\n".join(lines)
824
+
825
+
826
+ def render_table_list(items: list[dict], summary: dict | None = None) -> str:
827
+ """Render a list of sessions as a table.
828
+
829
+ Adapts to three shapes:
830
+
831
+ - **Full detail**: each session rendered via ``render_table_single``.
832
+ - **Analyzed, compact/minimal**: one row per session with a TOTAL footer.
833
+ - **Metadata only** (from ``list``): one row per session.
834
+ """
835
+ if not items:
836
+ return "(no sessions found)"
837
+
838
+ if "model_breakdown" in items[0] or "subagents" in items[0]:
839
+ return _render_full_detail_list(items, summary)
840
+
841
+ if "total" in items[0]:
842
+ return _render_analyzed_rows(items, summary)
843
+
844
+ return _render_metadata_rows(items)
845
+
846
+
847
+ def _render_full_detail_list(items: list[dict], summary: dict | None) -> str:
848
+ """Render each session in full, plus a footer."""
849
+ blocks = [render_table_single(item) for item in items]
850
+ divider = "\n\n" + "=" * 100 + "\n\n"
851
+ text = divider.join(blocks)
852
+ if summary is not None:
853
+ text += divider + _render_summary_footer(summary)
854
+ return text
855
+
856
+
857
+ def _render_summary_footer(summary: dict) -> str:
858
+ """Render the batch ``summary`` aggregate as a short human-readable block."""
859
+ lines = [f"Summary across {summary.get('session_count', 0)} sessions:"]
860
+ lines.append(f" Total input: {summary.get('total_input_tokens', 0):,} tokens")
861
+ lines.append(f" Total output: {summary.get('total_output_tokens', 0):,} tokens")
862
+ lines.append(
863
+ f" Total cached: {summary.get('total_cached_tokens', 0):,} tokens "
864
+ f"(avg ratio {summary.get('avg_cache_ratio', 0):.0%})"
865
+ )
866
+ lines.append(f" Total calls: {summary.get('total_llm_calls', 0)}")
867
+ lines.append(f" Total cost: ${summary.get('total_estimated_usd', 0):.4f}")
868
+ fallback = summary.get("fallback_pricing_models")
869
+ if fallback:
870
+ lines.append(f" Warning: fallback pricing used for {', '.join(fallback)}")
871
+ return "\n".join(lines)
872
+
873
+
874
+ def _render_analyzed_rows(items: list[dict], summary: dict | None = None) -> str:
875
+ """One row per analyzed session."""
876
+ rows = []
877
+ for i, s in enumerate(items, 1):
878
+ started = (s.get("started_at") or s.get("created_at") or "")[:16]
879
+ title = s.get("title") or "(no title)"
880
+ sid = s.get("session_id") or ""
881
+ models = s.get("models") or []
882
+ model = models[0] if models else "unknown"
883
+ t = s.get("total", {})
884
+ rows.append(
885
+ (i, started, title, sid, model, t.get("input_tokens", 0), t.get("estimated_usd", 0))
886
+ )
887
+
888
+ w_title = _col_width("Title", [r[2] for r in rows], cap=60)
889
+ w_id = _col_width("ID", [r[3] for r in rows])
890
+ w_model = _col_width("Model", [r[4] for r in rows], cap=30)
891
+
892
+ lines = [
893
+ f"{'#':<3} {'Date':<16} {'Title':<{w_title}} {'ID':<{w_id}} "
894
+ f"{'Model':<{w_model}} {'Input':>10} {'Cost':>8}"
895
+ ]
896
+ total_width = 3 + 1 + 16 + 1 + w_title + 1 + w_id + 1 + w_model + 1 + 10 + 1 + 8
897
+ lines.append("-" * total_width)
898
+
899
+ total_input = 0
900
+ total_usd = 0.0
901
+ for i, started, title, sid, model, inp, usd in rows:
902
+ total_input += inp
903
+ total_usd += usd
904
+ lines.append(
905
+ f"{i:<3} {started:<16} {title[:w_title]:<{w_title}} {sid:<{w_id}} "
906
+ f"{model[:w_model]:<{w_model}} {inp:>10,} ${usd:>7.2f}"
907
+ )
908
+
909
+ if summary is not None:
910
+ total_input = summary.get("total_input_tokens", total_input)
911
+ total_usd = summary.get("total_estimated_usd", total_usd)
912
+ lines.append("-" * total_width)
913
+ lines.append(
914
+ f"{'TOTAL':<3} {'':<16} {'':<{w_title}} {'':<{w_id}} {'':<{w_model}} "
915
+ f"{total_input:>10,} ${total_usd:>7.2f}"
916
+ )
917
+ return "\n".join(lines)
918
+
919
+
920
+ def _render_metadata_rows(items: list[dict]) -> str:
921
+ """One row per session with metadata only (from `list`)."""
922
+ w_title = _col_width("Title", [s.get("title") or "(no title)" for s in items], cap=60)
923
+ w_id = _col_width("ID", [s.get("session_id") or "" for s in items])
924
+
925
+ lines = [f"{'Created':<19} {'Logs':<4} {'Title':<{w_title}} {'ID':<{w_id}}"]
926
+ lines.append("-" * (19 + 1 + 4 + 1 + w_title + 2 + w_id))
927
+ for s in items:
928
+ created = (s.get("created_at") or "")[:19]
929
+ has_logs = "y" if s.get("has_debug_logs") else "n"
930
+ title = (s.get("title") or "(no title)")[:w_title]
931
+ lines.append(
932
+ f"{created:<19} {has_logs:<4} {title:<{w_title}} {s.get('session_id', ''):<{w_id}}"
933
+ )
934
+ return "\n".join(lines)
935
+
936
+
937
+ def render(payload: object, fmt: str) -> str:
938
+ """Render a payload (single session, list, or batch) as text."""
939
+ if fmt == "json":
940
+ return json.dumps(payload, indent=2, ensure_ascii=False)
941
+ if isinstance(payload, list):
942
+ return render_table_list(payload)
943
+ if isinstance(payload, dict) and "summary" in payload and "sessions" in payload:
944
+ return render_table_list(payload["sessions"], summary=payload["summary"])
945
+ return render_table_single(payload) # type: ignore[arg-type]
946
+
947
+
948
+ def emit(payload: object, fmt: str, output_path: Path | None = None) -> None:
949
+ """Render and print (or save to file) a payload."""
950
+ text = render(payload, fmt)
951
+ if output_path is not None:
952
+ output_path.write_text(text, encoding="utf-8")
953
+ click.echo(f"Saved {len(text):,} bytes to {output_path}")
954
+ else:
955
+ click.echo(text)
956
+
957
+
958
+ # ─── Shared CLI options ────────────────────────────────────────────────────────
959
+
960
+ FORMAT_CHOICE = click.Choice(("json", "table", "detailed"))
961
+ DETAIL_CHOICE = click.Choice(DETAIL_LEVELS)
962
+
963
+
964
+ def normalize_format(format_: str) -> str:
965
+ """Map the CLI-facing --format value to the internal renderer format."""
966
+ return "table" if format_ in ("table", "detailed") else "json"
967
+
968
+
969
+ def resolve_detail(detail: str, format_: str) -> str:
970
+ """Force detail to 'full' when --format detailed is requested."""
971
+ return "full" if format_ == "detailed" else detail
972
+
973
+
974
+ def detail_option(f: Any) -> Any:
975
+ return click.option(
976
+ "--detail",
977
+ type=DETAIL_CHOICE,
978
+ default="compact",
979
+ show_default=True,
980
+ help=(
981
+ "minimal (identity+total only), compact (+models list, default), "
982
+ "or full (+per-model and per-subagent breakdown). Ignored (forced "
983
+ "to full) when --format detailed is used."
984
+ ),
985
+ )(f)
986
+
987
+
988
+ def format_option(f: Any) -> Any:
989
+ return click.option(
990
+ "--format",
991
+ "format_",
992
+ type=FORMAT_CHOICE,
993
+ default="json",
994
+ show_default=True,
995
+ help=(
996
+ "json (machine-readable), table (human-readable), or detailed "
997
+ "(table forced to full detail — same as --format table --detail full)."
998
+ ),
999
+ )(f)
1000
+
1001
+
1002
+ def output_option(f: Any) -> Any:
1003
+ return click.option(
1004
+ "--output",
1005
+ "output_path",
1006
+ metavar="PATH",
1007
+ help="Write output to PATH instead of stdout.",
1008
+ )(f)
1009
+
1010
+
1011
+ def analysis_options(f: Any) -> Any:
1012
+ """Combine --detail, --format, --output for single/batch analysis commands."""
1013
+ f = output_option(f)
1014
+ f = format_option(f)
1015
+ f = detail_option(f)
1016
+ return f