agentabacus 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.
- agentabacus/__init__.py +3 -0
- agentabacus/adapters/__init__.py +17 -0
- agentabacus/adapters/base.py +101 -0
- agentabacus/adapters/claude_code.py +230 -0
- agentabacus/adapters/codex.py +122 -0
- agentabacus/cli.py +294 -0
- agentabacus/collect.py +94 -0
- agentabacus/config.py +37 -0
- agentabacus/data/__init__.py +0 -0
- agentabacus/data/pricing.csv +12 -0
- agentabacus/discovery.py +75 -0
- agentabacus/pricing.py +79 -0
- agentabacus/report.py +200 -0
- agentabacus/schema.py +260 -0
- agentabacus/store.py +221 -0
- agentabacus-0.1.0.dist-info/METADATA +168 -0
- agentabacus-0.1.0.dist-info/RECORD +20 -0
- agentabacus-0.1.0.dist-info/WHEEL +4 -0
- agentabacus-0.1.0.dist-info/entry_points.txt +2 -0
- agentabacus-0.1.0.dist-info/licenses/LICENSE +21 -0
agentabacus/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Adapter registry.
|
|
2
|
+
|
|
3
|
+
Adding support for a new agent CLI is: write a module exposing
|
|
4
|
+
`parse(path, kind, start_offset) -> Batch`, add a walker in discovery.py, and
|
|
5
|
+
register it here. Nothing else in the codebase needs to change.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import claude_code, codex
|
|
11
|
+
|
|
12
|
+
REGISTRY = {
|
|
13
|
+
claude_code.SOURCE: claude_code.parse,
|
|
14
|
+
codex.SOURCE: codex.parse,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
__all__ = ["REGISTRY", "claude_code", "codex"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Adapter helpers.
|
|
2
|
+
|
|
3
|
+
Design rule for every adapter: **be a tolerant parser**. These formats are
|
|
4
|
+
undocumented, version-dependent, and change without notice. An adapter that
|
|
5
|
+
raises on an unknown `type` turns a vendor's routine release into a crash for
|
|
6
|
+
every user. Route on known shapes, count what you skipped, never throw.
|
|
7
|
+
|
|
8
|
+
The strictness lives in schema.py instead -- messy at the edges, contract-
|
|
9
|
+
enforced in the core.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any, Iterator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_ts(value: Any) -> datetime | None:
|
|
21
|
+
"""ISO-8601 (with or without 'Z') or epoch ms/seconds. Never raises."""
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(value, (int, float)):
|
|
25
|
+
# Heuristic: anything past ~2001 in seconds is milliseconds here.
|
|
26
|
+
seconds = value / 1000 if value > 1e11 else value
|
|
27
|
+
try:
|
|
28
|
+
return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(tzinfo=None)
|
|
29
|
+
except (ValueError, OSError, OverflowError):
|
|
30
|
+
return None
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
try:
|
|
33
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
return dt.astimezone(timezone.utc).replace(tzinfo=None) if dt.tzinfo else dt
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sha256(text: str) -> str:
|
|
41
|
+
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def as_int(value: Any) -> int:
|
|
45
|
+
return value if isinstance(value, int) else 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def iter_lines(path, start_offset: int = 0) -> Iterator[tuple[dict, int]]:
|
|
49
|
+
"""Yield (record, offset_after_this_line) for every COMPLETE line.
|
|
50
|
+
|
|
51
|
+
A trailing partial line -- the file was mid-write when we read it -- is not
|
|
52
|
+
yielded and does not advance the offset, so the next run re-reads it whole.
|
|
53
|
+
Malformed complete lines are skipped with a sentinel rather than raising.
|
|
54
|
+
"""
|
|
55
|
+
with open(path, "rb") as fh:
|
|
56
|
+
fh.seek(start_offset)
|
|
57
|
+
offset = start_offset
|
|
58
|
+
for raw in fh:
|
|
59
|
+
if not raw.endswith(b"\n"):
|
|
60
|
+
break # torn write; leave the offset where it was
|
|
61
|
+
offset += len(raw)
|
|
62
|
+
text = raw.decode("utf-8", "replace").strip()
|
|
63
|
+
if not text:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
record = json.loads(text)
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
yield ({"__unparsed__": True}, offset)
|
|
69
|
+
continue
|
|
70
|
+
if isinstance(record, dict):
|
|
71
|
+
yield (record, offset)
|
|
72
|
+
else:
|
|
73
|
+
yield ({"__unparsed__": True}, offset)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Tool inputs vary per tool; these are the keys that carry "what did it act on".
|
|
77
|
+
_TARGET_KEYS = ("file_path", "path", "notebook_path", "command", "url", "pattern", "query")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def tool_target(tool_input: Any, limit: int = 400) -> str | None:
|
|
81
|
+
if not isinstance(tool_input, dict):
|
|
82
|
+
return None
|
|
83
|
+
for key in _TARGET_KEYS:
|
|
84
|
+
value = tool_input.get(key)
|
|
85
|
+
if isinstance(value, str) and value:
|
|
86
|
+
return value[:limit]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def content_chars(content: Any) -> int:
|
|
91
|
+
if isinstance(content, str):
|
|
92
|
+
return len(content)
|
|
93
|
+
if isinstance(content, list):
|
|
94
|
+
total = 0
|
|
95
|
+
for block in content:
|
|
96
|
+
if isinstance(block, dict):
|
|
97
|
+
total += len(block.get("text") or "")
|
|
98
|
+
elif isinstance(block, str):
|
|
99
|
+
total += len(block)
|
|
100
|
+
return total
|
|
101
|
+
return 0
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Reads three shapes, all JSONL:
|
|
4
|
+
|
|
5
|
+
<slug>/<uuid>.jsonl main session transcript
|
|
6
|
+
<slug>/<uuid>/subagents/agent-*.jsonl one per subagent thread
|
|
7
|
+
history.jsonl every prompt typed, survives GC
|
|
8
|
+
|
|
9
|
+
Two things this adapter exists to get right:
|
|
10
|
+
|
|
11
|
+
1. **Dedupe by requestId.** Consecutive assistant lines are content blocks of
|
|
12
|
+
ONE API response and each repeats the full usage. Aggregating with MAX per
|
|
13
|
+
requestId is the difference between correct numbers and numbers that are
|
|
14
|
+
2-3x too high. Verified against real transcripts: 16 assistant lines
|
|
15
|
+
collapsed to 6 requests, a 2.4-3.0x overcount if summed naively.
|
|
16
|
+
|
|
17
|
+
2. **The cache TTL split.** `cache_creation.ephemeral_1h_input_tokens` and
|
|
18
|
+
`ephemeral_5m_input_tokens` bill at different multipliers (2x vs 1.25x of
|
|
19
|
+
base input). We keep them apart all the way to the cost view.
|
|
20
|
+
|
|
21
|
+
Subagent files carry the PARENT's sessionId plus their own `agentId`, so the
|
|
22
|
+
thread_id is what separates them -- not the session id.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from ..schema import Batch, Prompt, Session, ToolCall, Turn
|
|
30
|
+
from .base import as_int, content_chars, iter_lines, parse_ts, sha256, tool_target
|
|
31
|
+
|
|
32
|
+
SOURCE = "claude_code"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _project_slug(path: Path) -> str | None:
|
|
36
|
+
"""The slug is the directory directly under `projects/`.
|
|
37
|
+
|
|
38
|
+
Derived by walking up rather than by a fixed parent index, because subagent
|
|
39
|
+
transcripts sit at two different depths (plain vs workflow subagents).
|
|
40
|
+
Note this is only a label: the slug encoding ('/' -> '-') is lossy and
|
|
41
|
+
irreversible, so `cwd` from inside the file is the authoritative path.
|
|
42
|
+
"""
|
|
43
|
+
for parent in path.parents:
|
|
44
|
+
if parent.parent.name == "projects":
|
|
45
|
+
return parent.name
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _usage_fields(usage: dict) -> dict:
|
|
50
|
+
"""Pull the token counts, keeping the two cache-write TTLs separate."""
|
|
51
|
+
creation = usage.get("cache_creation")
|
|
52
|
+
if isinstance(creation, dict):
|
|
53
|
+
w1h = as_int(creation.get("ephemeral_1h_input_tokens"))
|
|
54
|
+
w5m = as_int(creation.get("ephemeral_5m_input_tokens"))
|
|
55
|
+
else:
|
|
56
|
+
# Older/other shapes give only the total. Attribute it to the 5m rate,
|
|
57
|
+
# which is the cheaper of the two -- under-report rather than inflate.
|
|
58
|
+
w1h, w5m = 0, as_int(usage.get("cache_creation_input_tokens"))
|
|
59
|
+
|
|
60
|
+
details = usage.get("output_tokens_details")
|
|
61
|
+
thinking = as_int(details.get("thinking_tokens")) if isinstance(details, dict) else 0
|
|
62
|
+
|
|
63
|
+
server = usage.get("server_tool_use")
|
|
64
|
+
server = server if isinstance(server, dict) else {}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"input_tokens": as_int(usage.get("input_tokens")),
|
|
68
|
+
"output_tokens": as_int(usage.get("output_tokens")),
|
|
69
|
+
"thinking_tokens": thinking,
|
|
70
|
+
"cache_read_tokens": as_int(usage.get("cache_read_input_tokens")),
|
|
71
|
+
"cache_write_5m_tokens": w5m,
|
|
72
|
+
"cache_write_1h_tokens": w1h,
|
|
73
|
+
"web_search_requests": as_int(server.get("web_search_requests")),
|
|
74
|
+
"web_fetch_requests": as_int(server.get("web_fetch_requests")),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_history(path: Path, start_offset: int = 0) -> Batch:
|
|
79
|
+
"""history.jsonl: prompt text, project, sessionId, epoch-ms timestamp.
|
|
80
|
+
|
|
81
|
+
Only a hash and a length are kept. The prompt body never enters the
|
|
82
|
+
pipeline, so "does this leak my code?" is answerable from the schema
|
|
83
|
+
instead of from trust in a downstream filter.
|
|
84
|
+
"""
|
|
85
|
+
batch = Batch(byte_offset=start_offset)
|
|
86
|
+
for record, offset in iter_lines(path, start_offset):
|
|
87
|
+
batch.byte_offset = offset
|
|
88
|
+
if record.get("__unparsed__"):
|
|
89
|
+
batch.skipped_lines += 1
|
|
90
|
+
continue
|
|
91
|
+
display = record.get("display")
|
|
92
|
+
if not isinstance(display, str):
|
|
93
|
+
continue
|
|
94
|
+
batch.prompts.append(
|
|
95
|
+
Prompt(
|
|
96
|
+
prompt_sha256=sha256(display),
|
|
97
|
+
session_id=record.get("sessionId"),
|
|
98
|
+
source=SOURCE,
|
|
99
|
+
ts=parse_ts(record.get("timestamp")),
|
|
100
|
+
project=record.get("project"),
|
|
101
|
+
char_len=len(display),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return batch
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_transcript(path: Path, start_offset: int = 0, is_subagent: bool = False) -> Batch:
|
|
108
|
+
batch = Batch(byte_offset=start_offset)
|
|
109
|
+
|
|
110
|
+
turns: dict[str, Turn] = {}
|
|
111
|
+
tools: dict[str, ToolCall] = {}
|
|
112
|
+
meta: dict = {}
|
|
113
|
+
first_ts = last_ts = None
|
|
114
|
+
session_id = thread_id = None
|
|
115
|
+
|
|
116
|
+
for record, offset in iter_lines(path, start_offset):
|
|
117
|
+
batch.byte_offset = offset
|
|
118
|
+
if record.get("__unparsed__"):
|
|
119
|
+
batch.skipped_lines += 1
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
rec_type = record.get("type")
|
|
123
|
+
ts = parse_ts(record.get("timestamp"))
|
|
124
|
+
if ts:
|
|
125
|
+
first_ts = ts if first_ts is None or ts < first_ts else first_ts
|
|
126
|
+
last_ts = ts if last_ts is None or ts > last_ts else last_ts
|
|
127
|
+
|
|
128
|
+
if record.get("sessionId"):
|
|
129
|
+
session_id = record["sessionId"]
|
|
130
|
+
# Subagent threads are identified by agentId, NOT by a distinct session id.
|
|
131
|
+
if record.get("agentId"):
|
|
132
|
+
thread_id = record["agentId"]
|
|
133
|
+
for key in ("cwd", "version", "gitBranch", "entrypoint", "attributionAgent"):
|
|
134
|
+
if record.get(key):
|
|
135
|
+
meta.setdefault(key, record[key])
|
|
136
|
+
if record.get("isSidechain"):
|
|
137
|
+
is_subagent = True
|
|
138
|
+
|
|
139
|
+
message = record.get("message")
|
|
140
|
+
message = message if isinstance(message, dict) else {}
|
|
141
|
+
blocks = message.get("content")
|
|
142
|
+
blocks = blocks if isinstance(blocks, list) else []
|
|
143
|
+
|
|
144
|
+
if rec_type == "assistant":
|
|
145
|
+
request_id = record.get("requestId") or message.get("id")
|
|
146
|
+
usage = message.get("usage")
|
|
147
|
+
if request_id and isinstance(usage, dict):
|
|
148
|
+
fields = _usage_fields(usage)
|
|
149
|
+
existing = turns.get(request_id)
|
|
150
|
+
if existing is None:
|
|
151
|
+
turns[request_id] = Turn(
|
|
152
|
+
request_id=request_id,
|
|
153
|
+
session_id=session_id or "",
|
|
154
|
+
thread_id=thread_id or "main",
|
|
155
|
+
source=SOURCE,
|
|
156
|
+
model_id=message.get("model"),
|
|
157
|
+
ts=ts,
|
|
158
|
+
effort=record.get("effort"),
|
|
159
|
+
service_tier=usage.get("service_tier"),
|
|
160
|
+
speed=usage.get("speed") or "standard",
|
|
161
|
+
block_lines=1,
|
|
162
|
+
**fields,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
# THE DEDUPE. Same request, another content block, same usage
|
|
166
|
+
# repeated. MAX (not +=) so re-reads and partial lines are safe.
|
|
167
|
+
for key, value in fields.items():
|
|
168
|
+
setattr(existing, key, max(getattr(existing, key), value))
|
|
169
|
+
existing.block_lines += 1
|
|
170
|
+
if existing.ts is None or (ts and ts < existing.ts):
|
|
171
|
+
existing.ts = ts
|
|
172
|
+
|
|
173
|
+
for block in blocks:
|
|
174
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
175
|
+
tool_id = block.get("id")
|
|
176
|
+
if not tool_id:
|
|
177
|
+
continue
|
|
178
|
+
tools[tool_id] = ToolCall(
|
|
179
|
+
tool_use_id=tool_id,
|
|
180
|
+
session_id=session_id or "",
|
|
181
|
+
thread_id=thread_id or "main",
|
|
182
|
+
source=SOURCE,
|
|
183
|
+
request_id=request_id,
|
|
184
|
+
ts=ts,
|
|
185
|
+
tool_name=block.get("name"),
|
|
186
|
+
target=tool_target(block.get("input")),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
elif rec_type == "user":
|
|
190
|
+
for block in blocks:
|
|
191
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
192
|
+
continue
|
|
193
|
+
tool_id = block.get("tool_use_id")
|
|
194
|
+
call = tools.get(tool_id) if tool_id else None
|
|
195
|
+
if call is None:
|
|
196
|
+
continue
|
|
197
|
+
call.is_error = bool(block.get("is_error"))
|
|
198
|
+
call.result_chars = content_chars(block.get("content"))
|
|
199
|
+
|
|
200
|
+
if session_id is None:
|
|
201
|
+
return batch
|
|
202
|
+
|
|
203
|
+
thread = thread_id or "main"
|
|
204
|
+
batch.sessions.append(
|
|
205
|
+
Session(
|
|
206
|
+
session_id=session_id,
|
|
207
|
+
thread_id=thread,
|
|
208
|
+
source=SOURCE,
|
|
209
|
+
cwd=meta.get("cwd"),
|
|
210
|
+
project_slug=_project_slug(path),
|
|
211
|
+
git_branch=meta.get("gitBranch"),
|
|
212
|
+
cli_version=meta.get("version"),
|
|
213
|
+
entrypoint=meta.get("entrypoint"),
|
|
214
|
+
agent_type=meta.get("attributionAgent"),
|
|
215
|
+
parent_session_id=session_id if is_subagent else None,
|
|
216
|
+
is_subagent=is_subagent,
|
|
217
|
+
started_at=first_ts,
|
|
218
|
+
ended_at=last_ts,
|
|
219
|
+
transcript_path=str(path),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
batch.turns = list(turns.values())
|
|
223
|
+
batch.tool_calls = list(tools.values())
|
|
224
|
+
return batch
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse(path: Path, kind: str, start_offset: int = 0) -> Batch:
|
|
228
|
+
if kind == "history":
|
|
229
|
+
return parse_history(path, start_offset)
|
|
230
|
+
return parse_transcript(path, start_offset, is_subagent=(kind == "subagent"))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Codex CLI adapter -- STRUCTURE UNVERIFIED.
|
|
2
|
+
|
|
3
|
+
No Codex rollout files were available on the machine this was written against,
|
|
4
|
+
so this adapter is deliberately shape-agnostic: it walks each record looking for
|
|
5
|
+
anything that resembles a usage object and a request identifier, rather than
|
|
6
|
+
asserting a layout it cannot confirm.
|
|
7
|
+
|
|
8
|
+
That makes it useful as a starting point and as the template for a third
|
|
9
|
+
adapter, but treat its output as provisional until someone verifies it against
|
|
10
|
+
real files. Rows land with `source='codex'`, so they are trivial to exclude.
|
|
11
|
+
|
|
12
|
+
Contributing a verified version is the single highest-value PR here: paste two
|
|
13
|
+
or three real records into a test fixture and tighten the parsing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..schema import Batch, Session, Turn
|
|
22
|
+
from .base import as_int, iter_lines, parse_ts
|
|
23
|
+
|
|
24
|
+
SOURCE = "codex"
|
|
25
|
+
|
|
26
|
+
# Both Anthropic-style and OpenAI-style names, since Codex may use either.
|
|
27
|
+
_INPUT_KEYS = ("input_tokens", "prompt_tokens")
|
|
28
|
+
_OUTPUT_KEYS = ("output_tokens", "completion_tokens")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _find_usage(node: Any, depth: int = 0):
|
|
32
|
+
"""Depth-limited hunt for a dict that looks like a usage object."""
|
|
33
|
+
if depth > 6 or not isinstance(node, dict):
|
|
34
|
+
return None
|
|
35
|
+
looks_like_usage = any(k in node for k in _INPUT_KEYS) and any(
|
|
36
|
+
k in node for k in _OUTPUT_KEYS
|
|
37
|
+
)
|
|
38
|
+
if looks_like_usage:
|
|
39
|
+
return node
|
|
40
|
+
for value in node.values():
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
found = _find_usage(value, depth + 1)
|
|
43
|
+
if found is not None:
|
|
44
|
+
return found
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _first(node: dict, keys) -> Any:
|
|
49
|
+
for key in keys:
|
|
50
|
+
value = node.get(key)
|
|
51
|
+
if value:
|
|
52
|
+
return value
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse(path: Path, kind: str = "transcript", start_offset: int = 0) -> Batch:
|
|
57
|
+
batch = Batch(byte_offset=start_offset)
|
|
58
|
+
session_id = path.stem
|
|
59
|
+
turns: dict[str, Turn] = {}
|
|
60
|
+
model_id = None
|
|
61
|
+
first_ts = last_ts = None
|
|
62
|
+
|
|
63
|
+
for record, offset in iter_lines(path, start_offset):
|
|
64
|
+
batch.byte_offset = offset
|
|
65
|
+
if record.get("__unparsed__"):
|
|
66
|
+
batch.skipped_lines += 1
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
ts = parse_ts(_first(record, ("timestamp", "created_at", "time")))
|
|
70
|
+
if ts:
|
|
71
|
+
first_ts = ts if first_ts is None or ts < first_ts else first_ts
|
|
72
|
+
last_ts = ts if last_ts is None or ts > last_ts else last_ts
|
|
73
|
+
model_id = model_id or _first(record, ("model", "model_id"))
|
|
74
|
+
|
|
75
|
+
usage = _find_usage(record)
|
|
76
|
+
if not usage:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
request_id = (
|
|
80
|
+
_first(record, ("request_id", "response_id", "id"))
|
|
81
|
+
or f"{session_id}:{offset}"
|
|
82
|
+
)
|
|
83
|
+
fields = {
|
|
84
|
+
"input_tokens": as_int(_first(usage, _INPUT_KEYS)),
|
|
85
|
+
"output_tokens": as_int(_first(usage, _OUTPUT_KEYS)),
|
|
86
|
+
"cache_read_tokens": as_int(
|
|
87
|
+
_first(usage, ("cache_read_input_tokens", "cached_tokens"))
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
existing = turns.get(request_id)
|
|
91
|
+
if existing is None:
|
|
92
|
+
turns[request_id] = Turn(
|
|
93
|
+
request_id=f"codex:{request_id}",
|
|
94
|
+
session_id=session_id,
|
|
95
|
+
thread_id="main",
|
|
96
|
+
source=SOURCE,
|
|
97
|
+
model_id=_first(record, ("model", "model_id")) or model_id,
|
|
98
|
+
ts=ts,
|
|
99
|
+
speed="standard",
|
|
100
|
+
block_lines=1,
|
|
101
|
+
**fields,
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
# Same dedupe discipline as the Claude Code adapter: if a runtime
|
|
105
|
+
# repeats usage across records, MAX keeps the total honest.
|
|
106
|
+
for key, value in fields.items():
|
|
107
|
+
setattr(existing, key, max(getattr(existing, key), value))
|
|
108
|
+
existing.block_lines += 1
|
|
109
|
+
|
|
110
|
+
if turns:
|
|
111
|
+
batch.sessions.append(
|
|
112
|
+
Session(
|
|
113
|
+
session_id=session_id,
|
|
114
|
+
thread_id="main",
|
|
115
|
+
source=SOURCE,
|
|
116
|
+
started_at=first_ts,
|
|
117
|
+
ended_at=last_ts,
|
|
118
|
+
transcript_path=str(path),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
batch.turns = list(turns.values())
|
|
122
|
+
return batch
|