copilot-cli-trace-deck 0.1.0__tar.gz → 0.3.0__tar.gz
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_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/PKG-INFO +6 -4
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/README.md +5 -3
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/pyproject.toml +1 -1
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/data/sessions.py +443 -26
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/models.py +24 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/web/pages.py +258 -16
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck.egg-info/PKG-INFO +6 -4
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/LICENSE +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/setup.cfg +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/__init__.py +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/__main__.py +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/data/__init__.py +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/server.py +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/web/__init__.py +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/web/server.py +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck.egg-info/SOURCES.txt +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck.egg-info/dependency_links.txt +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck.egg-info/entry_points.txt +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck.egg-info/requires.txt +0 -0
- {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: copilot-cli-trace-deck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -34,23 +34,25 @@ Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
|
|
|
34
34
|
Run the app directly from the workspace with `uv`:
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
|
|
37
|
+
uvx copilot-cli-trace-deck@latest
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
By default the server listens on `http://127.0.0.1:9887` and opens that URL in your browser.
|
|
41
41
|
|
|
42
42
|
Live updates are pushed over Server-Sent Events and triggered by file-system changes in the session-state directory, so the home, summary, logs, and flow pages update without browser polling.
|
|
43
43
|
|
|
44
|
+
The summary view also estimates per-session GitHub AI Credits / USD cost from shutdown metrics, aggregating token usage across model switches within the same session.
|
|
45
|
+
|
|
44
46
|
You can also pass the session-state source and server options:
|
|
45
47
|
|
|
46
48
|
```bash
|
|
47
|
-
|
|
49
|
+
uvx copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
|
|
48
50
|
```
|
|
49
51
|
|
|
50
52
|
To skip opening a browser while still printing the local URL:
|
|
51
53
|
|
|
52
54
|
```bash
|
|
53
|
-
|
|
55
|
+
uvx copilot-cli-trace-deck --quiet
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
## Install As A Command
|
|
@@ -24,23 +24,25 @@ Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
|
|
|
24
24
|
Run the app directly from the workspace with `uv`:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
|
|
27
|
+
uvx copilot-cli-trace-deck@latest
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
By default the server listens on `http://127.0.0.1:9887` and opens that URL in your browser.
|
|
31
31
|
|
|
32
32
|
Live updates are pushed over Server-Sent Events and triggered by file-system changes in the session-state directory, so the home, summary, logs, and flow pages update without browser polling.
|
|
33
33
|
|
|
34
|
+
The summary view also estimates per-session GitHub AI Credits / USD cost from shutdown metrics, aggregating token usage across model switches within the same session.
|
|
35
|
+
|
|
34
36
|
You can also pass the session-state source and server options:
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
|
-
|
|
39
|
+
uvx copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
To skip opening a browser while still printing the local URL:
|
|
41
43
|
|
|
42
44
|
```bash
|
|
43
|
-
|
|
45
|
+
uvx copilot-cli-trace-deck --quiet
|
|
44
46
|
```
|
|
45
47
|
|
|
46
48
|
## Install As A Command
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "copilot-cli-trace-deck"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -1,10 +1,49 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from datetime import datetime
|
|
6
|
+
from decimal import Decimal
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
|
-
from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionPreview, SessionSummary
|
|
9
|
+
from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionModelUsage, SessionPreview, SessionSummary
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ModelPricing:
|
|
14
|
+
input_usd_per_million: Decimal
|
|
15
|
+
cached_input_usd_per_million: Decimal
|
|
16
|
+
output_usd_per_million: Decimal
|
|
17
|
+
cache_write_usd_per_million: Decimal = Decimal("0")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AI_CREDIT_USD = Decimal("0.01")
|
|
21
|
+
NANO_AIU_PER_AI_CREDIT = Decimal("1000000000")
|
|
22
|
+
TOKENS_PER_MILLION = Decimal("1000000")
|
|
23
|
+
MODEL_PRICING: dict[str, ModelPricing] = {
|
|
24
|
+
"gpt-4.1": ModelPricing(Decimal("2.00"), Decimal("0.50"), Decimal("8.00")),
|
|
25
|
+
"gpt-5-mini": ModelPricing(Decimal("0.25"), Decimal("0.025"), Decimal("2.00")),
|
|
26
|
+
"gpt-5.2": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
|
|
27
|
+
"gpt-5.2-codex": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
|
|
28
|
+
"gpt-5.3-codex": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
|
|
29
|
+
"gpt-5.4": ModelPricing(Decimal("2.50"), Decimal("0.25"), Decimal("15.00")),
|
|
30
|
+
"gpt-5.4-mini": ModelPricing(Decimal("0.75"), Decimal("0.075"), Decimal("4.50")),
|
|
31
|
+
"gpt-5.4-nano": ModelPricing(Decimal("0.20"), Decimal("0.02"), Decimal("1.25")),
|
|
32
|
+
"gpt-5.5": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("30.00")),
|
|
33
|
+
"claude-haiku-4.5": ModelPricing(Decimal("1.00"), Decimal("0.10"), Decimal("5.00"), Decimal("1.25")),
|
|
34
|
+
"claude-sonnet-4": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
|
|
35
|
+
"claude-sonnet-4.5": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
|
|
36
|
+
"claude-sonnet-4.6": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
|
|
37
|
+
"claude-opus-4.5": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
|
|
38
|
+
"claude-opus-4.6": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
|
|
39
|
+
"claude-opus-4.7": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
|
|
40
|
+
"gemini-2.5-pro": ModelPricing(Decimal("1.25"), Decimal("0.125"), Decimal("10.00")),
|
|
41
|
+
"gemini-3-flash": ModelPricing(Decimal("0.50"), Decimal("0.05"), Decimal("3.00")),
|
|
42
|
+
"gemini-3.1-pro": ModelPricing(Decimal("2.00"), Decimal("0.20"), Decimal("12.00")),
|
|
43
|
+
"gemini-3.5-flash": ModelPricing(Decimal("1.50"), Decimal("0.15"), Decimal("9.00")),
|
|
44
|
+
"raptor-mini": ModelPricing(Decimal("0.25"), Decimal("0.025"), Decimal("2.00")),
|
|
45
|
+
"goldeneye": ModelPricing(Decimal("1.25"), Decimal("0.125"), Decimal("10.00")),
|
|
46
|
+
}
|
|
8
47
|
|
|
9
48
|
|
|
10
49
|
def load_session_previews(session_root: Path) -> list[SessionPreview]:
|
|
@@ -21,20 +60,28 @@ def load_session_previews(session_root: Path) -> list[SessionPreview]:
|
|
|
21
60
|
if not title:
|
|
22
61
|
continue
|
|
23
62
|
|
|
24
|
-
|
|
63
|
+
events = read_jsonl_events(session_dir / "events.jsonl")
|
|
64
|
+
shutdown_event = find_current_shutdown_event(events)
|
|
65
|
+
model_name = find_current_model(events, shutdown_event)
|
|
66
|
+
updated_at = last_event_timestamp(events) or metadata.get("updated_at") or metadata.get("created_at") or first_event_timestamp(events)
|
|
25
67
|
session_rows.append(
|
|
26
68
|
(
|
|
27
69
|
updated_at,
|
|
28
|
-
SessionPreview(
|
|
70
|
+
SessionPreview(
|
|
71
|
+
session_id=session_dir.name,
|
|
72
|
+
title=title,
|
|
73
|
+
status="Idle" if shutdown_event else "Active",
|
|
74
|
+
model_name=model_name,
|
|
75
|
+
repository=repository_name(metadata.get("repository", "")),
|
|
76
|
+
branch=metadata.get("branch", ""),
|
|
77
|
+
updated_label=format_timestamp(updated_at) if updated_at else "",
|
|
78
|
+
is_active=shutdown_event is None,
|
|
79
|
+
),
|
|
29
80
|
)
|
|
30
81
|
)
|
|
31
82
|
|
|
32
83
|
session_rows.sort(key=lambda item: item[0], reverse=True)
|
|
33
|
-
|
|
34
|
-
if previews:
|
|
35
|
-
active = previews[0]
|
|
36
|
-
previews[0] = SessionPreview(session_id=active.session_id, title=active.title, is_active=True)
|
|
37
|
-
return previews
|
|
84
|
+
return [session for _, session in session_rows]
|
|
38
85
|
|
|
39
86
|
|
|
40
87
|
def load_session_summary(session_root: Path, session_id: str) -> SessionSummary | None:
|
|
@@ -48,11 +95,18 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
|
|
|
48
95
|
return None
|
|
49
96
|
|
|
50
97
|
events = read_jsonl_events(session_dir / "events.jsonl")
|
|
51
|
-
shutdown_event =
|
|
98
|
+
shutdown_event = find_current_shutdown_event(events)
|
|
52
99
|
model_name = find_current_model(events, shutdown_event)
|
|
53
|
-
|
|
100
|
+
model_usages, billing_stage = extract_model_usages(events, shutdown_event)
|
|
101
|
+
usage = aggregate_usage(model_usages)
|
|
102
|
+
shutdown_total_nano_aiu = extract_shutdown_total_nano_aiu(events)
|
|
103
|
+
estimated_cost_usd, estimated_ai_credits, billing_note = build_billing_estimate(
|
|
104
|
+
model_usages,
|
|
105
|
+
billing_stage,
|
|
106
|
+
shutdown_total_nano_aiu=shutdown_total_nano_aiu,
|
|
107
|
+
)
|
|
54
108
|
created_value = metadata.get("created_at") or first_event_timestamp(events)
|
|
55
|
-
updated_value = metadata.get("updated_at") or
|
|
109
|
+
updated_value = last_event_timestamp(events) or metadata.get("updated_at") or created_value
|
|
56
110
|
|
|
57
111
|
return SessionSummary(
|
|
58
112
|
session_id=session_id,
|
|
@@ -63,6 +117,7 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
|
|
|
63
117
|
location="CLI",
|
|
64
118
|
status="Idle" if shutdown_event else "Active",
|
|
65
119
|
model_name=model_name or "Unknown",
|
|
120
|
+
models_used_label=build_models_used_label(model_usages, model_name),
|
|
66
121
|
repository=repository_name(metadata.get("repository", "")),
|
|
67
122
|
branch=metadata.get("branch", ""),
|
|
68
123
|
model_turns=count_events(events, "assistant.turn_start"),
|
|
@@ -70,7 +125,12 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
|
|
|
70
125
|
total_input_tokens=usage.get("inputTokens", 0),
|
|
71
126
|
total_output_tokens=usage.get("outputTokens", 0),
|
|
72
127
|
total_cached_input_tokens=usage.get("cacheReadTokens", 0),
|
|
128
|
+
total_cache_write_tokens=usage.get("cacheWriteTokens", 0),
|
|
73
129
|
total_tokens=usage.get("inputTokens", 0) + usage.get("outputTokens", 0),
|
|
130
|
+
estimated_cost_usd=estimated_cost_usd,
|
|
131
|
+
estimated_ai_credits=estimated_ai_credits,
|
|
132
|
+
billing_note=billing_note,
|
|
133
|
+
model_usages=model_usages,
|
|
74
134
|
error_count=count_errors(events),
|
|
75
135
|
)
|
|
76
136
|
|
|
@@ -90,7 +150,7 @@ def load_session_flow(session_root: Path, session_id: str) -> list[SessionFlowNo
|
|
|
90
150
|
return None
|
|
91
151
|
|
|
92
152
|
events = read_jsonl_events(session_dir / "events.jsonl")
|
|
93
|
-
shutdown_event =
|
|
153
|
+
shutdown_event = find_current_shutdown_event(events)
|
|
94
154
|
model_name = find_current_model(events, shutdown_event) or "Unknown"
|
|
95
155
|
return build_flow_nodes(events, model_name)
|
|
96
156
|
|
|
@@ -143,30 +203,366 @@ def find_current_model(events: list[dict], shutdown_event: dict | None) -> str:
|
|
|
143
203
|
return ""
|
|
144
204
|
|
|
145
205
|
|
|
146
|
-
def
|
|
206
|
+
def find_current_shutdown_event(events: list[dict]) -> dict | None:
|
|
207
|
+
latest_shutdown: dict | None = None
|
|
208
|
+
latest_shutdown_index = -1
|
|
209
|
+
latest_lifecycle_resume_index = -1
|
|
210
|
+
|
|
211
|
+
for index, event in enumerate(events):
|
|
212
|
+
event_type = event.get("type")
|
|
213
|
+
if event_type == "session.shutdown":
|
|
214
|
+
latest_shutdown = event
|
|
215
|
+
latest_shutdown_index = index
|
|
216
|
+
continue
|
|
217
|
+
if event_type in {"session.start", "session.resume"}:
|
|
218
|
+
latest_lifecycle_resume_index = index
|
|
219
|
+
|
|
220
|
+
if latest_shutdown_index == -1:
|
|
221
|
+
return None
|
|
222
|
+
if latest_lifecycle_resume_index > latest_shutdown_index:
|
|
223
|
+
return None
|
|
224
|
+
return latest_shutdown
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def extract_model_usages(events: list[dict], shutdown_event: dict | None) -> tuple[list[SessionModelUsage], str]:
|
|
228
|
+
shutdown_model_usages = extract_all_shutdown_model_usages(events)
|
|
229
|
+
if shutdown_event:
|
|
230
|
+
return shutdown_model_usages, "shutdown"
|
|
231
|
+
|
|
232
|
+
live_model_usages = extract_live_model_usages_since_last_shutdown(events)
|
|
233
|
+
merged_model_usages = merge_model_usages(shutdown_model_usages, live_model_usages)
|
|
234
|
+
if merged_model_usages:
|
|
235
|
+
return merged_model_usages, "live"
|
|
236
|
+
return [], "pending"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def extract_all_shutdown_model_usages(events: list[dict]) -> list[SessionModelUsage]:
|
|
240
|
+
merged_model_usages: list[SessionModelUsage] = []
|
|
241
|
+
for event in events:
|
|
242
|
+
if event.get("type") != "session.shutdown":
|
|
243
|
+
continue
|
|
244
|
+
merged_model_usages = merge_model_usages(merged_model_usages, extract_shutdown_model_usages(event))
|
|
245
|
+
return merged_model_usages
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def extract_shutdown_total_nano_aiu(events: list[dict]) -> int | None:
|
|
249
|
+
total_nano_aiu = 0
|
|
250
|
+
found_billing = False
|
|
251
|
+
for event in events:
|
|
252
|
+
if event.get("type") != "session.shutdown":
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
raw_total_nano_aiu = event.get("data", {}).get("totalNanoAiu")
|
|
256
|
+
try:
|
|
257
|
+
total_nano_aiu += int(raw_total_nano_aiu)
|
|
258
|
+
except (TypeError, ValueError):
|
|
259
|
+
continue
|
|
260
|
+
found_billing = True
|
|
261
|
+
|
|
262
|
+
if not found_billing:
|
|
263
|
+
return None
|
|
264
|
+
return total_nano_aiu
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_shutdown_model_usages(shutdown_event: dict | None) -> list[SessionModelUsage]:
|
|
147
268
|
if not shutdown_event:
|
|
148
|
-
return
|
|
269
|
+
return []
|
|
149
270
|
|
|
150
271
|
model_metrics = shutdown_event.get("data", {}).get("modelMetrics", {})
|
|
151
|
-
if not isinstance(model_metrics, dict)
|
|
152
|
-
return
|
|
272
|
+
if not isinstance(model_metrics, dict):
|
|
273
|
+
return []
|
|
153
274
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
usage =
|
|
275
|
+
model_usages: list[SessionModelUsage] = []
|
|
276
|
+
for model_name, metrics in model_metrics.items():
|
|
277
|
+
if not isinstance(model_name, str) or not isinstance(metrics, dict):
|
|
278
|
+
continue
|
|
279
|
+
usage = metrics.get("usage", {})
|
|
280
|
+
if not isinstance(usage, dict):
|
|
281
|
+
continue
|
|
159
282
|
|
|
160
|
-
|
|
161
|
-
|
|
283
|
+
estimated_ai_credits = parse_nano_aiu_as_ai_credits(metrics.get("totalNanoAiu"))
|
|
284
|
+
|
|
285
|
+
model_usage = build_model_usage(model_name, usage, estimated_ai_credits=estimated_ai_credits)
|
|
286
|
+
if model_usage is None:
|
|
287
|
+
continue
|
|
288
|
+
model_usages.append(model_usage)
|
|
289
|
+
|
|
290
|
+
model_usages.sort(key=model_usage_sort_key, reverse=True)
|
|
291
|
+
return model_usages
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def extract_live_model_usages(events: list[dict]) -> list[SessionModelUsage]:
|
|
295
|
+
usage_by_model: dict[str, dict[str, int]] = {}
|
|
296
|
+
current_model = ""
|
|
297
|
+
for event in events:
|
|
298
|
+
event_type = str(event.get("type") or "")
|
|
299
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
300
|
+
if event_type == "session.model_change":
|
|
301
|
+
model_name = data.get("newModel")
|
|
302
|
+
if isinstance(model_name, str) and model_name:
|
|
303
|
+
current_model = model_name
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
if event_type != "assistant.message":
|
|
307
|
+
continue
|
|
162
308
|
|
|
309
|
+
model_name = data.get("model")
|
|
310
|
+
if not isinstance(model_name, str) or not model_name:
|
|
311
|
+
model_name = current_model
|
|
312
|
+
if not model_name:
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
usage = usage_by_model.setdefault(
|
|
316
|
+
model_name,
|
|
317
|
+
{
|
|
318
|
+
"inputTokens": 0,
|
|
319
|
+
"outputTokens": 0,
|
|
320
|
+
"cacheReadTokens": 0,
|
|
321
|
+
"cacheWriteTokens": 0,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
usage["inputTokens"] += parse_token_count(data.get("inputTokens"))
|
|
325
|
+
usage["outputTokens"] += parse_token_count(data.get("outputTokens"))
|
|
326
|
+
usage["cacheReadTokens"] += parse_token_count(data.get("cacheReadTokens"))
|
|
327
|
+
usage["cacheWriteTokens"] += parse_token_count(data.get("cacheWriteTokens"))
|
|
328
|
+
|
|
329
|
+
model_usages = [
|
|
330
|
+
model_usage
|
|
331
|
+
for model_name, usage in usage_by_model.items()
|
|
332
|
+
if (model_usage := build_model_usage(model_name, usage)) is not None
|
|
333
|
+
]
|
|
334
|
+
model_usages.sort(key=model_usage_sort_key, reverse=True)
|
|
335
|
+
return model_usages
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def extract_live_model_usages_since_last_shutdown(events: list[dict]) -> list[SessionModelUsage]:
|
|
339
|
+
last_shutdown_index = -1
|
|
340
|
+
for index, event in enumerate(events):
|
|
341
|
+
if event.get("type") == "session.shutdown":
|
|
342
|
+
last_shutdown_index = index
|
|
343
|
+
return extract_live_model_usages(events[last_shutdown_index + 1 :])
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def build_model_usage(
|
|
347
|
+
model_name: str,
|
|
348
|
+
usage: dict[str, object],
|
|
349
|
+
*,
|
|
350
|
+
estimated_ai_credits: Decimal | None = None,
|
|
351
|
+
) -> SessionModelUsage | None:
|
|
352
|
+
if not model_name:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
input_tokens = parse_token_count(usage.get("inputTokens"))
|
|
356
|
+
output_tokens = parse_token_count(usage.get("outputTokens"))
|
|
357
|
+
cached_input_tokens = parse_token_count(usage.get("cacheReadTokens"))
|
|
358
|
+
cache_write_tokens = parse_token_count(usage.get("cacheWriteTokens"))
|
|
359
|
+
total_tokens = input_tokens + output_tokens + cached_input_tokens + cache_write_tokens
|
|
360
|
+
estimated_cost_usd = estimate_model_cost(
|
|
361
|
+
model_name,
|
|
362
|
+
input_tokens=input_tokens,
|
|
363
|
+
cached_input_tokens=cached_input_tokens,
|
|
364
|
+
cache_write_tokens=cache_write_tokens,
|
|
365
|
+
output_tokens=output_tokens,
|
|
366
|
+
)
|
|
367
|
+
if estimated_ai_credits is None and estimated_cost_usd is not None:
|
|
368
|
+
estimated_ai_credits = estimated_cost_usd / AI_CREDIT_USD
|
|
369
|
+
|
|
370
|
+
return SessionModelUsage(
|
|
371
|
+
model_name=model_name,
|
|
372
|
+
input_tokens=input_tokens,
|
|
373
|
+
cached_input_tokens=cached_input_tokens,
|
|
374
|
+
cache_write_tokens=cache_write_tokens,
|
|
375
|
+
output_tokens=output_tokens,
|
|
376
|
+
total_tokens=total_tokens,
|
|
377
|
+
estimated_cost_usd=estimated_cost_usd,
|
|
378
|
+
estimated_ai_credits=estimated_ai_credits,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def merge_model_usages(*usage_lists: list[SessionModelUsage]) -> list[SessionModelUsage]:
|
|
383
|
+
merged_usage: dict[str, dict[str, object]] = {}
|
|
384
|
+
for usage_list in usage_lists:
|
|
385
|
+
for item in usage_list:
|
|
386
|
+
usage = merged_usage.setdefault(
|
|
387
|
+
item.model_name,
|
|
388
|
+
{
|
|
389
|
+
"inputTokens": 0,
|
|
390
|
+
"outputTokens": 0,
|
|
391
|
+
"cacheReadTokens": 0,
|
|
392
|
+
"cacheWriteTokens": 0,
|
|
393
|
+
"estimatedAiCredits": None,
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
usage["inputTokens"] += item.input_tokens
|
|
397
|
+
usage["outputTokens"] += item.output_tokens
|
|
398
|
+
usage["cacheReadTokens"] += item.cached_input_tokens
|
|
399
|
+
usage["cacheWriteTokens"] += item.cache_write_tokens
|
|
400
|
+
if item.estimated_ai_credits is not None:
|
|
401
|
+
current_estimated_ai_credits = usage.get("estimatedAiCredits")
|
|
402
|
+
if not isinstance(current_estimated_ai_credits, Decimal):
|
|
403
|
+
current_estimated_ai_credits = Decimal("0")
|
|
404
|
+
usage["estimatedAiCredits"] = current_estimated_ai_credits + item.estimated_ai_credits
|
|
405
|
+
|
|
406
|
+
model_usages = [
|
|
407
|
+
model_usage
|
|
408
|
+
for model_name, usage in merged_usage.items()
|
|
409
|
+
if (
|
|
410
|
+
model_usage := build_model_usage(
|
|
411
|
+
model_name,
|
|
412
|
+
usage,
|
|
413
|
+
estimated_ai_credits=usage.get("estimatedAiCredits") if isinstance(usage.get("estimatedAiCredits"), Decimal) else None,
|
|
414
|
+
)
|
|
415
|
+
) is not None
|
|
416
|
+
]
|
|
417
|
+
model_usages.sort(key=model_usage_sort_key, reverse=True)
|
|
418
|
+
return model_usages
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def aggregate_usage(model_usages: list[SessionModelUsage]) -> dict[str, int]:
|
|
163
422
|
return {
|
|
164
|
-
"inputTokens":
|
|
165
|
-
"outputTokens":
|
|
166
|
-
"cacheReadTokens":
|
|
423
|
+
"inputTokens": sum(item.input_tokens for item in model_usages),
|
|
424
|
+
"outputTokens": sum(item.output_tokens for item in model_usages),
|
|
425
|
+
"cacheReadTokens": sum(item.cached_input_tokens for item in model_usages),
|
|
426
|
+
"cacheWriteTokens": sum(item.cache_write_tokens for item in model_usages),
|
|
167
427
|
}
|
|
168
428
|
|
|
169
429
|
|
|
430
|
+
def parse_nano_aiu_as_ai_credits(raw_value: object) -> Decimal | None:
|
|
431
|
+
try:
|
|
432
|
+
return Decimal(int(raw_value)) / NANO_AIU_PER_AI_CREDIT
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def parse_token_count(raw_value: object) -> int:
|
|
438
|
+
try:
|
|
439
|
+
return int(raw_value or 0)
|
|
440
|
+
except (TypeError, ValueError):
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def estimate_model_cost(
|
|
445
|
+
model_name: str,
|
|
446
|
+
*,
|
|
447
|
+
input_tokens: int,
|
|
448
|
+
cached_input_tokens: int,
|
|
449
|
+
cache_write_tokens: int,
|
|
450
|
+
output_tokens: int,
|
|
451
|
+
) -> Decimal | None:
|
|
452
|
+
pricing = lookup_model_pricing(model_name)
|
|
453
|
+
if not pricing:
|
|
454
|
+
return Decimal("0") if not any((input_tokens, cached_input_tokens, cache_write_tokens, output_tokens)) else None
|
|
455
|
+
|
|
456
|
+
return (
|
|
457
|
+
Decimal(input_tokens) * pricing.input_usd_per_million
|
|
458
|
+
+ Decimal(cached_input_tokens) * pricing.cached_input_usd_per_million
|
|
459
|
+
+ Decimal(cache_write_tokens) * pricing.cache_write_usd_per_million
|
|
460
|
+
+ Decimal(output_tokens) * pricing.output_usd_per_million
|
|
461
|
+
) / TOKENS_PER_MILLION
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def build_billing_estimate(
|
|
465
|
+
model_usages: list[SessionModelUsage],
|
|
466
|
+
billing_stage: str,
|
|
467
|
+
*,
|
|
468
|
+
shutdown_total_nano_aiu: int | None = None,
|
|
469
|
+
) -> tuple[Decimal | None, Decimal | None, str]:
|
|
470
|
+
if not model_usages:
|
|
471
|
+
if billing_stage == "live":
|
|
472
|
+
return None, None, "Live billing estimate will appear after the first assistant response. Copilot only publishes live output tokens; input and cache token counters refresh on session shutdown."
|
|
473
|
+
return None, None, "Billing estimate appears after session shutdown publishes model metrics."
|
|
474
|
+
|
|
475
|
+
if billing_stage == "shutdown" and shutdown_total_nano_aiu is not None:
|
|
476
|
+
estimated_ai_credits = Decimal(shutdown_total_nano_aiu) / NANO_AIU_PER_AI_CREDIT
|
|
477
|
+
estimated_cost_usd = estimated_ai_credits * AI_CREDIT_USD
|
|
478
|
+
return estimated_cost_usd, estimated_ai_credits, "Estimate uses Copilot session shutdown billing fields."
|
|
479
|
+
|
|
480
|
+
missing_pricing = [item.model_name for item in model_usages if item.total_tokens and item.estimated_cost_usd is None]
|
|
481
|
+
known_costs = [item.estimated_cost_usd for item in model_usages if item.estimated_cost_usd is not None]
|
|
482
|
+
if not known_costs and missing_pricing:
|
|
483
|
+
missing_models = ", ".join(missing_pricing)
|
|
484
|
+
return None, None, f"Billing estimate unavailable because pricing is missing for: {missing_models}."
|
|
485
|
+
|
|
486
|
+
estimated_cost_usd = sum(known_costs, Decimal("0"))
|
|
487
|
+
estimated_ai_credits = estimated_cost_usd / AI_CREDIT_USD
|
|
488
|
+
if missing_pricing:
|
|
489
|
+
missing_models = ", ".join(missing_pricing)
|
|
490
|
+
if billing_stage == "live":
|
|
491
|
+
note = f"Live partial estimate. Completed shutdown metrics are included when available; the active segment contributes output tokens only because Copilot does not publish live input or cache token counts. Unpriced models excluded: {missing_models}. Final shutdown metrics may increase totals."
|
|
492
|
+
else:
|
|
493
|
+
note = f"Partial estimate. Unpriced models excluded: {missing_models}."
|
|
494
|
+
elif billing_stage == "live":
|
|
495
|
+
note = "Live estimate includes completed shutdown metrics plus live output tokens from the active event log tail. Input and cache token totals refresh on the next session shutdown."
|
|
496
|
+
else:
|
|
497
|
+
note = "Estimate aggregates all models recorded in session shutdown metrics."
|
|
498
|
+
return estimated_cost_usd, estimated_ai_credits, note
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def build_models_used_label(model_usages: list[SessionModelUsage], current_model: str) -> str:
|
|
502
|
+
model_names = [item.model_name for item in model_usages if item.model_name]
|
|
503
|
+
if not model_names:
|
|
504
|
+
return current_model or "Unknown"
|
|
505
|
+
if len(model_names) == 1:
|
|
506
|
+
return model_names[0]
|
|
507
|
+
|
|
508
|
+
primary_model = current_model if current_model in model_names else model_names[0]
|
|
509
|
+
remaining_models = len(model_names) - 1
|
|
510
|
+
return f"{primary_model} + {remaining_models} more"
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def model_usage_sort_key(item: SessionModelUsage) -> tuple[int, Decimal, int, str]:
|
|
514
|
+
estimated_cost = item.estimated_cost_usd if item.estimated_cost_usd is not None else Decimal("-1")
|
|
515
|
+
has_pricing = 1 if item.estimated_cost_usd is not None else 0
|
|
516
|
+
return has_pricing, estimated_cost, item.total_tokens, item.model_name.lower()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def lookup_model_pricing(model_name: str) -> ModelPricing | None:
|
|
520
|
+
for candidate in model_key_candidates(model_name):
|
|
521
|
+
pricing = MODEL_PRICING.get(candidate)
|
|
522
|
+
if pricing:
|
|
523
|
+
return pricing
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def model_key_candidates(model_name: str) -> list[str]:
|
|
528
|
+
raw_candidates = {model_name}
|
|
529
|
+
for separator in ("/", ":", "@"):
|
|
530
|
+
if separator in model_name:
|
|
531
|
+
raw_candidates.add(model_name.rsplit(separator, 1)[-1])
|
|
532
|
+
|
|
533
|
+
normalized_candidates: list[str] = []
|
|
534
|
+
seen: set[str] = set()
|
|
535
|
+
for raw_candidate in raw_candidates:
|
|
536
|
+
candidate = normalize_model_key(raw_candidate)
|
|
537
|
+
for variant in candidate_variants(candidate):
|
|
538
|
+
if variant and variant not in seen:
|
|
539
|
+
seen.add(variant)
|
|
540
|
+
normalized_candidates.append(variant)
|
|
541
|
+
return normalized_candidates
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def candidate_variants(candidate: str) -> list[str]:
|
|
545
|
+
variants = [candidate]
|
|
546
|
+
for suffix in ("-public-preview", "-preview", "-ga"):
|
|
547
|
+
if candidate.endswith(suffix):
|
|
548
|
+
variants.append(candidate[: -len(suffix)])
|
|
549
|
+
return variants
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def normalize_model_key(value: str) -> str:
|
|
553
|
+
normalized_chars: list[str] = []
|
|
554
|
+
previous_is_separator = False
|
|
555
|
+
for char in value.strip().lower():
|
|
556
|
+
if char.isalnum() or char == ".":
|
|
557
|
+
normalized_chars.append(char)
|
|
558
|
+
previous_is_separator = False
|
|
559
|
+
continue
|
|
560
|
+
if not previous_is_separator:
|
|
561
|
+
normalized_chars.append("-")
|
|
562
|
+
previous_is_separator = True
|
|
563
|
+
return "".join(normalized_chars).strip("-")
|
|
564
|
+
|
|
565
|
+
|
|
170
566
|
def count_events(events: list[dict], event_type: str) -> int:
|
|
171
567
|
return sum(1 for event in events if event.get("type") == event_type)
|
|
172
568
|
|
|
@@ -379,6 +775,11 @@ def build_flow_nodes(events: list[dict], model_name: str) -> list[SessionFlowNod
|
|
|
379
775
|
pending_tools[tool_call_id] = data
|
|
380
776
|
continue
|
|
381
777
|
|
|
778
|
+
if event_type == "session.resume":
|
|
779
|
+
nodes.append(build_resume_flow_node(next_index, event, event_index))
|
|
780
|
+
next_index += 1
|
|
781
|
+
continue
|
|
782
|
+
|
|
382
783
|
if event_type in {"hook.start", "hook.end", "assistant.turn_start", "assistant.turn_end", "session.start", "session.model_change", "system.message", "session.shutdown"}:
|
|
383
784
|
continue
|
|
384
785
|
|
|
@@ -611,11 +1012,27 @@ def build_abort_flow_node(index: int, event: dict, event_index: int) -> SessionF
|
|
|
611
1012
|
)
|
|
612
1013
|
|
|
613
1014
|
|
|
1015
|
+
def build_resume_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
|
|
1016
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
1017
|
+
return SessionFlowNode(
|
|
1018
|
+
index=index,
|
|
1019
|
+
kind="state",
|
|
1020
|
+
title="Session Resumed",
|
|
1021
|
+
subtitle=format_log_timestamp(str(event.get("timestamp") or "")),
|
|
1022
|
+
detail=compact_text(pretty_value(data), 180) if data else "Resumed previous Copilot CLI session",
|
|
1023
|
+
meta="",
|
|
1024
|
+
log_index=event_index,
|
|
1025
|
+
status="muted",
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
614
1029
|
def flow_event_label(event: dict) -> str:
|
|
615
1030
|
event_type = str(event.get("type") or "event")
|
|
616
1031
|
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
617
1032
|
if event_type == "subagent.selected":
|
|
618
1033
|
return str(data.get("agentDisplayName") or data.get("agentName") or "Subagent")
|
|
1034
|
+
if event_type == "session.resume":
|
|
1035
|
+
return "Session Resumed"
|
|
619
1036
|
if event_type == "session.model_change":
|
|
620
1037
|
return str(data.get("newModel") or "Model selected")
|
|
621
1038
|
if event_type == "user.message" and is_internal_user_message(data):
|
{copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/models.py
RENAMED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
from decimal import Decimal
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
@dataclass(frozen=True)
|
|
7
8
|
class SessionPreview:
|
|
8
9
|
session_id: str
|
|
9
10
|
title: str
|
|
11
|
+
status: str = ""
|
|
12
|
+
model_name: str = ""
|
|
13
|
+
repository: str = ""
|
|
14
|
+
branch: str = ""
|
|
15
|
+
updated_label: str = ""
|
|
10
16
|
is_active: bool = False
|
|
11
17
|
|
|
12
18
|
|
|
@@ -27,10 +33,28 @@ class SessionSummary:
|
|
|
27
33
|
total_input_tokens: int
|
|
28
34
|
total_output_tokens: int
|
|
29
35
|
total_cached_input_tokens: int
|
|
36
|
+
total_cache_write_tokens: int
|
|
30
37
|
total_tokens: int
|
|
38
|
+
estimated_cost_usd: Decimal | None
|
|
39
|
+
estimated_ai_credits: Decimal | None
|
|
40
|
+
billing_note: str
|
|
41
|
+
models_used_label: str
|
|
42
|
+
model_usages: list["SessionModelUsage"]
|
|
31
43
|
error_count: int
|
|
32
44
|
|
|
33
45
|
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SessionModelUsage:
|
|
48
|
+
model_name: str
|
|
49
|
+
input_tokens: int
|
|
50
|
+
cached_input_tokens: int
|
|
51
|
+
cache_write_tokens: int
|
|
52
|
+
output_tokens: int
|
|
53
|
+
total_tokens: int
|
|
54
|
+
estimated_cost_usd: Decimal | None
|
|
55
|
+
estimated_ai_credits: Decimal | None
|
|
56
|
+
|
|
57
|
+
|
|
34
58
|
@dataclass(frozen=True)
|
|
35
59
|
class SessionLogSection:
|
|
36
60
|
title: str
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import html
|
|
4
4
|
import json
|
|
5
|
+
from decimal import Decimal
|
|
5
6
|
|
|
6
7
|
from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionPreview, SessionSummary
|
|
7
8
|
|
|
@@ -199,6 +200,13 @@ def render_document(page_title: str, body: str) -> str:
|
|
|
199
200
|
min-width: 0;
|
|
200
201
|
}}
|
|
201
202
|
|
|
203
|
+
.session-copy {{
|
|
204
|
+
min-width: 0;
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-direction: column;
|
|
207
|
+
gap: 6px;
|
|
208
|
+
}}
|
|
209
|
+
|
|
202
210
|
.session-title {{
|
|
203
211
|
margin: 0;
|
|
204
212
|
font-size: clamp(0.96rem, 1.2vw, 1.28rem);
|
|
@@ -211,6 +219,20 @@ def render_document(page_title: str, body: str) -> str:
|
|
|
211
219
|
text-overflow: ellipsis;
|
|
212
220
|
}}
|
|
213
221
|
|
|
222
|
+
.session-meta {{
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-wrap: wrap;
|
|
225
|
+
gap: 6px 10px;
|
|
226
|
+
color: #8d97a6;
|
|
227
|
+
font-size: 0.84rem;
|
|
228
|
+
line-height: 1.35;
|
|
229
|
+
letter-spacing: -0.015em;
|
|
230
|
+
}}
|
|
231
|
+
|
|
232
|
+
.session-meta-item {{
|
|
233
|
+
white-space: nowrap;
|
|
234
|
+
}}
|
|
235
|
+
|
|
214
236
|
.icon {{
|
|
215
237
|
flex: none;
|
|
216
238
|
width: 24px;
|
|
@@ -338,13 +360,13 @@ def render_document(page_title: str, body: str) -> str:
|
|
|
338
360
|
|
|
339
361
|
.card-grid {{
|
|
340
362
|
display: grid;
|
|
341
|
-
grid-template-columns: repeat(
|
|
363
|
+
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
342
364
|
gap: 18px;
|
|
343
365
|
}}
|
|
344
366
|
|
|
345
367
|
.stat-card {{
|
|
346
368
|
min-height: 102px;
|
|
347
|
-
padding: 20px
|
|
369
|
+
padding: 20px 18px 18px;
|
|
348
370
|
border: 1px solid var(--line);
|
|
349
371
|
border-radius: 12px;
|
|
350
372
|
background: rgba(9, 13, 20, 0.68);
|
|
@@ -354,12 +376,11 @@ def render_document(page_title: str, body: str) -> str:
|
|
|
354
376
|
.stat-label {{
|
|
355
377
|
display: block;
|
|
356
378
|
color: var(--muted);
|
|
357
|
-
font-size: 0.
|
|
379
|
+
font-size: clamp(0.76rem, 0.72rem + 0.18vw, 0.88rem);
|
|
358
380
|
line-height: 1.25;
|
|
359
381
|
font-weight: 600;
|
|
382
|
+
letter-spacing: -0.02em;
|
|
360
383
|
white-space: nowrap;
|
|
361
|
-
overflow: hidden;
|
|
362
|
-
text-overflow: ellipsis;
|
|
363
384
|
}}
|
|
364
385
|
|
|
365
386
|
.stat-value {{
|
|
@@ -373,6 +394,75 @@ def render_document(page_title: str, body: str) -> str:
|
|
|
373
394
|
color: var(--text);
|
|
374
395
|
}}
|
|
375
396
|
|
|
397
|
+
.billing-note {{
|
|
398
|
+
margin: 16px 0 0;
|
|
399
|
+
color: var(--muted);
|
|
400
|
+
font-size: 0.94rem;
|
|
401
|
+
line-height: 1.5;
|
|
402
|
+
}}
|
|
403
|
+
|
|
404
|
+
.model-usage-panel {{
|
|
405
|
+
margin-top: 18px;
|
|
406
|
+
border: 1px solid var(--line);
|
|
407
|
+
border-radius: 12px;
|
|
408
|
+
background: rgba(9, 13, 20, 0.68);
|
|
409
|
+
overflow: hidden;
|
|
410
|
+
}}
|
|
411
|
+
|
|
412
|
+
.model-usage-title {{
|
|
413
|
+
padding: 16px 18px 0;
|
|
414
|
+
color: var(--text);
|
|
415
|
+
font-size: 0.96rem;
|
|
416
|
+
line-height: 1.3;
|
|
417
|
+
font-weight: 600;
|
|
418
|
+
}}
|
|
419
|
+
|
|
420
|
+
.model-usage-scroller {{
|
|
421
|
+
overflow-x: auto;
|
|
422
|
+
padding: 12px 18px 18px;
|
|
423
|
+
}}
|
|
424
|
+
|
|
425
|
+
.model-usage-table {{
|
|
426
|
+
width: 100%;
|
|
427
|
+
min-width: 760px;
|
|
428
|
+
border-collapse: collapse;
|
|
429
|
+
font-variant-numeric: tabular-nums;
|
|
430
|
+
}}
|
|
431
|
+
|
|
432
|
+
.model-usage-table th,
|
|
433
|
+
.model-usage-table td {{
|
|
434
|
+
padding: 12px 0;
|
|
435
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
|
436
|
+
text-align: left;
|
|
437
|
+
}}
|
|
438
|
+
|
|
439
|
+
.model-usage-table th {{
|
|
440
|
+
color: var(--muted);
|
|
441
|
+
font-size: 0.82rem;
|
|
442
|
+
line-height: 1.2;
|
|
443
|
+
font-weight: 700;
|
|
444
|
+
letter-spacing: 0.02em;
|
|
445
|
+
text-transform: uppercase;
|
|
446
|
+
}}
|
|
447
|
+
|
|
448
|
+
.model-usage-table tbody tr:last-child td {{
|
|
449
|
+
border-bottom: 0;
|
|
450
|
+
}}
|
|
451
|
+
|
|
452
|
+
.model-usage-table td {{
|
|
453
|
+
color: var(--text);
|
|
454
|
+
font-size: 0.95rem;
|
|
455
|
+
line-height: 1.35;
|
|
456
|
+
font-weight: 500;
|
|
457
|
+
}}
|
|
458
|
+
|
|
459
|
+
.model-usage-empty {{
|
|
460
|
+
padding: 16px 18px 18px;
|
|
461
|
+
color: var(--muted);
|
|
462
|
+
font-size: 0.94rem;
|
|
463
|
+
line-height: 1.5;
|
|
464
|
+
}}
|
|
465
|
+
|
|
376
466
|
.action-row {{
|
|
377
467
|
display: flex;
|
|
378
468
|
flex-wrap: wrap;
|
|
@@ -525,6 +615,45 @@ def build_index_page(session_previews: list[SessionPreview]) -> str:
|
|
|
525
615
|
let isPolling = false;
|
|
526
616
|
let lastMarkup = sessionList ? sessionList.innerHTML.trim() : '';
|
|
527
617
|
|
|
618
|
+
function parseSessionItems(markup) {{
|
|
619
|
+
const template = document.createElement('template');
|
|
620
|
+
template.innerHTML = '<ul>' + markup + '</ul>';
|
|
621
|
+
return Array.from(template.content.querySelectorAll('li[data-session-id]'));
|
|
622
|
+
}}
|
|
623
|
+
|
|
624
|
+
function syncSessionList(markup) {{
|
|
625
|
+
if (!sessionList) {{
|
|
626
|
+
return;
|
|
627
|
+
}}
|
|
628
|
+
|
|
629
|
+
const nextItems = parseSessionItems(markup);
|
|
630
|
+
const existingItems = new Map(
|
|
631
|
+
Array.from(sessionList.querySelectorAll('li[data-session-id]')).map((item) => [item.dataset.sessionId || '', item]),
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
nextItems.forEach((nextItem, index) => {{
|
|
635
|
+
const sessionId = nextItem.dataset.sessionId || '';
|
|
636
|
+
const currentItem = existingItems.get(sessionId);
|
|
637
|
+
let itemToPlace = nextItem;
|
|
638
|
+
|
|
639
|
+
if (currentItem) {{
|
|
640
|
+
existingItems.delete(sessionId);
|
|
641
|
+
if (currentItem.outerHTML === nextItem.outerHTML) {{
|
|
642
|
+
itemToPlace = currentItem;
|
|
643
|
+
}} else {{
|
|
644
|
+
currentItem.replaceWith(nextItem);
|
|
645
|
+
}}
|
|
646
|
+
}}
|
|
647
|
+
|
|
648
|
+
const currentAtIndex = sessionList.children[index] || null;
|
|
649
|
+
if (itemToPlace !== currentAtIndex) {{
|
|
650
|
+
sessionList.insertBefore(itemToPlace, currentAtIndex);
|
|
651
|
+
}}
|
|
652
|
+
}});
|
|
653
|
+
|
|
654
|
+
existingItems.forEach((item) => item.remove());
|
|
655
|
+
}}
|
|
656
|
+
|
|
528
657
|
async function refreshIndex() {{
|
|
529
658
|
if (isPolling || document.hidden || !sessionList) {{
|
|
530
659
|
return;
|
|
@@ -545,7 +674,7 @@ def build_index_page(session_previews: list[SessionPreview]) -> str:
|
|
|
545
674
|
return;
|
|
546
675
|
}}
|
|
547
676
|
|
|
548
|
-
|
|
677
|
+
syncSessionList(itemsHtml);
|
|
549
678
|
lastMarkup = itemsHtml;
|
|
550
679
|
}} catch (_error) {{
|
|
551
680
|
return;
|
|
@@ -599,6 +728,7 @@ def build_index_page(session_previews: list[SessionPreview]) -> str:
|
|
|
599
728
|
def build_session_page(summary: SessionSummary) -> str:
|
|
600
729
|
title = html.escape(summary.title)
|
|
601
730
|
detail_meta = build_session_detail_meta(summary)
|
|
731
|
+
stat_labels = build_summary_stat_labels(summary)
|
|
602
732
|
body = f"""
|
|
603
733
|
<main class="page detail-page">
|
|
604
734
|
<div class="shell app-shell detail-shell">
|
|
@@ -635,12 +765,18 @@ def build_session_page(summary: SessionSummary) -> str:
|
|
|
635
765
|
<div class="card-grid">
|
|
636
766
|
{render_stat_card('Model Turns', summary.model_turns, value_id='session-model-turns-value')}
|
|
637
767
|
{render_stat_card('Tool Calls', summary.tool_calls, value_id='session-tool-calls-value')}
|
|
638
|
-
{render_stat_card('
|
|
768
|
+
{render_stat_card(stat_labels['totalInputLabel'], summary.total_input_tokens, value_id='session-total-input-value', label_id='session-total-input-label')}
|
|
639
769
|
{render_stat_card('Total Output Tokens', summary.total_output_tokens, value_id='session-total-output-value')}
|
|
640
|
-
{render_stat_card('
|
|
770
|
+
{render_stat_card(stat_labels['totalCachedInputLabel'], summary.total_cached_input_tokens, value_id='session-total-cached-input-value', label_id='session-total-cached-input-label')}
|
|
771
|
+
{render_stat_card(stat_labels['totalCacheWriteLabel'], summary.total_cache_write_tokens, value_id='session-total-cache-write-value', label_id='session-total-cache-write-label')}
|
|
641
772
|
{render_stat_card('Total Tokens', summary.total_tokens, value_id='session-total-tokens-value')}
|
|
773
|
+
{render_stat_card('Estimated AI Credits', format_ai_credits(summary.estimated_ai_credits), value_id='session-estimated-ai-credits-value')}
|
|
642
774
|
{render_stat_card('Errors', summary.error_count, value_id='session-error-count-value')}
|
|
643
775
|
</div>
|
|
776
|
+
<p class="billing-note" id="session-billing-note">{html.escape(summary.billing_note)}</p>
|
|
777
|
+
<div class="model-usage-panel" id="session-model-usage-breakdown">
|
|
778
|
+
{render_model_usage_breakdown(summary)}
|
|
779
|
+
</div>
|
|
644
780
|
</div>
|
|
645
781
|
|
|
646
782
|
<div class="explore-block">
|
|
@@ -667,6 +803,13 @@ def build_session_page(summary: SessionSummary) -> str:
|
|
|
667
803
|
}}
|
|
668
804
|
}}
|
|
669
805
|
|
|
806
|
+
function setHTML(id, value) {{
|
|
807
|
+
const node = document.getElementById(id);
|
|
808
|
+
if (node) {{
|
|
809
|
+
node.innerHTML = value;
|
|
810
|
+
}}
|
|
811
|
+
}}
|
|
812
|
+
|
|
670
813
|
async function refreshSummary() {{
|
|
671
814
|
if (isPolling || document.hidden) {{
|
|
672
815
|
return;
|
|
@@ -688,13 +831,20 @@ def build_session_page(summary: SessionSummary) -> str:
|
|
|
688
831
|
setText('session-status-value', summary.status || '');
|
|
689
832
|
setText('session-created-value', summary.createdLabel || '');
|
|
690
833
|
setText('session-updated-value', summary.updatedLabel || '');
|
|
834
|
+
setText('session-total-input-label', summary.totalInputLabel || 'Total Input Tokens');
|
|
835
|
+
setText('session-total-cached-input-label', summary.totalCachedInputLabel || 'Total Cached Input Tokens');
|
|
836
|
+
setText('session-total-cache-write-label', summary.totalCacheWriteLabel || 'Total Cache Write Tokens');
|
|
691
837
|
setText('session-model-turns-value', summary.modelTurns || '0');
|
|
692
838
|
setText('session-tool-calls-value', summary.toolCalls || '0');
|
|
693
839
|
setText('session-total-input-value', summary.totalInputTokens || '0');
|
|
694
840
|
setText('session-total-output-value', summary.totalOutputTokens || '0');
|
|
695
841
|
setText('session-total-cached-input-value', summary.totalCachedInputTokens || '0');
|
|
842
|
+
setText('session-total-cache-write-value', summary.totalCacheWriteTokens || '0');
|
|
696
843
|
setText('session-total-tokens-value', summary.totalTokens || '0');
|
|
844
|
+
setText('session-estimated-ai-credits-value', summary.estimatedAiCredits || '-');
|
|
697
845
|
setText('session-error-count-value', summary.errorCount || '0');
|
|
846
|
+
setText('session-billing-note', summary.billingNote || '');
|
|
847
|
+
setHTML('session-model-usage-breakdown', summary.modelUsageBreakdownHtml || '');
|
|
698
848
|
}} catch (_error) {{
|
|
699
849
|
return;
|
|
700
850
|
}} finally {{
|
|
@@ -2123,31 +2273,65 @@ def render_session_list_markup(session_previews: list[SessionPreview]) -> str:
|
|
|
2123
2273
|
return "\n".join(render_session_item(session) for session in session_previews)
|
|
2124
2274
|
|
|
2125
2275
|
|
|
2276
|
+
def build_session_preview_meta(session: SessionPreview) -> str:
|
|
2277
|
+
items: list[str] = []
|
|
2278
|
+
if session.repository:
|
|
2279
|
+
items.append(session.repository)
|
|
2280
|
+
if session.branch:
|
|
2281
|
+
items.append(session.branch)
|
|
2282
|
+
if session.model_name:
|
|
2283
|
+
items.append(session.model_name)
|
|
2284
|
+
|
|
2285
|
+
if session.updated_label:
|
|
2286
|
+
items.append(f"Updated {session.updated_label}")
|
|
2287
|
+
|
|
2288
|
+
return "".join(f'<span class="session-meta-item">{html.escape(item)}</span>' for item in items)
|
|
2289
|
+
|
|
2290
|
+
|
|
2126
2291
|
def render_session_item(session: SessionPreview) -> str:
|
|
2127
2292
|
title = html.escape(session.title)
|
|
2128
2293
|
badge = '<span class="badge">Active</span>' if session.is_active else ""
|
|
2294
|
+
meta = build_session_preview_meta(session)
|
|
2129
2295
|
return f"""
|
|
2130
|
-
<li>
|
|
2296
|
+
<li data-session-id="{html.escape(session.session_id, quote=True)}">
|
|
2131
2297
|
<a class="session-link" href="/sessions/{session.session_id}" aria-label="Open {title}">
|
|
2132
2298
|
<div class="session-main">
|
|
2133
2299
|
{chat_icon()}
|
|
2134
|
-
<
|
|
2300
|
+
<div class="session-copy">
|
|
2301
|
+
<p class="session-title">{title}</p>
|
|
2302
|
+
<div class="session-meta">{meta}</div>
|
|
2303
|
+
</div>
|
|
2135
2304
|
</div>
|
|
2136
2305
|
{badge}
|
|
2137
2306
|
</a>
|
|
2138
2307
|
</li>"""
|
|
2139
2308
|
|
|
2140
2309
|
|
|
2141
|
-
def
|
|
2142
|
-
|
|
2310
|
+
def build_summary_stat_labels(summary: SessionSummary) -> dict[str, str]:
|
|
2311
|
+
if summary.status == "Active":
|
|
2312
|
+
return {
|
|
2313
|
+
"totalInputLabel": "Input Tokens (Finalized)",
|
|
2314
|
+
"totalCachedInputLabel": "Cached Input (Finalized)",
|
|
2315
|
+
"totalCacheWriteLabel": "Cache Write (Finalized)",
|
|
2316
|
+
}
|
|
2317
|
+
return {
|
|
2318
|
+
"totalInputLabel": "Total Input Tokens",
|
|
2319
|
+
"totalCachedInputLabel": "Total Cached Input Tokens",
|
|
2320
|
+
"totalCacheWriteLabel": "Total Cache Write Tokens",
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
|
|
2324
|
+
def render_stat_card(label: str, value: int | str, value_id: str | None = None, label_id: str | None = None) -> str:
|
|
2325
|
+
return render_live_stat_card(label, value, value_id=value_id, label_id=label_id)
|
|
2143
2326
|
|
|
2144
2327
|
|
|
2145
|
-
def render_live_stat_card(label: str, value: int, value_id: str | None = None) -> str:
|
|
2328
|
+
def render_live_stat_card(label: str, value: int | str, value_id: str | None = None, label_id: str | None = None) -> str:
|
|
2329
|
+
label_id_attr = f' id="{html.escape(label_id, quote=True)}"' if label_id else ''
|
|
2146
2330
|
value_id_attr = f' id="{html.escape(value_id, quote=True)}"' if value_id else ''
|
|
2147
2331
|
return f"""
|
|
2148
2332
|
<article class="stat-card">
|
|
2149
|
-
<span class="stat-label">{html.escape(label)}</span>
|
|
2150
|
-
<span class="stat-value"{value_id_attr}>{
|
|
2333
|
+
<span class="stat-label"{label_id_attr}>{html.escape(label)}</span>
|
|
2334
|
+
<span class="stat-value"{value_id_attr}>{html.escape(format_stat_value(value))}</span>
|
|
2151
2335
|
</article>"""
|
|
2152
2336
|
|
|
2153
2337
|
|
|
@@ -2195,6 +2379,7 @@ def render_empty_flow_state() -> str:
|
|
|
2195
2379
|
|
|
2196
2380
|
|
|
2197
2381
|
def build_session_snapshot_payload(summary: SessionSummary) -> dict[str, str]:
|
|
2382
|
+
stat_labels = build_summary_stat_labels(summary)
|
|
2198
2383
|
return {
|
|
2199
2384
|
"detailMeta": build_session_detail_meta(summary),
|
|
2200
2385
|
"sessionType": summary.session_type,
|
|
@@ -2202,12 +2387,19 @@ def build_session_snapshot_payload(summary: SessionSummary) -> dict[str, str]:
|
|
|
2202
2387
|
"status": summary.status,
|
|
2203
2388
|
"createdLabel": summary.created_label,
|
|
2204
2389
|
"updatedLabel": summary.updated_label,
|
|
2390
|
+
"totalInputLabel": stat_labels["totalInputLabel"],
|
|
2391
|
+
"totalCachedInputLabel": stat_labels["totalCachedInputLabel"],
|
|
2392
|
+
"totalCacheWriteLabel": stat_labels["totalCacheWriteLabel"],
|
|
2205
2393
|
"modelTurns": format_number(summary.model_turns),
|
|
2206
2394
|
"toolCalls": format_number(summary.tool_calls),
|
|
2207
2395
|
"totalInputTokens": format_number(summary.total_input_tokens),
|
|
2208
2396
|
"totalOutputTokens": format_number(summary.total_output_tokens),
|
|
2209
2397
|
"totalCachedInputTokens": format_number(summary.total_cached_input_tokens),
|
|
2398
|
+
"totalCacheWriteTokens": format_number(summary.total_cache_write_tokens),
|
|
2210
2399
|
"totalTokens": format_number(summary.total_tokens),
|
|
2400
|
+
"estimatedAiCredits": format_ai_credits(summary.estimated_ai_credits),
|
|
2401
|
+
"billingNote": summary.billing_note,
|
|
2402
|
+
"modelUsageBreakdownHtml": render_model_usage_breakdown(summary),
|
|
2211
2403
|
"errorCount": format_number(summary.error_count),
|
|
2212
2404
|
}
|
|
2213
2405
|
|
|
@@ -2292,7 +2484,8 @@ def render_empty_log_list() -> str:
|
|
|
2292
2484
|
|
|
2293
2485
|
|
|
2294
2486
|
def build_session_detail_meta(summary: SessionSummary) -> str:
|
|
2295
|
-
|
|
2487
|
+
label = "Models" if len(summary.model_usages) > 1 else "Model"
|
|
2488
|
+
return f"{label}: {summary.models_used_label or 'Unknown'} · Repository: {summary.repository or '-'} · Branch: {summary.branch or '-'}"
|
|
2296
2489
|
|
|
2297
2490
|
|
|
2298
2491
|
def chat_icon() -> str:
|
|
@@ -2303,3 +2496,52 @@ def chat_icon() -> str:
|
|
|
2303
2496
|
|
|
2304
2497
|
def format_number(value: int) -> str:
|
|
2305
2498
|
return f"{value:,}"
|
|
2499
|
+
|
|
2500
|
+
|
|
2501
|
+
def format_stat_value(value: int | str) -> str:
|
|
2502
|
+
return format_number(value) if isinstance(value, int) else value
|
|
2503
|
+
|
|
2504
|
+
|
|
2505
|
+
def format_ai_credits(value: Decimal | None) -> str:
|
|
2506
|
+
if value is None:
|
|
2507
|
+
return "-"
|
|
2508
|
+
return f"{value.quantize(Decimal('0.01')):,.2f}".rstrip("0").rstrip(".")
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def render_model_usage_breakdown(summary: SessionSummary) -> str:
|
|
2512
|
+
if not summary.model_usages:
|
|
2513
|
+
return '<div class="model-usage-empty">No per-model usage metrics available yet.</div>'
|
|
2514
|
+
|
|
2515
|
+
rows = "\n".join(
|
|
2516
|
+
f"""
|
|
2517
|
+
<tr>
|
|
2518
|
+
<td>{html.escape(item.model_name)}</td>
|
|
2519
|
+
<td>{format_number(item.input_tokens)}</td>
|
|
2520
|
+
<td>{format_number(item.cached_input_tokens)}</td>
|
|
2521
|
+
<td>{format_number(item.cache_write_tokens)}</td>
|
|
2522
|
+
<td>{format_number(item.output_tokens)}</td>
|
|
2523
|
+
<td>{format_number(item.total_tokens)}</td>
|
|
2524
|
+
<td>{html.escape(format_ai_credits(item.estimated_ai_credits))}</td>
|
|
2525
|
+
</tr>"""
|
|
2526
|
+
for item in summary.model_usages
|
|
2527
|
+
)
|
|
2528
|
+
return f"""
|
|
2529
|
+
<div class="model-usage-title">Model Usage Breakdown</div>
|
|
2530
|
+
<div class="model-usage-scroller">
|
|
2531
|
+
<table class="model-usage-table">
|
|
2532
|
+
<thead>
|
|
2533
|
+
<tr>
|
|
2534
|
+
<th>Model</th>
|
|
2535
|
+
<th>Input</th>
|
|
2536
|
+
<th>Cached Input</th>
|
|
2537
|
+
<th>Cache Write</th>
|
|
2538
|
+
<th>Output</th>
|
|
2539
|
+
<th>Total</th>
|
|
2540
|
+
<th>AIC</th>
|
|
2541
|
+
</tr>
|
|
2542
|
+
</thead>
|
|
2543
|
+
<tbody>
|
|
2544
|
+
{rows}
|
|
2545
|
+
</tbody>
|
|
2546
|
+
</table>
|
|
2547
|
+
</div>"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: copilot-cli-trace-deck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -34,23 +34,25 @@ Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
|
|
|
34
34
|
Run the app directly from the workspace with `uv`:
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
|
|
37
|
+
uvx copilot-cli-trace-deck@latest
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
By default the server listens on `http://127.0.0.1:9887` and opens that URL in your browser.
|
|
41
41
|
|
|
42
42
|
Live updates are pushed over Server-Sent Events and triggered by file-system changes in the session-state directory, so the home, summary, logs, and flow pages update without browser polling.
|
|
43
43
|
|
|
44
|
+
The summary view also estimates per-session GitHub AI Credits / USD cost from shutdown metrics, aggregating token usage across model switches within the same session.
|
|
45
|
+
|
|
44
46
|
You can also pass the session-state source and server options:
|
|
45
47
|
|
|
46
48
|
```bash
|
|
47
|
-
|
|
49
|
+
uvx copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
|
|
48
50
|
```
|
|
49
51
|
|
|
50
52
|
To skip opening a browser while still printing the local URL:
|
|
51
53
|
|
|
52
54
|
```bash
|
|
53
|
-
|
|
55
|
+
uvx copilot-cli-trace-deck --quiet
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
## Install As A Command
|
|
File without changes
|
|
File without changes
|
{copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/__init__.py
RENAMED
|
File without changes
|
{copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
{copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/server.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|