cli-consumption 0.0.1__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.
- cli_consumption/__init__.py +10 -0
- cli_consumption/__main__.py +4 -0
- cli_consumption/adapters/__init__.py +3 -0
- cli_consumption/adapters/base.py +18 -0
- cli_consumption/adapters/codex.py +591 -0
- cli_consumption/api.py +66 -0
- cli_consumption/cli.py +245 -0
- cli_consumption/dashboard.py +431 -0
- cli_consumption/exporting.py +31 -0
- cli_consumption/models.py +60 -0
- cli_consumption/py.typed +0 -0
- cli_consumption/storage.py +525 -0
- cli_consumption/sync.py +25 -0
- cli_consumption-0.0.1.dist-info/METADATA +192 -0
- cli_consumption-0.0.1.dist-info/RECORD +19 -0
- cli_consumption-0.0.1.dist-info/WHEEL +4 -0
- cli_consumption-0.0.1.dist-info/entry_points.txt +2 -0
- cli_consumption-0.0.1.dist-info/licenses/LICENSE +201 -0
- cli_consumption-0.0.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Analyze AI coding CLI usage without exporting conversation content."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("cli-consumption")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - source tree without installation
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from cli_consumption.models import Snapshot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Adapter(Protocol):
|
|
10
|
+
"""Contract implemented by every supported AI CLI."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
|
|
14
|
+
def collect(
|
|
15
|
+
self,
|
|
16
|
+
sources: list[tuple[str, Path]],
|
|
17
|
+
project_mappings: list[tuple[str, str]],
|
|
18
|
+
) -> Snapshot: ...
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cli_consumption.models import TOKEN_FIELDS, Snapshot, empty_tokens
|
|
13
|
+
|
|
14
|
+
OUTSIDE_PROJECT = "outside-project"
|
|
15
|
+
TOOL_PATTERN = re.compile(r"(?:tools|collaboration)\.([A-Za-z][A-Za-z0-9_]*)\s*\(")
|
|
16
|
+
KNOWN_NESTED_TOOLS = {
|
|
17
|
+
"apply_patch",
|
|
18
|
+
"create_goal",
|
|
19
|
+
"exec_command",
|
|
20
|
+
"get_goal",
|
|
21
|
+
"image_gen__imagegen",
|
|
22
|
+
"list_mcp_resource_templates",
|
|
23
|
+
"list_mcp_resources",
|
|
24
|
+
"read_mcp_resource",
|
|
25
|
+
"update_goal",
|
|
26
|
+
"update_plan",
|
|
27
|
+
"view_image",
|
|
28
|
+
"wait",
|
|
29
|
+
"web__run",
|
|
30
|
+
"write_stdin",
|
|
31
|
+
}
|
|
32
|
+
WORK_ITEM_KINDS = {
|
|
33
|
+
"AgentMessage": "message",
|
|
34
|
+
"CollabAgentToolCall": "agent-coordination",
|
|
35
|
+
"CommandExecution": "command",
|
|
36
|
+
"ContextCompaction": "compaction",
|
|
37
|
+
"DynamicToolCall": "dynamic-tool",
|
|
38
|
+
"Extension": "extension",
|
|
39
|
+
"FileChange": "file-change",
|
|
40
|
+
"ImageView": "media",
|
|
41
|
+
"McpToolCall": "mcp-tool",
|
|
42
|
+
"Reasoning": "reasoning",
|
|
43
|
+
"SubAgentActivity": "subagent-activity",
|
|
44
|
+
"UserMessage": "user-message",
|
|
45
|
+
}
|
|
46
|
+
SAFE_DIMENSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+-]*")
|
|
47
|
+
MAX_BIGINT = 9_223_372_036_854_775_807
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_timestamp(value: str | None) -> datetime | None:
|
|
51
|
+
if not value:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def infer_project(
|
|
60
|
+
metadata: dict[str, Any], mappings: list[tuple[str, str]]
|
|
61
|
+
) -> tuple[str, str]:
|
|
62
|
+
cwd = str(metadata.get("cwd") or "").rstrip("/\\")
|
|
63
|
+
normalized_cwd = cwd.replace("\\", "/")
|
|
64
|
+
for name, prefix in sorted(mappings, key=lambda item: len(item[1]), reverse=True):
|
|
65
|
+
normalized_prefix = prefix.replace("\\", "/").rstrip("/")
|
|
66
|
+
if normalized_cwd == normalized_prefix or normalized_cwd.startswith(
|
|
67
|
+
normalized_prefix + "/"
|
|
68
|
+
):
|
|
69
|
+
return name, "mapping"
|
|
70
|
+
git = metadata.get("git")
|
|
71
|
+
if isinstance(git, dict):
|
|
72
|
+
repository = str(git.get("repository_url") or git.get("repository") or "")
|
|
73
|
+
slug = re.split(r"[/\\:]", repository.rstrip("/\\"))[-1]
|
|
74
|
+
if slug.endswith(".git"):
|
|
75
|
+
slug = slug[:-4]
|
|
76
|
+
if slug:
|
|
77
|
+
return slug, "git"
|
|
78
|
+
return OUTSIDE_PROJECT, "none"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_tools(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
|
82
|
+
outer_name = str(payload.get("name", "unknown"))
|
|
83
|
+
if outer_name != "exec":
|
|
84
|
+
return [(outer_name, outer_name)]
|
|
85
|
+
raw_input = payload.get("input", "")
|
|
86
|
+
if not isinstance(raw_input, str):
|
|
87
|
+
raw_input = json.dumps(raw_input, sort_keys=True)
|
|
88
|
+
nested = [
|
|
89
|
+
name
|
|
90
|
+
for name in TOOL_PATTERN.findall(raw_input)
|
|
91
|
+
if name in KNOWN_NESTED_TOOLS or name.startswith("mcp__")
|
|
92
|
+
]
|
|
93
|
+
return [(outer_name, name) for name in nested] or [(outer_name, outer_name)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CodexAdapter:
|
|
97
|
+
"""Read local Codex rollout metadata while excluding message content."""
|
|
98
|
+
|
|
99
|
+
name = "codex"
|
|
100
|
+
|
|
101
|
+
def collect(
|
|
102
|
+
self,
|
|
103
|
+
sources: list[tuple[str, Path]],
|
|
104
|
+
project_mappings: list[tuple[str, str]] | None = None,
|
|
105
|
+
) -> Snapshot:
|
|
106
|
+
selected, duplicates, discovery_malformed = self._discover(sources)
|
|
107
|
+
snapshot = Snapshot(
|
|
108
|
+
provider=self.name,
|
|
109
|
+
duplicate_conversations=duplicates,
|
|
110
|
+
malformed_records=discovery_malformed,
|
|
111
|
+
)
|
|
112
|
+
mappings = project_mappings or []
|
|
113
|
+
for machine, path, event_count, digest in selected:
|
|
114
|
+
self._read_rollout(snapshot, machine, path, event_count, digest, mappings)
|
|
115
|
+
for machine, codex_home in sources:
|
|
116
|
+
snapshot.subagents.extend(
|
|
117
|
+
self._read_subagents(codex_home / "state_5.sqlite", machine)
|
|
118
|
+
)
|
|
119
|
+
return snapshot
|
|
120
|
+
|
|
121
|
+
def _read_subagents(
|
|
122
|
+
self, state_path: Path, source_machine: str
|
|
123
|
+
) -> list[dict[str, Any]]:
|
|
124
|
+
if not state_path.is_file():
|
|
125
|
+
return []
|
|
126
|
+
connection = sqlite3.connect(f"file:{state_path}?mode=ro", uri=True)
|
|
127
|
+
connection.row_factory = sqlite3.Row
|
|
128
|
+
try:
|
|
129
|
+
rows = connection.execute(
|
|
130
|
+
"""
|
|
131
|
+
SELECT e.parent_thread_id, e.child_thread_id, e.status,
|
|
132
|
+
t.created_at_ms, t.updated_at_ms, t.agent_nickname,
|
|
133
|
+
t.agent_role, t.tokens_used
|
|
134
|
+
FROM thread_spawn_edges e
|
|
135
|
+
LEFT JOIN threads t ON t.id = e.child_thread_id
|
|
136
|
+
ORDER BY t.created_at_ms, e.child_thread_id
|
|
137
|
+
"""
|
|
138
|
+
).fetchall()
|
|
139
|
+
except sqlite3.OperationalError:
|
|
140
|
+
return []
|
|
141
|
+
finally:
|
|
142
|
+
connection.close()
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
"id": f"codex:{source_machine}:{row['child_thread_id']}",
|
|
146
|
+
"provider": self.name,
|
|
147
|
+
"source_machine": source_machine,
|
|
148
|
+
"parent_thread_id": str(row["parent_thread_id"]),
|
|
149
|
+
"child_thread_id": str(row["child_thread_id"]),
|
|
150
|
+
"status": str(row["status"] or "unknown"),
|
|
151
|
+
"created_at_ms": _integer_or_none(row["created_at_ms"]),
|
|
152
|
+
"updated_at_ms": _integer_or_none(row["updated_at_ms"]),
|
|
153
|
+
"agent_nickname": str(row["agent_nickname"] or ""),
|
|
154
|
+
"agent_role": str(row["agent_role"] or ""),
|
|
155
|
+
"tokens_used": _integer_or_none(row["tokens_used"]),
|
|
156
|
+
}
|
|
157
|
+
for row in rows
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
def _discover(
|
|
161
|
+
self, sources: list[tuple[str, Path]]
|
|
162
|
+
) -> tuple[list[tuple[str, Path, int, str]], int, int]:
|
|
163
|
+
selected: dict[str, tuple[str, Path, int, str]] = {}
|
|
164
|
+
duplicates = 0
|
|
165
|
+
malformed = 0
|
|
166
|
+
for machine, codex_home in sources:
|
|
167
|
+
sessions = codex_home / "sessions"
|
|
168
|
+
if not sessions.is_dir():
|
|
169
|
+
raise ValueError(f"Missing Codex sessions directory: {sessions}")
|
|
170
|
+
for path in sorted(sessions.rglob("*.jsonl")):
|
|
171
|
+
event_count = 0
|
|
172
|
+
conversation_id = ""
|
|
173
|
+
digest = hashlib.sha256()
|
|
174
|
+
with path.open("rb") as handle:
|
|
175
|
+
for raw_line in handle:
|
|
176
|
+
digest.update(raw_line)
|
|
177
|
+
try:
|
|
178
|
+
event = json.loads(raw_line)
|
|
179
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
180
|
+
malformed += 1
|
|
181
|
+
continue
|
|
182
|
+
if not isinstance(event, dict):
|
|
183
|
+
malformed += 1
|
|
184
|
+
continue
|
|
185
|
+
event_count += 1
|
|
186
|
+
if event.get("type") == "session_meta":
|
|
187
|
+
conversation_id = str(
|
|
188
|
+
event.get("payload", {}).get("id", "")
|
|
189
|
+
)
|
|
190
|
+
conversation_id = conversation_id or path.stem
|
|
191
|
+
candidate = (machine, path, event_count, digest.hexdigest())
|
|
192
|
+
previous = selected.get(conversation_id)
|
|
193
|
+
if previous is None:
|
|
194
|
+
selected[conversation_id] = candidate
|
|
195
|
+
else:
|
|
196
|
+
duplicates += 1
|
|
197
|
+
if candidate[2:] > previous[2:]:
|
|
198
|
+
selected[conversation_id] = candidate
|
|
199
|
+
return list(selected.values()), duplicates, malformed
|
|
200
|
+
|
|
201
|
+
def _read_rollout(
|
|
202
|
+
self,
|
|
203
|
+
snapshot: Snapshot,
|
|
204
|
+
machine: str,
|
|
205
|
+
path: Path,
|
|
206
|
+
event_count: int,
|
|
207
|
+
digest: str,
|
|
208
|
+
mappings: list[tuple[str, str]],
|
|
209
|
+
) -> None:
|
|
210
|
+
events: list[dict[str, Any]] = []
|
|
211
|
+
with path.open(encoding="utf-8") as handle:
|
|
212
|
+
for line in handle:
|
|
213
|
+
try:
|
|
214
|
+
event = json.loads(line)
|
|
215
|
+
except json.JSONDecodeError:
|
|
216
|
+
continue
|
|
217
|
+
if isinstance(event, dict):
|
|
218
|
+
events.append(event)
|
|
219
|
+
|
|
220
|
+
metadata: dict[str, Any] = next(
|
|
221
|
+
(
|
|
222
|
+
payload
|
|
223
|
+
for event in events
|
|
224
|
+
if event.get("type") == "session_meta"
|
|
225
|
+
and isinstance((payload := event.get("payload")), dict)
|
|
226
|
+
),
|
|
227
|
+
{},
|
|
228
|
+
)
|
|
229
|
+
conversation_id = str(metadata.get("id") or path.stem)
|
|
230
|
+
record_id = f"codex:{conversation_id}"
|
|
231
|
+
project, project_source = infer_project(metadata, mappings)
|
|
232
|
+
timestamps = [
|
|
233
|
+
timestamp
|
|
234
|
+
for event in events
|
|
235
|
+
if (timestamp := parse_timestamp(event.get("timestamp"))) is not None
|
|
236
|
+
]
|
|
237
|
+
started_at = min(timestamps, default=None)
|
|
238
|
+
ended_at = max(timestamps, default=None)
|
|
239
|
+
active_turn_id: str | None = None
|
|
240
|
+
active_model: str | None = None
|
|
241
|
+
turns: dict[str, dict[str, Any]] = {}
|
|
242
|
+
models: set[str] = set()
|
|
243
|
+
totals = empty_tokens()
|
|
244
|
+
call_sequence = 0
|
|
245
|
+
tool_sequence = 0
|
|
246
|
+
work_sequence = 0
|
|
247
|
+
compaction_sequence = 0
|
|
248
|
+
compactions = 0
|
|
249
|
+
setting_defaults: dict[str, str | int | None] = {
|
|
250
|
+
"model": None,
|
|
251
|
+
"effort": None,
|
|
252
|
+
"collaboration_mode": None,
|
|
253
|
+
"service_tier": None,
|
|
254
|
+
"context_window_tokens": None,
|
|
255
|
+
}
|
|
256
|
+
settings_by_turn: dict[str, dict[str, str | int | None]] = {}
|
|
257
|
+
|
|
258
|
+
for event in events:
|
|
259
|
+
timestamp = parse_timestamp(event.get("timestamp"))
|
|
260
|
+
payload = event.get("payload", {})
|
|
261
|
+
if not isinstance(payload, dict):
|
|
262
|
+
continue
|
|
263
|
+
event_type = event.get("type")
|
|
264
|
+
payload_type = payload.get("type")
|
|
265
|
+
if event_type == "compacted":
|
|
266
|
+
compactions += 1
|
|
267
|
+
compaction_sequence += 1
|
|
268
|
+
snapshot.compaction_events.append(
|
|
269
|
+
{
|
|
270
|
+
"id": f"{record_id}:compaction:{compaction_sequence}",
|
|
271
|
+
"conversation_id": record_id,
|
|
272
|
+
"turn_id": (
|
|
273
|
+
f"{record_id}:{active_turn_id}" if active_turn_id else None
|
|
274
|
+
),
|
|
275
|
+
"sequence": compaction_sequence,
|
|
276
|
+
"timestamp": _iso(timestamp),
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
if event_type == "event_msg" and payload_type == "thread_settings_applied":
|
|
280
|
+
raw_settings = payload.get("thread_settings")
|
|
281
|
+
if isinstance(raw_settings, dict):
|
|
282
|
+
updates: dict[str, str | int | None] = {
|
|
283
|
+
"model": _safe_dimension(raw_settings.get("model"), 255),
|
|
284
|
+
"effort": _safe_dimension(
|
|
285
|
+
raw_settings.get("reasoning_effort"), 64
|
|
286
|
+
),
|
|
287
|
+
"collaboration_mode": _collaboration_mode(
|
|
288
|
+
raw_settings.get("collaboration_mode")
|
|
289
|
+
),
|
|
290
|
+
"service_tier": _safe_dimension(
|
|
291
|
+
raw_settings.get("service_tier"), 64
|
|
292
|
+
),
|
|
293
|
+
}
|
|
294
|
+
_merge_present(setting_defaults, updates)
|
|
295
|
+
if active_turn_id and active_turn_id in settings_by_turn:
|
|
296
|
+
_merge_present(settings_by_turn[active_turn_id], updates)
|
|
297
|
+
if event_type == "turn_context":
|
|
298
|
+
active_turn_id = (
|
|
299
|
+
str(payload.get("turn_id") or active_turn_id or "") or None
|
|
300
|
+
)
|
|
301
|
+
active_model = (
|
|
302
|
+
_safe_dimension(payload.get("model"), 255) or active_model
|
|
303
|
+
)
|
|
304
|
+
if active_model:
|
|
305
|
+
models.add(active_model)
|
|
306
|
+
if active_turn_id:
|
|
307
|
+
settings_by_turn[active_turn_id] = {
|
|
308
|
+
**setting_defaults,
|
|
309
|
+
"model": active_model,
|
|
310
|
+
"effort": _safe_dimension(payload.get("effort"), 64)
|
|
311
|
+
or setting_defaults["effort"],
|
|
312
|
+
"collaboration_mode": _collaboration_mode(
|
|
313
|
+
payload.get("collaboration_mode")
|
|
314
|
+
)
|
|
315
|
+
or setting_defaults["collaboration_mode"],
|
|
316
|
+
}
|
|
317
|
+
if event_type == "event_msg" and payload_type == "task_started":
|
|
318
|
+
active_turn_id = str(payload.get("turn_id") or "") or None
|
|
319
|
+
if active_turn_id:
|
|
320
|
+
settings = settings_by_turn.setdefault(
|
|
321
|
+
active_turn_id, dict(setting_defaults)
|
|
322
|
+
)
|
|
323
|
+
settings["model"] = active_model or settings["model"]
|
|
324
|
+
context_window = _positive_integer_or_none(
|
|
325
|
+
payload.get("model_context_window")
|
|
326
|
+
)
|
|
327
|
+
if context_window is not None:
|
|
328
|
+
settings["context_window_tokens"] = context_window
|
|
329
|
+
turns[active_turn_id] = {
|
|
330
|
+
"id": f"{record_id}:{active_turn_id}",
|
|
331
|
+
"conversation_id": record_id,
|
|
332
|
+
"external_id": active_turn_id,
|
|
333
|
+
"started_at": _iso(timestamp),
|
|
334
|
+
"ended_at": None,
|
|
335
|
+
"status": "in-progress",
|
|
336
|
+
"duration_ms": None,
|
|
337
|
+
"time_to_first_token_ms": None,
|
|
338
|
+
"model_calls": 0,
|
|
339
|
+
"tool_calls": 0,
|
|
340
|
+
**empty_tokens(),
|
|
341
|
+
}
|
|
342
|
+
continue
|
|
343
|
+
if event_type == "event_msg" and payload_type in {
|
|
344
|
+
"task_complete",
|
|
345
|
+
"turn_aborted",
|
|
346
|
+
}:
|
|
347
|
+
turn_id = str(payload.get("turn_id") or active_turn_id or "")
|
|
348
|
+
if turn_id in turns:
|
|
349
|
+
turns[turn_id].update(
|
|
350
|
+
ended_at=_iso(timestamp),
|
|
351
|
+
status="completed"
|
|
352
|
+
if payload_type == "task_complete"
|
|
353
|
+
else "aborted",
|
|
354
|
+
duration_ms=_integer_or_none(payload.get("duration_ms")),
|
|
355
|
+
time_to_first_token_ms=_integer_or_none(
|
|
356
|
+
payload.get("time_to_first_token_ms")
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
active_turn_id = None
|
|
360
|
+
continue
|
|
361
|
+
if event_type == "event_msg" and payload_type == "item_completed":
|
|
362
|
+
item = payload.get("item")
|
|
363
|
+
if not isinstance(item, dict):
|
|
364
|
+
continue
|
|
365
|
+
work_sequence += 1
|
|
366
|
+
started_at_ms = _integer_or_none(payload.get("started_at_ms"))
|
|
367
|
+
completed_at_ms = _integer_or_none(payload.get("completed_at_ms"))
|
|
368
|
+
turn_id = str(payload.get("turn_id") or active_turn_id or "") or None
|
|
369
|
+
snapshot.work_items.append(
|
|
370
|
+
{
|
|
371
|
+
"id": f"{record_id}:work:{work_sequence}",
|
|
372
|
+
"conversation_id": record_id,
|
|
373
|
+
"turn_id": f"{record_id}:{turn_id}" if turn_id else None,
|
|
374
|
+
"sequence": work_sequence,
|
|
375
|
+
"kind": WORK_ITEM_KINDS.get(
|
|
376
|
+
str(item.get("type") or ""), "other"
|
|
377
|
+
),
|
|
378
|
+
"tool_name": _safe_dimension(item.get("tool"), 512),
|
|
379
|
+
"started_at_ms": started_at_ms,
|
|
380
|
+
"completed_at_ms": completed_at_ms,
|
|
381
|
+
"duration_ms": _interval_duration(
|
|
382
|
+
started_at_ms, completed_at_ms
|
|
383
|
+
),
|
|
384
|
+
"status": _work_item_status(item),
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
continue
|
|
388
|
+
if event_type == "event_msg" and payload_type == "token_count":
|
|
389
|
+
info = payload.get("info")
|
|
390
|
+
usage = info.get("last_token_usage") if isinstance(info, dict) else None
|
|
391
|
+
if not isinstance(usage, dict):
|
|
392
|
+
continue
|
|
393
|
+
call_sequence += 1
|
|
394
|
+
tokens = {
|
|
395
|
+
field: _nonnegative_integer(usage.get(field))
|
|
396
|
+
for field in TOKEN_FIELDS
|
|
397
|
+
}
|
|
398
|
+
tokens.update(_derived_tokens(tokens))
|
|
399
|
+
for field, value in tokens.items():
|
|
400
|
+
totals[field] += value
|
|
401
|
+
turn = turns.get(active_turn_id or "")
|
|
402
|
+
if turn:
|
|
403
|
+
turn["model_calls"] += 1
|
|
404
|
+
for field, value in tokens.items():
|
|
405
|
+
turn[field] += value
|
|
406
|
+
snapshot.model_calls.append(
|
|
407
|
+
{
|
|
408
|
+
"id": f"{record_id}:model:{call_sequence}",
|
|
409
|
+
"conversation_id": record_id,
|
|
410
|
+
"turn_id": turn["id"] if turn else None,
|
|
411
|
+
"sequence": call_sequence,
|
|
412
|
+
"timestamp": _iso(timestamp),
|
|
413
|
+
"model": active_model or "unknown",
|
|
414
|
+
**tokens,
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
context_window = _positive_integer_or_none(
|
|
418
|
+
info.get("model_context_window")
|
|
419
|
+
)
|
|
420
|
+
if context_window is not None:
|
|
421
|
+
if active_turn_id and active_turn_id in settings_by_turn:
|
|
422
|
+
settings_by_turn[active_turn_id]["context_window_tokens"] = (
|
|
423
|
+
context_window
|
|
424
|
+
)
|
|
425
|
+
snapshot.context_samples.append(
|
|
426
|
+
{
|
|
427
|
+
"id": f"{record_id}:context:{call_sequence}",
|
|
428
|
+
"conversation_id": record_id,
|
|
429
|
+
"turn_id": turn["id"] if turn else None,
|
|
430
|
+
"sequence": call_sequence,
|
|
431
|
+
"timestamp": _iso(timestamp),
|
|
432
|
+
"input_tokens": max(0, tokens["input_tokens"]),
|
|
433
|
+
"context_window_tokens": context_window,
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
continue
|
|
437
|
+
if event_type == "response_item" and payload_type in {
|
|
438
|
+
"custom_tool_call",
|
|
439
|
+
"function_call",
|
|
440
|
+
}:
|
|
441
|
+
for outer_name, tool_name in extract_tools(payload):
|
|
442
|
+
tool_sequence += 1
|
|
443
|
+
turn = turns.get(active_turn_id or "")
|
|
444
|
+
if turn:
|
|
445
|
+
turn["tool_calls"] += 1
|
|
446
|
+
snapshot.tool_calls.append(
|
|
447
|
+
{
|
|
448
|
+
"id": f"{record_id}:tool:{tool_sequence}",
|
|
449
|
+
"conversation_id": record_id,
|
|
450
|
+
"turn_id": turn["id"] if turn else None,
|
|
451
|
+
"sequence": tool_sequence,
|
|
452
|
+
"timestamp": _iso(timestamp),
|
|
453
|
+
"tool_name": tool_name,
|
|
454
|
+
"outer_tool_name": outer_name,
|
|
455
|
+
}
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
for turn in turns.values():
|
|
459
|
+
if turn["ended_at"] is None:
|
|
460
|
+
turn["ended_at"] = _iso(ended_at)
|
|
461
|
+
snapshot.turns.append(turn)
|
|
462
|
+
external_turn_id = str(turn["external_id"])
|
|
463
|
+
settings = settings_by_turn.get(external_turn_id, setting_defaults)
|
|
464
|
+
snapshot.turn_settings.append(
|
|
465
|
+
{
|
|
466
|
+
"id": f"{record_id}:settings:{external_turn_id}",
|
|
467
|
+
"conversation_id": record_id,
|
|
468
|
+
"turn_id": str(turn["id"]),
|
|
469
|
+
"model": settings["model"],
|
|
470
|
+
"effort": settings["effort"],
|
|
471
|
+
"collaboration_mode": settings["collaboration_mode"],
|
|
472
|
+
"service_tier": settings["service_tier"],
|
|
473
|
+
"context_window_tokens": settings["context_window_tokens"],
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
snapshot.conversations.append(
|
|
477
|
+
{
|
|
478
|
+
"id": record_id,
|
|
479
|
+
"provider": self.name,
|
|
480
|
+
"external_id": conversation_id,
|
|
481
|
+
"source_machine": machine,
|
|
482
|
+
"project": project,
|
|
483
|
+
"project_source": project_source,
|
|
484
|
+
"started_at": _iso(started_at),
|
|
485
|
+
"ended_at": _iso(ended_at),
|
|
486
|
+
"duration_seconds": (
|
|
487
|
+
(ended_at - started_at).total_seconds()
|
|
488
|
+
if started_at is not None and ended_at is not None
|
|
489
|
+
else None
|
|
490
|
+
),
|
|
491
|
+
"source": str(metadata.get("source") or ""),
|
|
492
|
+
"models": sorted(models),
|
|
493
|
+
"iterations": len(turns),
|
|
494
|
+
"model_calls": call_sequence,
|
|
495
|
+
"tool_calls": tool_sequence,
|
|
496
|
+
"compactions": compactions,
|
|
497
|
+
"event_count": event_count,
|
|
498
|
+
"content_hash": digest,
|
|
499
|
+
**totals,
|
|
500
|
+
}
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _derived_tokens(tokens: dict[str, int]) -> dict[str, int]:
|
|
505
|
+
return {
|
|
506
|
+
"uncached_input_tokens": max(
|
|
507
|
+
0,
|
|
508
|
+
tokens["input_tokens"]
|
|
509
|
+
- tokens["cached_input_tokens"]
|
|
510
|
+
- tokens["cache_write_input_tokens"],
|
|
511
|
+
),
|
|
512
|
+
"visible_output_tokens": max(
|
|
513
|
+
0, tokens["output_tokens"] - tokens["reasoning_output_tokens"]
|
|
514
|
+
),
|
|
515
|
+
"unattributed_tokens": max(
|
|
516
|
+
0,
|
|
517
|
+
tokens["total_tokens"] - tokens["input_tokens"] - tokens["output_tokens"],
|
|
518
|
+
),
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _safe_dimension(value: object, maximum: int) -> str | None:
|
|
523
|
+
if not isinstance(value, str):
|
|
524
|
+
return None
|
|
525
|
+
normalized = value.strip()
|
|
526
|
+
if not normalized or len(normalized) > maximum:
|
|
527
|
+
return None
|
|
528
|
+
return normalized if SAFE_DIMENSION.fullmatch(normalized) else None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _merge_present(
|
|
532
|
+
target: dict[str, str | int | None], updates: dict[str, str | int | None]
|
|
533
|
+
) -> None:
|
|
534
|
+
target.update((key, value) for key, value in updates.items() if value is not None)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _collaboration_mode(value: object) -> str | None:
|
|
538
|
+
if isinstance(value, dict):
|
|
539
|
+
value = value.get("mode")
|
|
540
|
+
return _safe_dimension(value, 64)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _work_item_status(item: dict[str, Any]) -> str:
|
|
544
|
+
exit_code = item.get("exit_code")
|
|
545
|
+
if (
|
|
546
|
+
isinstance(exit_code, int)
|
|
547
|
+
and not isinstance(exit_code, bool)
|
|
548
|
+
and exit_code != 0
|
|
549
|
+
):
|
|
550
|
+
return "failed"
|
|
551
|
+
if item.get("success") is False or bool(item.get("error")):
|
|
552
|
+
return "failed"
|
|
553
|
+
status = str(item.get("status") or "").lower().replace("_", "-")
|
|
554
|
+
if status in {"completed", "success", "succeeded"}:
|
|
555
|
+
return "completed"
|
|
556
|
+
if status in {"failed", "error", "errored"}:
|
|
557
|
+
return "failed"
|
|
558
|
+
if status in {"in-progress", "running", "pending"}:
|
|
559
|
+
return "in-progress"
|
|
560
|
+
return "unknown"
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _interval_duration(
|
|
564
|
+
started_at_ms: int | None, completed_at_ms: int | None
|
|
565
|
+
) -> int | None:
|
|
566
|
+
if started_at_ms is None or completed_at_ms is None:
|
|
567
|
+
return None
|
|
568
|
+
return max(0, completed_at_ms - started_at_ms)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _positive_integer_or_none(value: object) -> int | None:
|
|
572
|
+
parsed = _integer_or_none(value)
|
|
573
|
+
return parsed if parsed is not None and parsed > 0 else None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _iso(value: datetime | None) -> str | None:
|
|
577
|
+
return value.isoformat() if value else None
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _integer_or_none(value: object) -> int | None:
|
|
581
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
582
|
+
return None
|
|
583
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
584
|
+
return None
|
|
585
|
+
parsed = int(value)
|
|
586
|
+
return parsed if -MAX_BIGINT <= parsed <= MAX_BIGINT else None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _nonnegative_integer(value: object) -> int:
|
|
590
|
+
parsed = _integer_or_none(value)
|
|
591
|
+
return max(0, parsed or 0)
|