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.
- copilot_session_usage/__init__.py +10 -0
- copilot_session_usage/_internal/__init__.py +1 -0
- copilot_session_usage/_internal/copilot_cli.py +18 -0
- copilot_session_usage/_internal/core.py +1016 -0
- copilot_session_usage/_internal/vscode.py +242 -0
- copilot_session_usage/api.py +211 -0
- copilot_session_usage/cli.py +282 -0
- copilot_session_usage/data/__init__.py +0 -0
- copilot_session_usage/data/custom-models-pricing.yml +22 -0
- copilot_session_usage/data/models-and-pricing.lock +7 -0
- copilot_session_usage/data/models-and-pricing.yml +269 -0
- copilot_session_usage-0.1.0.dist-info/METADATA +187 -0
- copilot_session_usage-0.1.0.dist-info/RECORD +16 -0
- copilot_session_usage-0.1.0.dist-info/WHEEL +4 -0
- copilot_session_usage-0.1.0.dist-info/entry_points.txt +2 -0
- copilot_session_usage-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|